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

@ -20,6 +20,8 @@ export type {
AdapterSkillContext,
AdapterSessionCodec,
AdapterModel,
AdapterModelProfileKey,
AdapterModelProfileDefinition,
HireApprovedPayload,
HireApprovedHookResult,
ConfigFieldOption,

View file

@ -144,6 +144,16 @@ export interface AdapterModel {
label: string;
}
export type AdapterModelProfileKey = "cheap";
export interface AdapterModelProfileDefinition {
key: AdapterModelProfileKey;
label: string;
description?: string;
adapterConfig: Record<string, unknown>;
source?: "adapter_default" | "discovered";
}
export type AdapterEnvironmentCheckLevel = "info" | "warn" | "error";
export interface AdapterEnvironmentCheck {
@ -329,6 +339,8 @@ export interface ServerAdapterModule {
supportsLocalAgentJwt?: boolean;
models?: AdapterModel[];
listModels?: () => Promise<AdapterModel[]>;
modelProfiles?: AdapterModelProfileDefinition[];
listModelProfiles?: () => Promise<AdapterModelProfileDefinition[]>;
/**
* Optional explicit refresh hook for model discovery.
* Use this when the adapter caches discovered models and needs a bypass path
@ -435,6 +447,14 @@ export interface CreateConfigValues {
promptTemplate: string;
model: string;
thinkingEffort: string;
/**
* Optional cheap model profile config for new agents on adapters that
* support model profiles. Persisted under
* `runtimeConfig.modelProfiles.cheap.adapterConfig`, never on the primary
* `adapterConfig`.
*/
cheapModel?: string;
cheapModelEnabled?: boolean;
chrome: boolean;
dangerouslySkipPermissions: boolean;
search: boolean;

View file

@ -1,3 +1,5 @@
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "claude_local";
export const label = "Claude Code (local)";
@ -10,6 +12,19 @@ export const models = [
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
];
export const modelProfiles: AdapterModelProfileDefinition[] = [
{
key: "cheap",
label: "Cheap",
description: "Use Claude Sonnet as the lower-cost Claude Code lane while preserving the agent's primary model.",
adapterConfig: {
model: "claude-sonnet-4-6",
effort: "low",
},
source: "adapter_default",
},
];
export const agentConfigurationDoc = `# claude_local agent configuration
Adapter: claude_local

View file

@ -1,5 +1,8 @@
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "codex_local";
export const label = "Codex (local)";
export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex";
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.4"] as const;
@ -40,6 +43,19 @@ export const models = [
{ id: "codex-mini-latest", label: "Codex Mini" },
];
export const modelProfiles: AdapterModelProfileDefinition[] = [
{
key: "cheap",
label: "Cheap",
description: "Use the lowest-cost known Codex local model lane without changing the primary model.",
adapterConfig: {
model: "gpt-5.3-codex-spark",
modelReasoningEffort: "low",
},
source: "adapter_default",
},
];
export const agentConfigurationDoc = `# codex_local agent configuration
Adapter: codex_local

View file

@ -1,5 +1,8 @@
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "cursor";
export const label = "Cursor CLI (local)";
export const DEFAULT_CURSOR_LOCAL_MODEL = "auto";
const CURSOR_FALLBACK_MODEL_IDS = [
@ -46,6 +49,18 @@ const CURSOR_FALLBACK_MODEL_IDS = [
export const models = CURSOR_FALLBACK_MODEL_IDS.map((id) => ({ id, label: id }));
export const modelProfiles: AdapterModelProfileDefinition[] = [
{
key: "cheap",
label: "Cheap",
description: "Use Cursor's known Codex mini model as the budget lane instead of assuming auto is cheap.",
adapterConfig: {
model: "gpt-5.1-codex-mini",
},
source: "adapter_default",
},
];
export const agentConfigurationDoc = `# cursor agent configuration
Adapter: cursor

View file

@ -1,5 +1,8 @@
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "gemini_local";
export const label = "Gemini CLI (local)";
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
export const models = [
@ -11,6 +14,18 @@ export const models = [
{ id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
];
export const modelProfiles: AdapterModelProfileDefinition[] = [
{
key: "cheap",
label: "Cheap",
description: "Use Gemini Flash Lite as the budget Gemini CLI lane while preserving the primary model.",
adapterConfig: {
model: "gemini-2.5-flash-lite",
},
source: "adapter_default",
},
];
export const agentConfigurationDoc = `# gemini_local agent configuration
Adapter: gemini_local

View file

@ -1,3 +1,5 @@
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "opencode_local";
export const label = "OpenCode (local)";
@ -11,6 +13,19 @@ export const models: Array<{ id: string; label: string }> = [
{ id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" },
];
export const modelProfiles: AdapterModelProfileDefinition[] = [
{
key: "cheap",
label: "Cheap",
description: "Use OpenCode's known Codex mini model as the budget lane.",
adapterConfig: {
model: "openai/gpt-5.1-codex-mini",
variant: "low",
},
source: "adapter_default",
},
];
export const agentConfigurationDoc = `# opencode_local agent configuration
Adapter: opencode_local

View file

@ -1,8 +1,12 @@
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "pi_local";
export const label = "Pi (local)";
export const models: Array<{ id: string; label: string }> = [];
export const modelProfiles: AdapterModelProfileDefinition[] = [];
export const agentConfigurationDoc = `# pi_local agent configuration
Adapter: pi_local

View file

@ -73,6 +73,10 @@ export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 5;
export const WORKSPACE_BRANCH_ROUTINE_VARIABLE = "workspaceBranch";
export const MODEL_PROFILE_KEYS = ["cheap"] as const;
export type ModelProfileKey = (typeof MODEL_PROFILE_KEYS)[number];
export const AGENT_ICON_NAMES = [
"bot",
"cpu",

View file

@ -13,6 +13,7 @@ export {
AGENT_ROLE_LABELS,
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
MODEL_PROFILE_KEYS,
AGENT_ICON_NAMES,
ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUSES,
@ -117,6 +118,7 @@ export {
type AgentStatus,
type AgentAdapterType,
type AgentRole,
type ModelProfileKey,
type AgentIconName,
type IssueStatus,
type IssuePriority,

View file

@ -1,5 +1,6 @@
import type {
AgentAdapterType,
ModelProfileKey,
PauseReason,
AgentRole,
AgentStatus,
@ -13,6 +14,16 @@ export interface AgentPermissions {
canCreateAgents: boolean;
}
export interface AgentModelProfileConfig {
enabled?: boolean;
label?: string;
adapterConfig: Record<string, unknown>;
}
export interface AgentRuntimeConfig extends Record<string, unknown> {
modelProfiles?: Partial<Record<ModelProfileKey, AgentModelProfileConfig>>;
}
export type AgentInstructionsBundleMode = "managed" | "external";
export interface AgentInstructionsFileSummary {
@ -72,7 +83,7 @@ export interface Agent {
capabilities: string | null;
adapterType: AgentAdapterType;
adapterConfig: Record<string, unknown>;
runtimeConfig: Record<string, unknown>;
runtimeConfig: AgentRuntimeConfig;
defaultEnvironmentId?: string | null;
budgetMonthlyCents: number;
spentMonthlyCents: number;

View file

@ -74,7 +74,9 @@ export type {
AgentAccessState,
AgentChainOfCommandEntry,
AgentDetail,
AgentModelProfileConfig,
AgentPermissions,
AgentRuntimeConfig,
AgentInstructionsBundleMode,
AgentInstructionsFileSummary,
AgentInstructionsFileDetail,

View file

@ -6,6 +6,7 @@ import type {
IssueExecutionStateStatus,
IssueOriginKind,
IssuePriority,
ModelProfileKey,
IssueThreadInteractionContinuationPolicy,
IssueThreadInteractionKind,
IssueThreadInteractionStatus,
@ -59,6 +60,7 @@ export interface IssueLabel {
}
export interface IssueAssigneeAdapterOverrides {
modelProfile?: ModelProfileKey;
adapterConfig?: Record<string, unknown>;
useProjectWorkspace?: boolean;
}

View file

@ -51,6 +51,18 @@ export const createAgentInstructionsBundleSchema = z.object({
}),
});
const agentModelProfileConfigSchema = z.object({
enabled: z.boolean().optional(),
label: z.string().trim().min(1).optional(),
adapterConfig: adapterConfigSchema,
}).strict();
export const agentRuntimeConfigSchema = z.object({
modelProfiles: z.object({
cheap: agentModelProfileConfigSchema.optional(),
}).strict().optional(),
}).catchall(z.unknown());
export const createAgentSchema = z.object({
name: z.string().min(1),
role: z.enum(AGENT_ROLES).optional().default("general"),
@ -62,7 +74,7 @@ export const createAgentSchema = z.object({
adapterType: agentAdapterTypeSchema,
adapterConfig: adapterConfigSchema.optional().default({}),
instructionsBundle: createAgentInstructionsBundleSchema.optional(),
runtimeConfig: z.record(z.unknown()).optional().default({}),
runtimeConfig: agentRuntimeConfigSchema.optional().default({}),
defaultEnvironmentId: z.string().uuid().optional().nullable(),
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
permissions: agentPermissionsSchema.optional(),

View file

@ -107,6 +107,7 @@ export {
createAgentSchema,
createAgentHireSchema,
updateAgentSchema,
agentRuntimeConfigSchema,
agentInstructionsBundleModeSchema,
updateAgentInstructionsBundleSchema,
upsertAgentInstructionsFileSchema,

View file

@ -8,6 +8,7 @@ import {
updateIssueSchema,
upsertIssueDocumentSchema,
} from "./issue.js";
import { createAgentSchema } from "./agent.js";
describe("issue validators", () => {
it("passes real line breaks through unchanged", () => {
@ -93,4 +94,87 @@ describe("issue validators", () => {
expect(parsed.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
});
it("accepts the cheap model profile in issue assignee adapter overrides", () => {
const parsed = createIssueSchema.parse({
title: "Run a cheap heartbeat",
assigneeAdapterOverrides: {
modelProfile: "cheap",
},
});
expect(parsed.assigneeAdapterOverrides?.modelProfile).toBe("cheap");
});
it("rejects unknown issue model profile keys", () => {
const parsed = updateIssueSchema.safeParse({
assigneeAdapterOverrides: {
modelProfile: "fast",
},
});
expect(parsed.success).toBe(false);
});
it("validates agent runtime cheap model profile config without rejecting other runtime fields", () => {
const parsed = createAgentSchema.parse({
name: "Coder",
adapterType: "codex_local",
runtimeConfig: {
heartbeat: { enabled: true },
modelProfiles: {
cheap: {
enabled: true,
label: "Cheap Codex",
adapterConfig: {
model: "gpt-5.3-codex-spark",
},
},
},
},
});
expect(parsed.runtimeConfig.modelProfiles?.cheap?.adapterConfig).toEqual({
model: "gpt-5.3-codex-spark",
});
expect(parsed.runtimeConfig.heartbeat).toEqual({ enabled: true });
});
it("validates cheap model profile env bindings like top-level adapter config", () => {
const parsed = createAgentSchema.safeParse({
name: "Coder",
adapterType: "codex_local",
runtimeConfig: {
modelProfiles: {
cheap: {
adapterConfig: {
env: {
API_TOKEN: 123,
},
},
},
},
},
});
expect(parsed.success).toBe(false);
});
it("rejects unknown agent runtime model profile keys", () => {
const parsed = createAgentSchema.safeParse({
name: "Coder",
adapterType: "codex_local",
runtimeConfig: {
modelProfiles: {
fast: {
adapterConfig: {
model: "gpt-5-mini",
},
},
},
},
});
expect(parsed.success).toBe(false);
});
});

View file

@ -10,6 +10,7 @@ import {
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES,
MODEL_PROFILE_KEYS,
} from "../constants.js";
import { multilineTextSchema } from "./text.js";
@ -44,6 +45,7 @@ export const issueExecutionWorkspaceSettingsSchema = z
export const issueAssigneeAdapterOverridesSchema = z
.object({
modelProfile: z.enum(MODEL_PROFILE_KEYS).optional(),
adapterConfig: z.record(z.unknown()).optional(),
useProjectWorkspace: z.boolean().optional(),
})