From 14d1c9c4f00ae38726f42f2fa132714beb58ed1a Mon Sep 17 00:00:00 2001 From: Neerav Makwana <261249544+neeravmakwana@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:10:55 -0400 Subject: [PATCH] fix(models/auth): merge agents.defaults.models on provider login `openclaw models auth login` was replacing `agents.defaults.models` wholesale whenever a provider returned a `configPatch` with that key, even if the patch only listed the new default model. Re-authenticating an OAuth provider such as OpenAI Codex wiped aliases and per-model params for every other provider. Make replacement opt-in via `ProviderAuthResult.replaceDefaultModels`. Ordinary logins merge their allowlist patch so unrelated entries survive; the Anthropic -> Claude CLI migration opts in because it renames keys the merge path would otherwise keep stale. Fixes #69414. Made-with: Cursor --- CHANGELOG.md | 1 + extensions/anthropic/cli-migration.ts | 2 + src/commands/models/auth.test.ts | 70 ++++++++++++++----- src/commands/models/auth.ts | 4 +- .../provider-auth-choice-helpers.test.ts | 43 +++++++----- src/plugins/provider-auth-choice-helpers.ts | 10 ++- src/plugins/provider-auth-choice.ts | 4 +- src/plugins/types.ts | 6 ++ 8 files changed, 100 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2e1990e9a7..2b5201f32d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Models/auth: merge provider-owned default-model additions from `openclaw models auth login` instead of replacing `agents.defaults.models`, so re-authenticating an OAuth provider such as OpenAI Codex no longer wipes other providers' aliases and per-model params. Migrations that must rename keys (Anthropic -> Claude CLI) opt in with `replaceDefaultModels`. Fixes #69414. (#70435) Thanks @neeravmakwana. - Media understanding/audio: prefer configured or key-backed STT providers before auto-detected local Whisper CLIs, so installed local transcription tools no longer shadow API providers such as Groq/OpenAI in `tools.media.audio` auto mode. Fixes #68727. - Providers/OpenAI: lock the auth picker wording for OpenAI API key, Codex browser login, and Codex device pairing so the setup choices no longer imply a mixed Codex/API-key auth path. (#67848) Thanks @tmlxrd. - Agents/BTW: route `/btw` side questions through provider stream registration with the session workspace, so Ollama provider URL construction and workspace-scoped hooks apply correctly. Fixes #68336. (#70413) Thanks @suboss87. diff --git a/extensions/anthropic/cli-migration.ts b/extensions/anthropic/cli-migration.ts index 031cacaa02c..6603c280e69 100644 --- a/extensions/anthropic/cli-migration.ts +++ b/extensions/anthropic/cli-migration.ts @@ -180,6 +180,8 @@ export function buildAnthropicCliMigrationResult( }, }, }, + // Rewrites `anthropic/*` -> `claude-cli/*`; merge would keep stale keys. + replaceDefaultModels: true, defaultModel, notes: [ "Claude CLI auth detected; switched Anthropic model selection to the local Claude CLI backend.", diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index fe6a52de59c..8ebcd4939ec 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -154,23 +154,28 @@ vi.mock("../provider-auth-helpers.js", () => { null ); }), - applyProviderAuthConfigPatch: vi.fn((cfg: OpenClawConfig, patch: unknown) => { - const merged = mergePatch(cfg, patch); - const patchModels = (patch as { agents?: { defaults?: { models?: unknown } } })?.agents - ?.defaults?.models; - return isRecord(patchModels) - ? { - ...merged, - agents: { - ...merged.agents, - defaults: { - ...merged.agents?.defaults, - models: patchModels, + applyProviderAuthConfigPatch: vi.fn( + (cfg: OpenClawConfig, patch: unknown, options?: { replaceDefaultModels?: boolean }) => { + const merged = mergePatch(cfg, patch); + if (!options?.replaceDefaultModels) { + return merged; + } + const patchModels = (patch as { agents?: { defaults?: { models?: unknown } } })?.agents + ?.defaults?.models; + return isRecord(patchModels) + ? { + ...merged, + agents: { + ...merged.agents, + defaults: { + ...merged.agents?.defaults, + models: patchModels, + }, }, - }, - } - : merged; - }), + } + : merged; + }, + ), applyDefaultModel: vi.fn((cfg: OpenClawConfig, model: string) => ({ ...cfg, agents: { @@ -482,6 +487,7 @@ describe("modelsAuthLoginCommand", () => { }, }, }, + replaceDefaultModels: true, notes: [ "Claude CLI auth detected; switched Anthropic model selection to the local Claude CLI backend.", "Existing Anthropic auth profiles are kept for rollback.", @@ -573,6 +579,38 @@ describe("modelsAuthLoginCommand", () => { expect(runtime.log).toHaveBeenCalledWith("Default model set to claude-cli/claude-sonnet-4-6"); }); + it("preserves other providers' allowlist entries on an openai-codex OAuth login", async () => { + const runtime = createRuntime(); + const existingModels = { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + "anthropic/claude-opus-4-6": { alias: "opus" }, + "moonshot/kimi-k2.5": { alias: "kimi" }, + "openai-codex/gpt-5.4": { alias: "gpt54" }, + }; + currentConfig = { agents: { defaults: { models: existingModels } } }; + runProviderAuth.mockResolvedValue({ + profiles: [ + { + profileId: "openai-codex:user@example.com", + credential: { + type: "oauth", + provider: "openai-codex", + access: "a", + refresh: "r", + expires: Date.now() + 60_000, + email: "user@example.com", + }, + }, + ], + configPatch: { agents: { defaults: { models: { "openai-codex/gpt-5.4": {} } } } }, + defaultModel: "openai-codex/gpt-5.4", + }); + + await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); + + expect(lastUpdatedConfig?.agents?.defaults?.models).toEqual(existingModels); + }); + it("survives lockout clearing failure without blocking login", async () => { const runtime = createRuntime(); mocks.loadAuthProfileStoreForRuntime.mockImplementation(() => { diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 136090bd607..0c751a61e53 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -246,7 +246,9 @@ async function persistProviderAuthResult(params: { await updateConfig((cfg) => { let next = cfg; if (params.result.configPatch) { - next = applyProviderAuthConfigPatch(next, params.result.configPatch); + next = applyProviderAuthConfigPatch(next, params.result.configPatch, { + replaceDefaultModels: params.result.replaceDefaultModels, + }); } for (const profile of params.result.profiles) { next = applyAuthProfileConfig(next, { diff --git a/src/plugins/provider-auth-choice-helpers.test.ts b/src/plugins/provider-auth-choice-helpers.test.ts index 7cf7d0e4637..e87eb937914 100644 --- a/src/plugins/provider-auth-choice-helpers.test.ts +++ b/src/plugins/provider-auth-choice-helpers.test.ts @@ -3,38 +3,43 @@ import type { OpenClawConfig } from "../config/config.js"; import { applyProviderAuthConfigPatch } from "./provider-auth-choice-helpers.js"; describe("applyProviderAuthConfigPatch", () => { - it("replaces patched default model maps instead of recursively merging them", () => { - const base = { - agents: { - defaults: { - model: { - primary: "anthropic/claude-sonnet-4-6", - fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"], - }, - models: { - "anthropic/claude-sonnet-4-6": { alias: "Sonnet" }, - "anthropic/claude-opus-4-6": { alias: "Opus" }, - "openai/gpt-5.2": {}, - }, + const base = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: ["openai/gpt-5.2"] }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "Sonnet" }, + "anthropic/claude-opus-4-6": { alias: "Opus" }, + "openai/gpt-5.2": {}, }, }, - }; + }, + }; + + it("merges default model maps by default so other providers survive login", () => { + const patch = { agents: { defaults: { models: { "openai-codex/gpt-5.4": {} } } } }; + const next = applyProviderAuthConfigPatch(base, patch); + expect(next.agents?.defaults?.models).toEqual({ + ...base.agents.defaults.models, + "openai-codex/gpt-5.4": {}, + }); + expect(next.agents?.defaults?.model).toEqual(base.agents.defaults.model); + }); + + it("replaces the allowlist only when replaceDefaultModels is set", () => { const patch = { agents: { defaults: { models: { "claude-cli/claude-sonnet-4-6": { alias: "Sonnet" }, - "claude-cli/claude-opus-4-6": { alias: "Opus" }, "openai/gpt-5.2": {}, }, }, }, }; - - const next = applyProviderAuthConfigPatch(base, patch); - + const next = applyProviderAuthConfigPatch(base, patch, { replaceDefaultModels: true }); expect(next.agents?.defaults?.models).toEqual(patch.agents.defaults.models); - expect(next.agents?.defaults?.model).toEqual(base.agents?.defaults?.model); + expect(next.agents?.defaults?.model).toEqual(base.agents.defaults.model); }); it("keeps normal recursive merges for unrelated provider auth patch fields", () => { diff --git a/src/plugins/provider-auth-choice-helpers.ts b/src/plugins/provider-auth-choice-helpers.ts index 07e1a848615..b7dd3bfc063 100644 --- a/src/plugins/provider-auth-choice-helpers.ts +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -63,9 +63,13 @@ export function mergeConfigPatch(base: T, patch: unknown): T { return next as T; } -export function applyProviderAuthConfigPatch(cfg: OpenClawConfig, patch: unknown): OpenClawConfig { +export function applyProviderAuthConfigPatch( + cfg: OpenClawConfig, + patch: unknown, + options?: { replaceDefaultModels?: boolean }, +): OpenClawConfig { const merged = mergeConfigPatch(cfg, patch); - if (!isPlainRecord(patch)) { + if (!options?.replaceDefaultModels || !isPlainRecord(patch)) { return merged; } @@ -81,7 +85,7 @@ export function applyProviderAuthConfigPatch(cfg: OpenClawConfig, patch: unknown ...merged.agents, defaults: { ...merged.agents?.defaults, - // Provider auth migrations can intentionally replace the exact allowlist. + // Opt-in replacement for migrations that rename/remove model keys. models: patchModels as NonNullable< NonNullable["defaults"] >["models"], diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index f2bdb5dc722..05388760d56 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -155,7 +155,9 @@ export async function runProviderPluginAuthMethod(params: { let nextConfig = params.config; if (result.configPatch) { - nextConfig = applyProviderAuthConfigPatch(nextConfig, result.configPatch); + nextConfig = applyProviderAuthConfigPatch(nextConfig, result.configPatch, { + replaceDefaultModels: result.replaceDefaultModels, + }); } for (const profile of result.profiles) { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 97dc168ee20..e99cc12625a 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -240,6 +240,12 @@ export type ProviderAuthResult = { configPatch?: Partial; defaultModel?: string; notes?: string[]; + /** + * Opt in to replace `agents.defaults.models` wholesale with the patch map. + * Default behavior merges the map so other providers' entries survive. + * Set only from migrations that intentionally rename/remove model keys. + */ + replaceDefaultModels?: boolean; }; /** Interactive auth context passed to provider login/setup methods. */