Add cheap model profiles for local adapters (#4881)

## Thinking Path

> - Paperclip is a control plane for autonomous AI companies, where
adapters are the boundary between the board, agents, and execution
runtimes.
> - Local adapters currently expose a primary runtime configuration, but
operators often need a cheaper model lane for routine or low-risk work.
> - That cheap lane has to stay adapter-owned: runtime profile settings
should not mutate the primary adapter config or bypass existing
auth/secret mediation.
> - Issue creation also needs an ergonomic way to request primary,
cheap, or custom model behavior for a selected assignee.
> - This pull request adds a first-class `cheap` model profile contract
across adapter capabilities, heartbeat config resolution, agent
configuration, and issue creation.
> - The benefit is cheaper task execution can be configured and
requested explicitly while preserving adapter boundaries, secret
handling, and audit visibility.

## What Changed

- Added adapter model-profile capability metadata and a `cheap` profile
contract for supported local adapters.
- Applied `runtimeConfig.modelProfiles.cheap.adapterConfig` during
heartbeat config resolution, including requested/applied/fallback run
metadata.
- Added agent configuration UI for cheap model profile settings without
writing those settings into primary `adapterConfig`.
- Added New Issue assignee model lane controls for Primary / Cheap /
Custom and request payload handling.
- Added run ledger profile badges and Storybook stories for the new
cheap-lane UI states.
- Added tests for validators, heartbeat model profile application,
permission/secret mediation, UI payload helpers, and run ledger
rendering.
- Added committed UI verification screenshots under
`docs/pr-screenshots/pap-2837/`.
- Addressed Greptile review feedback around cheap-profile defaults,
shared profile types, and fallback test data.

## Verification

Local:

- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
server/src/__tests__/adapter-registry.test.ts
server/src/__tests__/agent-permissions-routes.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/lib/agent-config-patch.test.ts
ui/src/lib/issue-assignee-overrides.test.ts
ui/src/lib/new-agent-runtime-config.test.ts` — passed, 8 files / 103
tests.
- `pnpm exec vitest run ui/src/lib/new-agent-runtime-config.test.ts
ui/src/components/IssueRunLedger.test.tsx` — passed after
Greptile/rebase follow-up, 2 files / 17 tests.
- `pnpm --filter @paperclipai/ui typecheck` — passed after
Greptile/rebase follow-up.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `pnpm test:run` — did not complete successfully in this local
worktree: it stopped in pre-existing `@paperclipai/adapter-utils`
sandbox/SSH fixture suites outside this PR diff. Failures were 5s local
timeouts plus `git init -b` unsupported by this machine's Git 2.21.0.
The branch-specific targeted suites above passed.
- Branch was fetched/rebased onto `public-gh/master`; `git rev-list
--left-right --count public-gh/master...HEAD` reports `0 9`.

Remote PR checks on latest head
`e30bf399146451c86cee98ed528d51d33fa5af5a`:

- `policy` — passed.
- `verify` — passed.
- `e2e` — passed.
- `Greptile Review` — passed, confidence score 5/5; Greptile review
threads resolved.
- `security/snyk (cryppadotta)` — passed.

Screenshots:

- [New issue cheap lane
desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-cheap-desktop.png)
- [New issue custom lane
desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-custom-desktop.png)
- [New issue unsupported adapter
desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-unsupported-desktop.png)
- [Run ledger model profile badges
desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/runledger-profile-badges-desktop.png)
- Mobile variants are also in `docs/pr-screenshots/pap-2837/`.

## Risks

- Medium: heartbeat config mediation now merges runtime model profiles
into adapter configs, so adapter secret normalization and host-command
restrictions must keep covering nested config paths.
- Medium: the UI adds another issue creation choice; unsupported
adapters must keep hiding the cheap lane and preserve primary behavior.
- Low migration risk: no database migration is included.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex coding agent using GPT-5-class reasoning with repo tool use
and command execution. Exact served model/context window was not exposed
by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-04-30 15:32:04 -05:00 committed by GitHub
parent 1fe1067361
commit a3de1d764d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 2216 additions and 151 deletions

View file

@ -28,6 +28,7 @@ import {
findActiveServerAdapter,
findServerAdapter,
listAdapterModels,
listAdapterModelProfiles,
registerServerAdapter,
requireServerAdapter,
unregisterServerAdapter,
@ -79,6 +80,31 @@ describe("server adapter registry", () => {
]);
});
it("exposes adapter model profiles when adapters declare them", async () => {
const adapterWithProfiles: ServerAdapterModule = {
...externalAdapter,
modelProfiles: [
{
key: "cheap",
label: "Cheap",
adapterConfig: { model: "external-mini" },
source: "adapter_default",
},
],
};
registerServerAdapter(adapterWithProfiles);
expect(await listAdapterModelProfiles("external_test")).toEqual([
{
key: "cheap",
label: "Cheap",
adapterConfig: { model: "external-mini" },
source: "adapter_default",
},
]);
});
it("removes external adapters when unregistered", () => {
registerServerAdapter(externalAdapter);
@ -167,6 +193,45 @@ describe("server adapter registry", () => {
expect(adapter!.supportsLocalAgentJwt).toBe(true);
});
it("built-in local adapters declare cheap model profile defaults where supported", async () => {
await expect(listAdapterModelProfiles("claude_local")).resolves.toEqual([
expect.objectContaining({
key: "cheap",
adapterConfig: expect.objectContaining({ model: "claude-sonnet-4-6" }),
source: "adapter_default",
}),
]);
await expect(listAdapterModelProfiles("codex_local")).resolves.toEqual([
expect.objectContaining({
key: "cheap",
adapterConfig: expect.objectContaining({ model: "gpt-5.3-codex-spark" }),
source: "adapter_default",
}),
]);
await expect(listAdapterModelProfiles("gemini_local")).resolves.toEqual([
expect.objectContaining({
key: "cheap",
adapterConfig: expect.objectContaining({ model: "gemini-2.5-flash-lite" }),
source: "adapter_default",
}),
]);
await expect(listAdapterModelProfiles("opencode_local")).resolves.toEqual([
expect.objectContaining({
key: "cheap",
adapterConfig: expect.objectContaining({ model: "openai/gpt-5.1-codex-mini" }),
source: "adapter_default",
}),
]);
await expect(listAdapterModelProfiles("cursor")).resolves.toEqual([
expect.objectContaining({
key: "cheap",
adapterConfig: expect.objectContaining({ model: "gpt-5.1-codex-mini" }),
source: "adapter_default",
}),
]);
await expect(listAdapterModelProfiles("pi_local")).resolves.toEqual([]);
});
it("switches active adapter behavior back to the builtin when an override is paused", async () => {
const builtIn = findServerAdapter("claude_local");
expect(builtIn).not.toBeNull();

View file

@ -496,6 +496,165 @@ describe.sequential("agent permission routes", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("blocks agent-authenticated self-updates that set cheap-profile host-executed workspace commands", async () => {
mockAgentService.getById.mockResolvedValue({
...baseAgent,
adapterType: "codex_local",
});
const app = await createApp({
type: "agent",
agentId,
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({
runtimeConfig: {
modelProfiles: {
cheap: {
adapterConfig: {
workspaceStrategy: {
type: "git_worktree",
provisionCommand: "touch /tmp/paperclip-rce",
},
},
},
},
},
}));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(res.body.error).toContain(
"runtimeConfig.modelProfiles.cheap.adapterConfig.workspaceStrategy.provisionCommand",
);
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("allows board updates that set cheap-profile workspace commands", async () => {
mockAgentService.getById.mockResolvedValue({
...baseAgent,
adapterType: "codex_local",
});
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const runtimeConfig = {
modelProfiles: {
cheap: {
adapterConfig: {
workspaceStrategy: {
type: "git_worktree",
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
},
},
},
};
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ runtimeConfig }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(
agentId,
expect.objectContaining({ runtimeConfig }),
expect.anything(),
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "agent.updated",
}));
});
it("normalizes cheap-profile env bindings through the adapter config secret pipeline", async () => {
mockAgentService.getById.mockResolvedValue({
...baseAgent,
adapterType: "codex_local",
});
mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => ({
...config,
env: {
API_TOKEN: {
type: "secret_ref",
secretId: "33333333-3333-4333-8333-333333333333",
version: "latest",
},
},
}));
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({
runtimeConfig: {
modelProfiles: {
cheap: {
adapterConfig: {
model: "gpt-5.3-codex-spark",
env: {
API_TOKEN: {
type: "secret_ref",
secretId: "33333333-3333-4333-8333-333333333333",
version: "latest",
},
},
},
},
},
},
}));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockSecretService.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
model: "gpt-5.3-codex-spark",
env: expect.any(Object),
}),
{ strictMode: false },
);
expect(mockAgentService.update).toHaveBeenCalledWith(
agentId,
expect.objectContaining({
runtimeConfig: {
modelProfiles: {
cheap: {
adapterConfig: {
model: "gpt-5.3-codex-spark",
env: {
API_TOKEN: {
type: "secret_ref",
secretId: "33333333-3333-4333-8333-333333333333",
version: "latest",
},
},
},
},
},
},
}),
expect.anything(),
);
});
it("blocks agent-authenticated self-updates that set instructions bundle roots", async () => {
const app = await createApp({
type: "agent",

View file

@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import type { AdapterModelProfileDefinition } from "../adapters/index.js";
import {
mergeModelProfileAdapterConfig,
normalizeModelProfileWakeContext,
resolveModelProfileApplication,
} from "../services/heartbeat.ts";
const cheapProfile: AdapterModelProfileDefinition = {
key: "cheap",
label: "Cheap",
adapterConfig: {
model: "adapter-cheap",
modelReasoningEffort: "low",
},
source: "adapter_default",
};
describe("heartbeat model profile application", () => {
it("applies cheap profile patches before explicit issue adapter config overrides", () => {
const modelProfile = resolveModelProfileApplication({
adapterModelProfiles: [cheapProfile],
agentRuntimeConfig: {},
issueModelProfile: "cheap",
contextSnapshot: {},
});
const merged = mergeModelProfileAdapterConfig({
baseConfig: {
model: "primary",
modelReasoningEffort: "high",
approvalPolicy: "strict",
},
modelProfile,
issueAdapterConfig: {
model: "issue-explicit",
},
});
expect(modelProfile).toMatchObject({
requested: "cheap",
requestedBy: "issue_override",
applied: "cheap",
configSource: "adapter_default",
fallbackReason: null,
});
expect(merged).toEqual({
model: "issue-explicit",
modelReasoningEffort: "low",
approvalPolicy: "strict",
});
});
it("lets agent runtime profile config customize adapter defaults", () => {
const modelProfile = resolveModelProfileApplication({
adapterModelProfiles: [cheapProfile],
agentRuntimeConfig: {
modelProfiles: {
cheap: {
adapterConfig: {
model: "agent-cheap",
},
},
},
},
issueModelProfile: null,
contextSnapshot: { modelProfile: "cheap" },
});
expect(modelProfile).toMatchObject({
requested: "cheap",
requestedBy: "wake_context",
applied: "cheap",
configSource: "agent_runtime",
adapterConfig: {
model: "agent-cheap",
modelReasoningEffort: "low",
},
});
});
it("falls back to the primary config when the adapter does not support the requested profile", () => {
const modelProfile = resolveModelProfileApplication({
adapterModelProfiles: [],
agentRuntimeConfig: {
modelProfiles: {
cheap: {
adapterConfig: {
model: "agent-cheap",
},
},
},
},
issueModelProfile: null,
contextSnapshot: { modelProfile: "cheap" },
});
const merged = mergeModelProfileAdapterConfig({
baseConfig: {
model: "primary",
},
modelProfile,
issueAdapterConfig: null,
});
expect(modelProfile).toMatchObject({
requested: "cheap",
applied: null,
fallbackReason: "adapter_profile_not_supported",
adapterConfig: null,
});
expect(merged).toEqual({ model: "primary" });
});
it("normalizes a wake payload model profile into run context", () => {
const contextSnapshot = normalizeModelProfileWakeContext({
contextSnapshot: {},
payload: { modelProfile: "cheap" },
});
expect(contextSnapshot).toMatchObject({ modelProfile: "cheap" });
});
});