fix(claude): default unset models to Opus 5 (#13055)

Resolve unset Claude models to Opus 5 across CLI and ACP execution, preserve explicit and provider-specific overrides, and show the default in agent configuration.

Verified 212 focused tests after merging master, UI typecheck and token gates, and all CI checks. Greptile reviewed the final head at 5/5.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 15:13:27 -05:00 committed by GitHub
parent ebaeba40ee
commit 5128b4f323
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 155 additions and 10 deletions

View File

@ -17,7 +17,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
| `model` | string | No | Claude model to use (e.g. `claude-opus-4-6`) |
| `model` | string | No | Claude model to use (default: `claude-opus-5`) |
| `promptTemplate` | string | No | Prompt used for all runs |
| `env` | object | No | Environment variables (supports secret refs) |
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
@ -25,6 +25,23 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) |
| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (default: `true`); required for headless runs where interactive approval is impossible |
## Default model
An omitted, empty, or whitespace-only `model` uses Claude Opus 5
(`claude-opus-5`) on both the CLI and ACP engines. This also applies to existing
agents with an unset model, including agents created through the API and agents
running in sandboxes. No database migration is needed. The editor shows the
Paperclip default and leaves the setting unset until you select a model.
An explicit `model` takes precedence over `ANTHROPIC_MODEL`. When only
`ANTHROPIC_MODEL` is configured, the adapter keeps that override. Bedrock and
Vertex configurations without an explicit model keep their provider-specific
default because those providers use different model IDs. Host environment
settings apply only to local targets when resolving the model.
The default does not change explicitly configured agent models or the separate
Paperclip Runner's qualified provider profiles.
## Prompt Templates
Templates support `{{variable}}` substitution:

View File

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CLAUDE_LOCAL_MODEL, resolveClaudeModel } from "./index.js";
describe("Claude model defaults", () => {
it.each([undefined, null, "", " "])("uses Opus 5 for an unset model (%j)", (model) => {
expect(DEFAULT_CLAUDE_LOCAL_MODEL).toBe("claude-opus-5");
expect(resolveClaudeModel(model)).toBe("claude-opus-5");
});
it("keeps explicit model IDs ahead of environment overrides", () => {
expect(resolveClaudeModel(" claude-sonnet-4-5 ", { ANTHROPIC_MODEL: "opus" }))
.toBe("claude-sonnet-4-5");
expect(resolveClaudeModel("", { ANTHROPIC_MODEL: " custom-model " })).toBe("custom-model");
});
it.each([
{ CLAUDE_CODE_USE_BEDROCK: "1" },
{ CLAUDE_CODE_USE_BEDROCK: "true" },
{ ANTHROPIC_BEDROCK_BASE_URL: "https://bedrock.example" },
{ CLAUDE_CODE_USE_VERTEX: "1" },
])("keeps provider-specific defaults for %j", (env) => {
expect(resolveClaudeModel(undefined, env)).toBe("");
expect(resolveClaudeModel("provider-model", env)).toBe("provider-model");
});
});

View File

@ -1,3 +1,26 @@
export const DEFAULT_CLAUDE_LOCAL_MODEL = "claude-opus-5";
/** Resolve Paperclip's default without replacing an explicit provider model. */
export function resolveClaudeModel(
model: unknown,
env: Record<string, unknown> = {},
): string {
const configured = typeof model === "string" ? model.trim() : "";
if (configured) return configured;
const environmentModel = typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL.trim() : "";
if (environmentModel) return environmentModel;
// These providers use their own model IDs and region-specific defaults.
const providerFlag = (value: unknown) => value === "1" || value === "true";
if (
providerFlag(env.CLAUDE_CODE_USE_BEDROCK)
|| providerFlag(env.CLAUDE_CODE_USE_VERTEX)
|| (typeof env.ANTHROPIC_BEDROCK_BASE_URL === "string" && env.ANTHROPIC_BEDROCK_BASE_URL.trim())
) {
return "";
}
return DEFAULT_CLAUDE_LOCAL_MODEL;
}
export const type = "claude_local";
export const label = "Claude Code";
@ -25,7 +48,7 @@ Core fields:
- engine (string, optional): execution engine. Leave unset/auto to use ACP when prerequisites pass and fall back to the Claude Code CLI with diagnostics. Use "cli" to pin the CLI lane or "acp" to require ACP.
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime
- model (string, optional): Claude model id
- model (string, optional): Claude model id. Missing or blank defaults to ${DEFAULT_CLAUDE_LOCAL_MODEL} in both CLI and ACP, including existing agents. Explicit model IDs and ANTHROPIC_MODEL overrides are preserved. Bedrock/Vertex without an explicit model retain their provider default.
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
- chrome (boolean, optional): pass --chrome when running Claude
- promptTemplate (string, optional): run prompt template

View File

@ -237,6 +237,28 @@ function buildContext(root: string, overrides: Partial<AdapterExecutionContext>
}
describe("claude_local ACP lane", () => {
it("uses the same default model in ACP startup and session identity", async () => {
const root = await makeTempRoot("paperclip-claude-acp-default-");
const meta: AdapterInvocationMeta[] = [];
const execute = createClaudeAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
});
const result = await execute(buildContext(root, {
onMeta: async (payload) => { meta.push(payload); },
}));
expect(result.exitCode).toBe(0);
expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-opus-5");
});
it("keeps ACP model precedence consistent with CLI and provider overrides", () => {
expect(buildClaudeAcpConfig({ model: "claude-sonnet-4-5", env: { ANTHROPIC_MODEL: "opus" } }))
.toMatchObject({ model: "claude-sonnet-4-5", env: { ANTHROPIC_MODEL: "claude-sonnet-4-5" } });
expect(buildClaudeAcpConfig({}, { ANTHROPIC_MODEL: "custom-model" }))
.toMatchObject({ model: "custom-model", env: { ANTHROPIC_MODEL: "custom-model" } });
expect(buildClaudeAcpConfig({}, { CLAUDE_CODE_USE_BEDROCK: "1" }).model).toBe("");
expect(buildClaudeAcpConfig({ env: { CLAUDE_CODE_USE_VERTEX: "1" } }).model).toBe("");
});
it("maps Claude config to the ACPX Claude target", () => {
expect(buildClaudeAcpConfig({
engine: "acp",

View File

@ -53,7 +53,7 @@ import { buildLocalAdapterTestProbeEnv } from "./probe-env.js";
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
import { buildClaudeProbePermissionArgs } from "./permissions.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { resolveClaudeModel, SANDBOX_INSTALL_COMMAND } from "../index.js";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageRootDir = path.resolve(moduleDir, "../..");
@ -127,7 +127,12 @@ function firstNonEmptyString(...values: unknown[]): string | undefined {
return undefined;
}
export function buildClaudeAcpConfig(config: Record<string, unknown>): Record<string, unknown> {
export function buildClaudeAcpConfig(
config: Record<string, unknown>,
inheritedEnv: Record<string, unknown> = {},
): Record<string, unknown> {
const env = parseObject(config.env);
const model = resolveClaudeModel(config.model, { ...inheritedEnv, ...env });
const agentCommand = firstNonEmptyString(config.agentCommand, config.acpAgentCommand);
const stateDir = firstNonEmptyString(config.stateDir, config.acpStateDir);
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;
@ -144,6 +149,9 @@ export function buildClaudeAcpConfig(config: Record<string, unknown>): Record<st
return {
...config,
model,
// ACP reads ANTHROPIC_MODEL at startup; keep it aligned with CLI precedence.
...(model ? { env: { ...env, ANTHROPIC_MODEL: model } } : {}),
agent: "claude",
mode,
permissionMode,
@ -349,9 +357,13 @@ export function createClaudeAcpExecutor(options: ClaudeAcpExecutorOptions = {}):
currentExecutor = createAcpxEngineExecutor(withClaudeAcpDefaults(options));
executor = currentExecutor;
}
const target = readAdapterExecutionTarget({
executionTarget: ctx.executionTarget,
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
});
const result = await currentExecutor({
...ctx,
config: buildClaudeAcpConfig(ctx.config),
config: buildClaudeAcpConfig(ctx.config, target?.kind === "remote" ? {} : process.env),
});
return mapClaudeAcpAuthErrorCode(result);
};

View File

@ -85,6 +85,7 @@ describe("claude remote execution", () => {
afterEach(async () => {
vi.clearAllMocks();
vi.unstubAllEnvs();
resetClaudeCliCapabilitiesCacheForTests();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
@ -94,6 +95,8 @@ describe("claude remote execution", () => {
});
it("prepares the workspace, syncs Claude runtime assets, and restores workspace changes for remote SSH execution", async () => {
vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", "1");
vi.stubEnv("ANTHROPIC_MODEL", "host-only-model");
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
@ -188,6 +191,7 @@ describe("claude remote execution", () => {
const call = runChildProcess.mock.calls[0] as unknown as
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
expect(call?.[2]).toEqual(expect.arrayContaining(["--model", "claude-opus-5"]));
expect(call?.[2]).toContain("--allowedTools");
expect(call?.[2]).toContain(
"Task AskUserQuestion Bash CronCreate CronDelete CronList Edit EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Glob Grep Monitor NotebookEdit PushNotification Read RemoteTrigger ScheduleWakeup Skill TaskOutput TaskStop TodoWrite ToolSearch WebFetch WebSearch Write",

View File

@ -92,7 +92,7 @@ import { resolveClaudeDesiredSkillNames } from "./skills.js";
import { isBedrockModelId } from "./models.js";
import { prepareClaudePromptBundle } from "./prompt-cache.js";
import { buildClaudeExecutionPermissionArgs } from "./permissions.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { resolveClaudeModel, SANDBOX_INSTALL_COMMAND } from "../index.js";
import {
createClaudeAcpExecutor,
formatClaudeAcpFallbackMessage,
@ -431,7 +431,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const model = asString(config.model, "");
const effort = asString(config.effort, "");
const chrome = asBoolean(config.chrome, false);
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
@ -490,6 +489,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const modelEnv = executionTargetIsRemote ? env : effectiveEnv;
const model = resolveClaudeModel(config.model, modelEnv);
const billingType = resolveClaudeBillingType(effectiveEnv);
const claudeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkillNames = new Set(resolveClaudeDesiredSkillNames(config, claudeSkillEntries));
@ -870,7 +871,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
heartbeatPromptChars: renderedPrompt.length,
};
const passesConfiguredModel = Boolean(
model && (!isBedrockAuth(effectiveEnv) || isBedrockModelId(model)),
model && (!isBedrockAuth(modelEnv) || isBedrockModelId(model)),
);
const buildClaudeArgs = (

View File

@ -34,7 +34,7 @@ import {
import { isBedrockModelId } from "./models.js";
import { buildClaudeProbePermissionArgs } from "./permissions.js";
import { prepareSandboxClaudeProbeRuntime } from "./claude-config.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { resolveClaudeModel, SANDBOX_INSTALL_COMMAND } from "../index.js";
import { resolveClaudeExecutionEngineForRun, testClaudeAcpEnvironment } from "./acp.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
import {
@ -239,7 +239,7 @@ export async function testEnvironment(
check.code !== "claude_managed_config_dir_failed",
);
let configuredModelIsCompatible = true;
const configuredModel = asString(config.model, "").trim();
const configuredModel = resolveClaudeModel(config.model, considerHostEnv ? { ...process.env, ...env } : env);
const minimumCliVersion =
claudeCommandLooksLike(command, "claude") &&
(!hasBedrock || isBedrockModelId(configuredModel))

View File

@ -350,6 +350,34 @@ function createLocalSandboxRunner() {
}
describe("claude execute", () => {
it.each([
[undefined, "claude-opus-5"],
["", "claude-opus-5"],
[" ", "claude-opus-5"],
["claude-sonnet-4-5", "claude-sonnet-4-5"],
])("passes the resolved model to the CLI for %j", async (model, expected) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-default-"));
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root);
try {
const result = await execute({
runId: "run-default",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
engine: "cli", model, command: commandPath, cwd: workspace,
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
},
context: {}, onLog: async () => {},
});
expect(result.exitCode).toBe(0);
const { argv } = JSON.parse(await fs.readFile(capturePath, "utf8"));
expect(argv[argv.indexOf("--model") + 1]).toBe(expected);
} finally {
restore();
await fs.rm(root, { recursive: true, force: true });
}
});
it("uses a strict per-agent MCP config only when managed servers are present", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-mcp-config-"));
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root);

View File

@ -872,6 +872,17 @@ describe("AgentConfigForm environment selector", () => {
});
});
it("names the Claude default for new and existing agents without pinning it", async () => {
const environments = [makeEnvironment({ id: "local-1", name: "Local", driver: "local" })];
const existing = await renderForm(environments, { adapterType: "claude_local", adapterConfig: {} });
roots.push(existing.root);
const created = await renderCreateForm(environments, { adapterType: "claude_local", model: "" });
roots.push(created.root);
expect(existing.container.textContent).toContain("Default (claude-opus-5)");
expect(created.container.textContent).toContain("Default (claude-opus-5)");
expect(existing.onSave).not.toHaveBeenCalled();
});
it("keeps secret access out of the main Configuration content", async () => {
const result = await renderForm([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),

View File

@ -24,6 +24,7 @@ import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CLAUDE_LOCAL_MODEL } from "@paperclipai/adapter-claude-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local";
@ -1662,6 +1663,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}}
open={modelOpen}
onOpenChange={setModelOpen}
defaultLabel={adapterType === "claude_local" ? `Default (${DEFAULT_CLAUDE_LOCAL_MODEL})` : undefined}
allowDefault={adapterType !== "opencode_local" && adapterType !== "pi_local"}
required={adapterType === "opencode_local" || adapterType === "pi_local"}
groupByProvider={adapterType === "opencode_local" || adapterType === "pi_local"}