Skip to content

Commit 62cf544

Browse files
committed
fix(anthropic): preserve Claude CLI runtime migration
1 parent 01eb56e commit 62cf544

6 files changed

Lines changed: 196 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
3030
- MS Teams/media: sniff inline `data:image/*` attachment bytes before staging them, skipping payloads that are not actually images.
3131
- 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.
3232
- 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.
33+
- 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.
3334
- Update: let package-swap `doctor --fix` persist core config repairs while plugin schemas are still converging, preventing update failures on externalized channel configs.
3435
- Update: carry plugin-validation bypasses into config mutation pre-write reads, so package update doctor repairs can finish while externalized plugin schemas are converging.
3536
- 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.

extensions/anthropic/cli-migration.test.ts

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,18 @@ describe("anthropic cli migration", () => {
133133
},
134134
agentRuntime: { id: "claude-cli" },
135135
models: {
136-
"anthropic/claude-opus-4-7": { alias: "Opus" },
137-
"anthropic/claude-sonnet-4-6": {},
138-
"anthropic/claude-opus-4-6": { alias: "Opus" },
139-
"anthropic/claude-opus-4-5": {},
140-
"anthropic/claude-sonnet-4-5": {},
141-
"anthropic/claude-haiku-4-5": {},
136+
"anthropic/claude-opus-4-7": {
137+
alias: "Opus",
138+
agentRuntime: { id: "claude-cli" },
139+
},
140+
"anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } },
141+
"anthropic/claude-opus-4-6": {
142+
alias: "Opus",
143+
agentRuntime: { id: "claude-cli" },
144+
},
145+
"anthropic/claude-opus-4-5": { agentRuntime: { id: "claude-cli" } },
146+
"anthropic/claude-sonnet-4-5": { agentRuntime: { id: "claude-cli" } },
147+
"anthropic/claude-haiku-4-5": { agentRuntime: { id: "claude-cli" } },
142148
"openai/gpt-5.2": {},
143149
},
144150
},
@@ -165,12 +171,12 @@ describe("anthropic cli migration", () => {
165171
agentRuntime: { id: "claude-cli" },
166172
models: {
167173
"openai/gpt-5.2": {},
168-
"anthropic/claude-opus-4-7": {},
169-
"anthropic/claude-sonnet-4-6": {},
170-
"anthropic/claude-opus-4-6": {},
171-
"anthropic/claude-opus-4-5": {},
172-
"anthropic/claude-sonnet-4-5": {},
173-
"anthropic/claude-haiku-4-5": {},
174+
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
175+
"anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } },
176+
"anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } },
177+
"anthropic/claude-opus-4-5": { agentRuntime: { id: "claude-cli" } },
178+
"anthropic/claude-sonnet-4-5": { agentRuntime: { id: "claude-cli" } },
179+
"anthropic/claude-haiku-4-5": { agentRuntime: { id: "claude-cli" } },
174180
},
175181
},
176182
},
@@ -195,16 +201,53 @@ describe("anthropic cli migration", () => {
195201
model: { primary: "anthropic/claude-opus-4-7" },
196202
agentRuntime: { id: "claude-cli" },
197203
models: {
198-
"anthropic/claude-opus-4-7": {},
199-
"anthropic/claude-sonnet-4-6": {},
200-
"anthropic/claude-opus-4-6": {},
201-
"anthropic/claude-opus-4-5": {},
202-
"anthropic/claude-sonnet-4-5": {},
203-
"anthropic/claude-haiku-4-5": {},
204+
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
205+
"anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } },
206+
"anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } },
207+
"anthropic/claude-opus-4-5": { agentRuntime: { id: "claude-cli" } },
208+
"anthropic/claude-sonnet-4-5": { agentRuntime: { id: "claude-cli" } },
209+
"anthropic/claude-haiku-4-5": { agentRuntime: { id: "claude-cli" } },
210+
},
211+
},
212+
},
213+
});
214+
});
215+
216+
it("preserves explicit model runtime policy while filling missing Claude CLI policies", () => {
217+
const result = buildAnthropicCliMigrationResult({
218+
agents: {
219+
defaults: {
220+
model: {
221+
primary: "anthropic/claude-opus-4-7",
222+
fallbacks: ["anthropic/claude-sonnet-4-6"],
223+
},
224+
models: {
225+
"anthropic/claude-opus-4-7": {
226+
alias: "Opus",
227+
agentRuntime: { id: "pi" },
228+
},
229+
"anthropic/claude-sonnet-4-6": {
230+
alias: "Sonnet",
231+
agentRuntime: { id: "auto" },
232+
},
204233
},
205234
},
206235
},
207236
});
237+
238+
const defaults = result.configPatch?.agents?.defaults;
239+
if (!defaults) {
240+
throw new Error("Expected Claude CLI migration to return default agent config");
241+
}
242+
243+
expect(defaults.models?.["anthropic/claude-opus-4-7"]).toEqual({
244+
alias: "Opus",
245+
agentRuntime: { id: "pi" },
246+
});
247+
expect(defaults.models?.["anthropic/claude-sonnet-4-6"]).toEqual({
248+
alias: "Sonnet",
249+
agentRuntime: { id: "claude-cli" },
250+
});
208251
});
209252

210253
it("registered cli auth tells users to run claude auth login when local auth is missing", async () => {
@@ -340,9 +383,11 @@ describe("anthropic cli migration", () => {
340383
expect(defaults?.agentRuntime?.id).toBe("claude-cli");
341384
expect(defaults?.models?.["anthropic/claude-opus-4-7"]).toEqual({
342385
alias: "Opus",
386+
agentRuntime: { id: "claude-cli" },
343387
});
344388
expect(defaults?.models?.["anthropic/claude-opus-4-6"]).toEqual({
345389
alias: "Opus",
390+
agentRuntime: { id: "claude-cli" },
346391
});
347392
expect(defaults?.models?.["openai/gpt-5.2"]).toEqual({});
348393
});

extensions/anthropic/cli-migration.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,29 @@ function toAnthropicModelRef(raw: string): string | null {
3535
return `anthropic/${modelId}`;
3636
}
3737

38+
function isRecord(value: unknown): value is Record<string, unknown> {
39+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
40+
}
41+
3842
function rewriteModelSelection(model: AgentDefaultsModel): {
3943
value: AgentDefaultsModel;
4044
primary?: string;
45+
runtimeRefs: string[];
4146
changed: boolean;
4247
} {
4348
if (typeof model === "string") {
4449
const converted = toAnthropicModelRef(model);
4550
return converted
46-
? { value: converted, primary: converted, changed: true }
47-
: { value: model, changed: false };
51+
? { value: converted, primary: converted, runtimeRefs: [converted], changed: true }
52+
: { value: model, runtimeRefs: [], changed: false };
4853
}
4954
if (!model || typeof model !== "object" || Array.isArray(model)) {
50-
return { value: model, changed: false };
55+
return { value: model, runtimeRefs: [], changed: false };
5156
}
5257

5358
const current = model as Record<string, unknown>;
5459
const next: Record<string, unknown> = { ...current };
60+
const runtimeRefs: string[] = [];
5561
let changed = false;
5662
let primary: string | undefined;
5763

@@ -60,15 +66,23 @@ function rewriteModelSelection(model: AgentDefaultsModel): {
6066
if (converted) {
6167
next.primary = converted;
6268
primary = converted;
69+
runtimeRefs.push(converted);
6370
changed = true;
6471
}
6572
}
6673

6774
const currentFallbacks = current.fallbacks;
6875
if (Array.isArray(currentFallbacks)) {
69-
const nextFallbacks = currentFallbacks.map((entry) =>
70-
typeof entry === "string" ? (toAnthropicModelRef(entry) ?? entry) : entry,
71-
);
76+
const nextFallbacks = currentFallbacks.map((entry) => {
77+
if (typeof entry !== "string") {
78+
return entry;
79+
}
80+
const converted = toAnthropicModelRef(entry);
81+
if (converted) {
82+
runtimeRefs.push(converted);
83+
}
84+
return converted ?? entry;
85+
});
7286
if (nextFallbacks.some((entry, index) => entry !== currentFallbacks[index])) {
7387
next.fallbacks = nextFallbacks;
7488
changed = true;
@@ -78,6 +92,7 @@ function rewriteModelSelection(model: AgentDefaultsModel): {
7892
return {
7993
value: changed ? next : model,
8094
...(primary ? { primary } : {}),
95+
runtimeRefs,
8196
changed,
8297
};
8398
}
@@ -116,11 +131,19 @@ function rewriteModelEntryMap(models: Record<string, unknown> | undefined): {
116131

117132
function seedClaudeCliAllowlist(
118133
models: NonNullable<AgentDefaultsModels>,
134+
selectedRefs: readonly string[] = [],
119135
): NonNullable<AgentDefaultsModels> {
120136
const next = { ...models };
137+
const runtimeRefs = new Set<string>();
121138
for (const ref of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) {
122139
const canonicalRef = toAnthropicModelRef(ref) ?? ref;
123-
next[canonicalRef] = next[canonicalRef] ?? {};
140+
runtimeRefs.add(canonicalRef);
141+
}
142+
for (const ref of selectedRefs) {
143+
runtimeRefs.add(ref);
144+
}
145+
for (const ref of runtimeRefs) {
146+
next[ref] = modelEntryWithClaudeCliRuntime(next[ref]);
124147
}
125148
return next;
126149
}
@@ -136,6 +159,21 @@ function selectClaudeCliRuntime(agentRuntime: AgentDefaultsRuntimePolicy | undef
136159
};
137160
}
138161

162+
function modelEntryWithClaudeCliRuntime(entry: unknown): Record<string, unknown> {
163+
const base = isRecord(entry) ? { ...entry } : {};
164+
const currentRuntimeId = isRecord(base.agentRuntime) ? base.agentRuntime.id : undefined;
165+
const currentRuntime =
166+
typeof currentRuntimeId === "string" ? normalizeLowercaseStringOrEmpty(currentRuntimeId) : "";
167+
if (currentRuntime && currentRuntime !== "auto") {
168+
return base;
169+
}
170+
base.agentRuntime = {
171+
...(isRecord(base.agentRuntime) ? base.agentRuntime : {}),
172+
id: CLAUDE_CLI_BACKEND_ID,
173+
};
174+
return base;
175+
}
176+
139177
export function hasClaudeCliAuth(options?: { allowKeychainPrompt?: boolean }): boolean {
140178
return Boolean(
141179
options?.allowKeychainPrompt === false
@@ -187,7 +225,10 @@ export function buildAnthropicCliMigrationResult(
187225
const existingModels = (rewrittenModels.value ??
188226
defaults?.models ??
189227
{}) as NonNullable<AgentDefaultsModels>;
190-
const nextModels = seedClaudeCliAllowlist(existingModels);
228+
const nextModels = seedClaudeCliAllowlist(existingModels, [
229+
...rewrittenModel.runtimeRefs,
230+
...rewrittenModels.migrated,
231+
]);
191232
const defaultModel = rewrittenModel.primary ?? "anthropic/claude-opus-4-7";
192233

193234
return {

extensions/anthropic/config-defaults.ts

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ function normalizeProviderId(provider: string): string {
1616
return normalized;
1717
}
1818

19+
function isRecord(value: unknown): value is Record<string, unknown> {
20+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
21+
}
22+
1923
function resolveAnthropicDefaultAuthMode(
2024
config: OpenClawConfig,
2125
env: NodeJS.ProcessEnv,
@@ -154,9 +158,16 @@ function usesClaudeCliModelSelection(config: OpenClawConfig): boolean {
154158
if (parsedPrimary?.provider === CLAUDE_CLI_BACKEND_ID) {
155159
return true;
156160
}
157-
return Object.keys(config.agents?.defaults?.models ?? {}).some((key) => {
161+
return Object.entries(config.agents?.defaults?.models ?? {}).some(([key, entry]) => {
158162
const parsed = parseProviderModelRef(key, "anthropic");
159-
return parsed?.provider === CLAUDE_CLI_BACKEND_ID;
163+
if (parsed?.provider === CLAUDE_CLI_BACKEND_ID) {
164+
return true;
165+
}
166+
const runtimeId = isRecord(entry?.agentRuntime) ? entry.agentRuntime.id : undefined;
167+
return (
168+
parsed?.provider === "anthropic" &&
169+
normalizeLowercaseStringOrEmpty(runtimeId) === CLAUDE_CLI_BACKEND_ID
170+
);
160171
});
161172
}
162173

@@ -166,6 +177,63 @@ function toCanonicalAnthropicModelRef(ref: string): string {
166177
: ref;
167178
}
168179

180+
function toClaudeCliRuntimeModelRef(raw: string): string | null {
181+
const ref = resolveAnthropicPrimaryModelRef(raw);
182+
if (!ref) {
183+
return null;
184+
}
185+
const parsed = parseProviderModelRef(ref, "anthropic");
186+
if (!parsed) {
187+
return null;
188+
}
189+
if (parsed.provider !== "anthropic" && parsed.provider !== CLAUDE_CLI_BACKEND_ID) {
190+
return null;
191+
}
192+
if (!normalizeLowercaseStringOrEmpty(parsed.model).startsWith("claude-")) {
193+
return null;
194+
}
195+
return `anthropic/${parsed.model}`;
196+
}
197+
198+
function modelEntryWithClaudeCliRuntime(entry: unknown): Record<string, unknown> {
199+
const base = isRecord(entry) ? { ...entry } : {};
200+
const currentRuntimeId = isRecord(base.agentRuntime) ? base.agentRuntime.id : undefined;
201+
const currentRuntime = normalizeLowercaseStringOrEmpty(currentRuntimeId);
202+
if (currentRuntime && currentRuntime !== "auto") {
203+
return base;
204+
}
205+
base.agentRuntime = {
206+
...(isRecord(base.agentRuntime) ? base.agentRuntime : {}),
207+
id: CLAUDE_CLI_BACKEND_ID,
208+
};
209+
return base;
210+
}
211+
212+
function collectClaudeCliRuntimeRefs(
213+
model: string | { primary?: string; fallbacks?: string[] } | undefined,
214+
): string[] {
215+
const refs = new Set<string>();
216+
if (typeof model === "string") {
217+
const ref = toClaudeCliRuntimeModelRef(model);
218+
if (ref) {
219+
refs.add(ref);
220+
}
221+
return [...refs];
222+
}
223+
const primary =
224+
typeof model?.primary === "string" ? toClaudeCliRuntimeModelRef(model.primary) : null;
225+
if (primary) {
226+
refs.add(primary);
227+
}
228+
for (const fallback of model?.fallbacks ?? []) {
229+
const ref = toClaudeCliRuntimeModelRef(fallback);
230+
if (ref) {
231+
refs.add(ref);
232+
}
233+
}
234+
return [...refs];
235+
}
236+
169237
function normalizeAnthropicProviderConfig<T extends { api?: string; models?: unknown[] }>(
170238
providerConfig: T,
171239
): T {
@@ -290,12 +358,17 @@ export function applyAnthropicConfigDefaults(params: {
290358
if (authMode === "oauth" && usesClaudeCliModelSelection(params.config)) {
291359
const nextModels = defaults.models ? { ...defaults.models } : {};
292360
let modelsMutated = false;
361+
const runtimeRefs = new Set<string>(collectClaudeCliRuntimeRefs(defaults.model));
293362
for (const rawRef of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) {
294-
const ref = toCanonicalAnthropicModelRef(rawRef);
295-
if (ref in nextModels) {
363+
runtimeRefs.add(toCanonicalAnthropicModelRef(rawRef));
364+
}
365+
for (const ref of runtimeRefs) {
366+
const current = nextModels[ref];
367+
const updated = modelEntryWithClaudeCliRuntime(current);
368+
if (JSON.stringify(updated) === JSON.stringify(current ?? {})) {
296369
continue;
297370
}
298-
nextModels[ref] = {};
371+
nextModels[ref] = updated;
299372
modelsMutated = true;
300373
}
301374
if (modelsMutated) {

extensions/anthropic/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ describe("anthropic provider replay hooks", () => {
264264
"anthropic/claude-sonnet-4-5",
265265
"anthropic/claude-haiku-4-5",
266266
]) {
267-
expect(models[modelId]).toEqual({});
267+
expect(models[modelId]).toEqual({ agentRuntime: { id: "claude-cli" } });
268268
}
269269
});
270270

src/gateway/gateway-cli-backend.live.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,10 @@ describeLive("gateway live (cli backend)", () => {
370370
...(bootstrapWorkspace ? { workspace: bootstrapWorkspace.workspaceRootDir } : {}),
371371
model: { primary: configModelKey },
372372
models: {
373-
[configModelKey]: {},
374-
...(modelSwitchTarget ? { [modelSwitchTarget]: {} } : {}),
373+
[configModelKey]: { agentRuntime: modelSelection.agentRuntime },
374+
...(modelSwitchTarget
375+
? { [modelSwitchTarget]: { agentRuntime: modelSelection.agentRuntime } }
376+
: {}),
375377
},
376378
agentRuntime: modelSelection.agentRuntime,
377379
cliBackends: {

0 commit comments

Comments
 (0)