mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 19:04:45 +00:00
fix(anthropic): preserve Claude CLI runtime migration
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
|
||||
@@ -35,23 +35,29 @@ function toAnthropicModelRef(raw: string): string | null {
|
||||
return `anthropic/${modelId}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
const next: Record<string, unknown> = { ...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<string, unknown> | undefined): {
|
||||
|
||||
function seedClaudeCliAllowlist(
|
||||
models: NonNullable<AgentDefaultsModels>,
|
||||
selectedRefs: readonly string[] = [],
|
||||
): NonNullable<AgentDefaultsModels> {
|
||||
const next = { ...models };
|
||||
const runtimeRefs = new Set<string>();
|
||||
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<string, unknown> {
|
||||
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<AgentDefaultsModels>;
|
||||
const nextModels = seedClaudeCliAllowlist(existingModels);
|
||||
const nextModels = seedClaudeCliAllowlist(existingModels, [
|
||||
...rewrittenModel.runtimeRefs,
|
||||
...rewrittenModels.migrated,
|
||||
]);
|
||||
const defaultModel = rewrittenModel.primary ?? "anthropic/claude-opus-4-7";
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,10 @@ function normalizeProviderId(provider: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string>();
|
||||
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<T extends { api?: string; models?: unknown[] }>(
|
||||
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<string>(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) {
|
||||
|
||||
@@ -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" } });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user