diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index e158877a66..d18ff1c7a9 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -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: diff --git a/packages/adapters/claude-local/src/index.test.ts b/packages/adapters/claude-local/src/index.test.ts new file mode 100644 index 0000000000..8add6054b5 --- /dev/null +++ b/packages/adapters/claude-local/src/index.test.ts @@ -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"); + }); +}); diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 703a69eef1..1af3540bde 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -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 { + 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 diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index 3ec97c6813..d5c4aacb09 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -237,6 +237,28 @@ function buildContext(root: string, overrides: Partial } 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", diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index 019e06baa6..d26c48d7e4 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -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): Record { +export function buildClaudeAcpConfig( + config: Record, + inheritedEnv: Record = {}, +): Record { + 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): Record { 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; 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", diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 0f24419c9f..e875157464 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -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 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 { + 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); diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index bdc8f27ca9..2d7190c56b 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -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" }), diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 2ee76300c6..921e6c98d2 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -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"}