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:
parent
ebaeba40ee
commit
5128b4f323
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Reference in New Issue