From 62cf54484ff7d5dcee8774033dfcb188573fb77a Mon Sep 17 00:00:00 2001 From: Alex Knight <15041791+amknight@users.noreply.github.com> Date: Sat, 16 May 2026 21:01:23 +1000 Subject: [PATCH] fix(anthropic): preserve Claude CLI runtime migration --- CHANGELOG.md | 1 + extensions/anthropic/cli-migration.test.ts | 81 ++++++++++++++----- extensions/anthropic/cli-migration.ts | 57 ++++++++++++-- extensions/anthropic/config-defaults.ts | 83 ++++++++++++++++++-- extensions/anthropic/index.test.ts | 2 +- src/gateway/gateway-cli-backend.live.test.ts | 6 +- 6 files changed, 196 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 417aa5f75c4..0f9b4af2edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - MS Teams/media: sniff inline `data:image/*` attachment bytes before staging them, skipping payloads that are not actually images. - WebChat/media: require trusted local-media provenance before preserving local audio reply paths for display, so untrusted audio-looking paths go through normal staging and read-policy checks. - Agents/tool media: preserve trusted local-media provenance when merging generated tool attachments into final reply payloads, so trusted audio/media survives outbound display normalization. +- Anthropic/Claude CLI: write model-scoped `claude-cli` runtime policy when reusing local Claude CLI auth, so upgraded Telegram and Dashboard gateway turns keep using the CLI backend instead of falling through to Anthropic API billing. Fixes #82344. Thanks @amknight. - Update: let package-swap `doctor --fix` persist core config repairs while plugin schemas are still converging, preventing update failures on externalized channel configs. - Update: carry plugin-validation bypasses into config mutation pre-write reads, so package update doctor repairs can finish while externalized plugin schemas are converging. - Update/doctor: keep plugin-validation bypasses on the top-level `$include` config write path, so package repair can update included plugin config files without flattening them into the root config. diff --git a/extensions/anthropic/cli-migration.test.ts b/extensions/anthropic/cli-migration.test.ts index aadf4924ba4..0ced54c1f13 100644 --- a/extensions/anthropic/cli-migration.test.ts +++ b/extensions/anthropic/cli-migration.test.ts @@ -133,12 +133,18 @@ describe("anthropic cli migration", () => { }, agentRuntime: { id: "claude-cli" }, models: { - "anthropic/claude-opus-4-7": { alias: "Opus" }, - "anthropic/claude-sonnet-4-6": {}, - "anthropic/claude-opus-4-6": { alias: "Opus" }, - "anthropic/claude-opus-4-5": {}, - "anthropic/claude-sonnet-4-5": {}, - "anthropic/claude-haiku-4-5": {}, + "anthropic/claude-opus-4-7": { + alias: "Opus", + agentRuntime: { id: "claude-cli" }, + }, + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-opus-4-6": { + alias: "Opus", + agentRuntime: { id: "claude-cli" }, + }, + "anthropic/claude-opus-4-5": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-sonnet-4-5": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-haiku-4-5": { agentRuntime: { id: "claude-cli" } }, "openai/gpt-5.2": {}, }, }, @@ -165,12 +171,12 @@ describe("anthropic cli migration", () => { agentRuntime: { id: "claude-cli" }, models: { "openai/gpt-5.2": {}, - "anthropic/claude-opus-4-7": {}, - "anthropic/claude-sonnet-4-6": {}, - "anthropic/claude-opus-4-6": {}, - "anthropic/claude-opus-4-5": {}, - "anthropic/claude-sonnet-4-5": {}, - "anthropic/claude-haiku-4-5": {}, + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-opus-4-5": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-sonnet-4-5": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-haiku-4-5": { agentRuntime: { id: "claude-cli" } }, }, }, }, @@ -195,18 +201,55 @@ describe("anthropic cli migration", () => { model: { primary: "anthropic/claude-opus-4-7" }, agentRuntime: { id: "claude-cli" }, models: { - "anthropic/claude-opus-4-7": {}, - "anthropic/claude-sonnet-4-6": {}, - "anthropic/claude-opus-4-6": {}, - "anthropic/claude-opus-4-5": {}, - "anthropic/claude-sonnet-4-5": {}, - "anthropic/claude-haiku-4-5": {}, + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-opus-4-5": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-sonnet-4-5": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-haiku-4-5": { agentRuntime: { id: "claude-cli" } }, }, }, }, }); }); + it("preserves explicit model runtime policy while filling missing Claude CLI policies", () => { + const result = buildAnthropicCliMigrationResult({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-7", + fallbacks: ["anthropic/claude-sonnet-4-6"], + }, + models: { + "anthropic/claude-opus-4-7": { + alias: "Opus", + agentRuntime: { id: "pi" }, + }, + "anthropic/claude-sonnet-4-6": { + alias: "Sonnet", + agentRuntime: { id: "auto" }, + }, + }, + }, + }, + }); + + const defaults = result.configPatch?.agents?.defaults; + if (!defaults) { + throw new Error("Expected Claude CLI migration to return default agent config"); + } + + expect(defaults.models?.["anthropic/claude-opus-4-7"]).toEqual({ + alias: "Opus", + agentRuntime: { id: "pi" }, + }); + expect(defaults.models?.["anthropic/claude-sonnet-4-6"]).toEqual({ + alias: "Sonnet", + agentRuntime: { id: "claude-cli" }, + }); + }); + it("registered cli auth tells users to run claude auth login when local auth is missing", async () => { readClaudeCliCredentialsForSetup.mockReturnValue(null); const method = await resolveAnthropicCliAuthMethod(); @@ -340,9 +383,11 @@ describe("anthropic cli migration", () => { expect(defaults?.agentRuntime?.id).toBe("claude-cli"); expect(defaults?.models?.["anthropic/claude-opus-4-7"]).toEqual({ alias: "Opus", + agentRuntime: { id: "claude-cli" }, }); expect(defaults?.models?.["anthropic/claude-opus-4-6"]).toEqual({ alias: "Opus", + agentRuntime: { id: "claude-cli" }, }); expect(defaults?.models?.["openai/gpt-5.2"]).toEqual({}); }); diff --git a/extensions/anthropic/cli-migration.ts b/extensions/anthropic/cli-migration.ts index d1ac28c720b..06c2424f593 100644 --- a/extensions/anthropic/cli-migration.ts +++ b/extensions/anthropic/cli-migration.ts @@ -35,23 +35,29 @@ function toAnthropicModelRef(raw: string): string | null { return `anthropic/${modelId}`; } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + function rewriteModelSelection(model: AgentDefaultsModel): { value: AgentDefaultsModel; primary?: string; + runtimeRefs: string[]; changed: boolean; } { if (typeof model === "string") { const converted = toAnthropicModelRef(model); return converted - ? { value: converted, primary: converted, changed: true } - : { value: model, changed: false }; + ? { value: converted, primary: converted, runtimeRefs: [converted], changed: true } + : { value: model, runtimeRefs: [], changed: false }; } if (!model || typeof model !== "object" || Array.isArray(model)) { - return { value: model, changed: false }; + return { value: model, runtimeRefs: [], changed: false }; } const current = model as Record; const next: Record = { ...current }; + const runtimeRefs: string[] = []; let changed = false; let primary: string | undefined; @@ -60,15 +66,23 @@ function rewriteModelSelection(model: AgentDefaultsModel): { if (converted) { next.primary = converted; primary = converted; + runtimeRefs.push(converted); changed = true; } } const currentFallbacks = current.fallbacks; if (Array.isArray(currentFallbacks)) { - const nextFallbacks = currentFallbacks.map((entry) => - typeof entry === "string" ? (toAnthropicModelRef(entry) ?? entry) : entry, - ); + const nextFallbacks = currentFallbacks.map((entry) => { + if (typeof entry !== "string") { + return entry; + } + const converted = toAnthropicModelRef(entry); + if (converted) { + runtimeRefs.push(converted); + } + return converted ?? entry; + }); if (nextFallbacks.some((entry, index) => entry !== currentFallbacks[index])) { next.fallbacks = nextFallbacks; changed = true; @@ -78,6 +92,7 @@ function rewriteModelSelection(model: AgentDefaultsModel): { return { value: changed ? next : model, ...(primary ? { primary } : {}), + runtimeRefs, changed, }; } @@ -116,11 +131,19 @@ function rewriteModelEntryMap(models: Record | undefined): { function seedClaudeCliAllowlist( models: NonNullable, + selectedRefs: readonly string[] = [], ): NonNullable { const next = { ...models }; + const runtimeRefs = new Set(); for (const ref of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) { const canonicalRef = toAnthropicModelRef(ref) ?? ref; - next[canonicalRef] = next[canonicalRef] ?? {}; + runtimeRefs.add(canonicalRef); + } + for (const ref of selectedRefs) { + runtimeRefs.add(ref); + } + for (const ref of runtimeRefs) { + next[ref] = modelEntryWithClaudeCliRuntime(next[ref]); } return next; } @@ -136,6 +159,21 @@ function selectClaudeCliRuntime(agentRuntime: AgentDefaultsRuntimePolicy | undef }; } +function modelEntryWithClaudeCliRuntime(entry: unknown): Record { + const base = isRecord(entry) ? { ...entry } : {}; + const currentRuntimeId = isRecord(base.agentRuntime) ? base.agentRuntime.id : undefined; + const currentRuntime = + typeof currentRuntimeId === "string" ? normalizeLowercaseStringOrEmpty(currentRuntimeId) : ""; + if (currentRuntime && currentRuntime !== "auto") { + return base; + } + base.agentRuntime = { + ...(isRecord(base.agentRuntime) ? base.agentRuntime : {}), + id: CLAUDE_CLI_BACKEND_ID, + }; + return base; +} + export function hasClaudeCliAuth(options?: { allowKeychainPrompt?: boolean }): boolean { return Boolean( options?.allowKeychainPrompt === false @@ -187,7 +225,10 @@ export function buildAnthropicCliMigrationResult( const existingModels = (rewrittenModels.value ?? defaults?.models ?? {}) as NonNullable; - const nextModels = seedClaudeCliAllowlist(existingModels); + const nextModels = seedClaudeCliAllowlist(existingModels, [ + ...rewrittenModel.runtimeRefs, + ...rewrittenModels.migrated, + ]); const defaultModel = rewrittenModel.primary ?? "anthropic/claude-opus-4-7"; return { diff --git a/extensions/anthropic/config-defaults.ts b/extensions/anthropic/config-defaults.ts index bd0e3479fb7..85b29c38ab6 100644 --- a/extensions/anthropic/config-defaults.ts +++ b/extensions/anthropic/config-defaults.ts @@ -16,6 +16,10 @@ function normalizeProviderId(provider: string): string { return normalized; } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + function resolveAnthropicDefaultAuthMode( config: OpenClawConfig, env: NodeJS.ProcessEnv, @@ -154,9 +158,16 @@ function usesClaudeCliModelSelection(config: OpenClawConfig): boolean { if (parsedPrimary?.provider === CLAUDE_CLI_BACKEND_ID) { return true; } - return Object.keys(config.agents?.defaults?.models ?? {}).some((key) => { + return Object.entries(config.agents?.defaults?.models ?? {}).some(([key, entry]) => { const parsed = parseProviderModelRef(key, "anthropic"); - return parsed?.provider === CLAUDE_CLI_BACKEND_ID; + if (parsed?.provider === CLAUDE_CLI_BACKEND_ID) { + return true; + } + const runtimeId = isRecord(entry?.agentRuntime) ? entry.agentRuntime.id : undefined; + return ( + parsed?.provider === "anthropic" && + normalizeLowercaseStringOrEmpty(runtimeId) === CLAUDE_CLI_BACKEND_ID + ); }); } @@ -166,6 +177,63 @@ function toCanonicalAnthropicModelRef(ref: string): string { : ref; } +function toClaudeCliRuntimeModelRef(raw: string): string | null { + const ref = resolveAnthropicPrimaryModelRef(raw); + if (!ref) { + return null; + } + const parsed = parseProviderModelRef(ref, "anthropic"); + if (!parsed) { + return null; + } + if (parsed.provider !== "anthropic" && parsed.provider !== CLAUDE_CLI_BACKEND_ID) { + return null; + } + if (!normalizeLowercaseStringOrEmpty(parsed.model).startsWith("claude-")) { + return null; + } + return `anthropic/${parsed.model}`; +} + +function modelEntryWithClaudeCliRuntime(entry: unknown): Record { + const base = isRecord(entry) ? { ...entry } : {}; + const currentRuntimeId = isRecord(base.agentRuntime) ? base.agentRuntime.id : undefined; + const currentRuntime = normalizeLowercaseStringOrEmpty(currentRuntimeId); + if (currentRuntime && currentRuntime !== "auto") { + return base; + } + base.agentRuntime = { + ...(isRecord(base.agentRuntime) ? base.agentRuntime : {}), + id: CLAUDE_CLI_BACKEND_ID, + }; + return base; +} + +function collectClaudeCliRuntimeRefs( + model: string | { primary?: string; fallbacks?: string[] } | undefined, +): string[] { + const refs = new Set(); + if (typeof model === "string") { + const ref = toClaudeCliRuntimeModelRef(model); + if (ref) { + refs.add(ref); + } + return [...refs]; + } + const primary = + typeof model?.primary === "string" ? toClaudeCliRuntimeModelRef(model.primary) : null; + if (primary) { + refs.add(primary); + } + for (const fallback of model?.fallbacks ?? []) { + const ref = toClaudeCliRuntimeModelRef(fallback); + if (ref) { + refs.add(ref); + } + } + return [...refs]; +} + function normalizeAnthropicProviderConfig( providerConfig: T, ): T { @@ -290,12 +358,17 @@ export function applyAnthropicConfigDefaults(params: { if (authMode === "oauth" && usesClaudeCliModelSelection(params.config)) { const nextModels = defaults.models ? { ...defaults.models } : {}; let modelsMutated = false; + const runtimeRefs = new Set(collectClaudeCliRuntimeRefs(defaults.model)); for (const rawRef of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) { - const ref = toCanonicalAnthropicModelRef(rawRef); - if (ref in nextModels) { + runtimeRefs.add(toCanonicalAnthropicModelRef(rawRef)); + } + for (const ref of runtimeRefs) { + const current = nextModels[ref]; + const updated = modelEntryWithClaudeCliRuntime(current); + if (JSON.stringify(updated) === JSON.stringify(current ?? {})) { continue; } - nextModels[ref] = {}; + nextModels[ref] = updated; modelsMutated = true; } if (modelsMutated) { diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index c88165fb918..dda93bbea11 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -264,7 +264,7 @@ describe("anthropic provider replay hooks", () => { "anthropic/claude-sonnet-4-5", "anthropic/claude-haiku-4-5", ]) { - expect(models[modelId]).toEqual({}); + expect(models[modelId]).toEqual({ agentRuntime: { id: "claude-cli" } }); } }); diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index e2e1e7fad16..8db433d9356 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -370,8 +370,10 @@ describeLive("gateway live (cli backend)", () => { ...(bootstrapWorkspace ? { workspace: bootstrapWorkspace.workspaceRootDir } : {}), model: { primary: configModelKey }, models: { - [configModelKey]: {}, - ...(modelSwitchTarget ? { [modelSwitchTarget]: {} } : {}), + [configModelKey]: { agentRuntime: modelSelection.agentRuntime }, + ...(modelSwitchTarget + ? { [modelSwitchTarget]: { agentRuntime: modelSelection.agentRuntime } } + : {}), }, agentRuntime: modelSelection.agentRuntime, cliBackends: {