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
This commit is contained in:
Neerav Makwana
2026-04-22 22:10:55 -04:00
committed by Peter Steinberger
parent 5bd8254f61
commit 14d1c9c4f0
8 changed files with 100 additions and 40 deletions

View File

@@ -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.

View File

@@ -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.",

View File

@@ -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(() => {

View File

@@ -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, {

View File

@@ -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", () => {

View File

@@ -63,9 +63,13 @@ export function mergeConfigPatch<T>(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<OpenClawConfig["agents"]>["defaults"]
>["models"],

View File

@@ -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) {

View File

@@ -240,6 +240,12 @@ export type ProviderAuthResult = {
configPatch?: Partial<OpenClawConfig>;
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. */