From c06014d50cc7f31c3cde32906e11dbd2b7491e48 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 7 Mar 2026 19:22:21 +0000 Subject: [PATCH] fix(agents): respect explicit provider baseUrl in merge mode (#39103) Land #39103 by @BigUncle. Co-authored-by: BigUncle --- CHANGELOG.md | 1 + ...ssing-provider-apikey-from-env-var.test.ts | 63 +++++++++++++------ src/agents/models-config.ts | 22 ++++++- 3 files changed, 66 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f30946ed3..a696f54ee54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -255,6 +255,7 @@ Docs: https://docs.openclaw.ai - Agents/cache-trace stability: guard stable stringify against circular references in trace payloads so near-limit payloads no longer crash with `Maximum call stack size exceeded`; adds regression coverage. (#38935) Thanks @MumuTW. - Extensions/diffs CI stability: add `headers` to the `localReq` test helper in `extensions/diffs/index.test.ts` so forwarding-hint checks no longer crash with `req.headers` undefined. (supersedes #39063) Thanks @Shennng. - Agents/compaction thresholding: apply `agents.defaults.contextTokens` cap to the model passed into embedded run and `/compact` session creation so auto-compaction thresholds use the effective context window, not native model max context. (#39099) Thanks @MumuTW. +- Models/merge mode provider precedence: when `models.mode: "merge"` is active and config explicitly sets a provider `baseUrl`, keep config as source of truth instead of preserving stale runtime `models.json` `baseUrl` values; includes normalized provider-key coverage. (#39103) Thanks @BigUncle. ## 2026.3.2 diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index a7277736581..c821cbd5303 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -60,18 +60,24 @@ function createMergeConfigProvider() { }; } -async function runCustomProviderMergeTest(seedProvider: { - baseUrl: string; - apiKey: string; - api: string; - models: Array<{ id: string; name: string; input: string[] }>; +async function runCustomProviderMergeTest(params: { + seedProvider: { + baseUrl: string; + apiKey: string; + api: string; + models: Array<{ id: string; name: string; input: string[] }>; + }; + existingProviderKey?: string; + configProviderKey?: string; }) { - await writeAgentModelsJson({ providers: { custom: seedProvider } }); + const existingProviderKey = params.existingProviderKey ?? "custom"; + const configProviderKey = params.configProviderKey ?? "custom"; + await writeAgentModelsJson({ providers: { [existingProviderKey]: params.seedProvider } }); await ensureOpenClawModelsJson({ models: { mode: "merge", providers: { - custom: createMergeConfigProvider(), + [configProviderKey]: createMergeConfigProvider(), }, }, }); @@ -208,16 +214,35 @@ describe("models-config", () => { }); }); - it("preserves non-empty agent apiKey/baseUrl for matching providers in merge mode", async () => { + it("preserves non-empty agent apiKey but lets explicit config baseUrl win in merge mode", async () => { await withTempHome(async () => { const parsed = await runCustomProviderMergeTest({ - baseUrl: "https://agent.example/v1", - apiKey: "AGENT_KEY", // pragma: allowlist secret - api: "openai-responses", - models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + seedProvider: { + baseUrl: "https://agent.example/v1", + apiKey: "AGENT_KEY", // pragma: allowlist secret + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, }); expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); - expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1"); + expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + }); + }); + + it("lets explicit config baseUrl win in merge mode when the config provider key is normalized", async () => { + await withTempHome(async () => { + const parsed = await runCustomProviderMergeTest({ + seedProvider: { + baseUrl: "https://agent.example/v1", + apiKey: "AGENT_KEY", + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + existingProviderKey: "custom", + configProviderKey: " custom ", + }); + expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); + expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); }); }); @@ -249,7 +274,7 @@ describe("models-config", () => { providers: Record; }>(); expect(parsed.providers.custom?.apiKey).toBe("CUSTOM_PROVIDER_API_KEY"); // pragma: allowlist secret - expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1"); + expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); }); }); @@ -335,10 +360,12 @@ describe("models-config", () => { it("uses config apiKey/baseUrl when existing agent values are empty", async () => { await withTempHome(async () => { const parsed = await runCustomProviderMergeTest({ - baseUrl: "", - apiKey: "", - api: "openai-responses", - models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + seedProvider: { + baseUrl: "", + apiKey: "", + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, }); expect(parsed.providers.custom?.apiKey).toBe("CONFIG_KEY"); expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 11832b30b15..a3f1fd19ff3 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -149,8 +149,10 @@ function mergeWithExistingProviderSecrets(params: { nextProviders: Record; existingProviders: Record[string]>; secretRefManagedProviders: ReadonlySet; + explicitBaseUrlProviders: ReadonlySet; }): Record { - const { nextProviders, existingProviders, secretRefManagedProviders } = params; + const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } = + params; const mergedProviders: Record = {}; for (const [key, entry] of Object.entries(existingProviders)) { mergedProviders[key] = entry; @@ -175,7 +177,11 @@ function mergeWithExistingProviderSecrets(params: { ) { preserved.apiKey = existing.apiKey; } - if (typeof existing.baseUrl === "string" && existing.baseUrl) { + if ( + !explicitBaseUrlProviders.has(key) && + typeof existing.baseUrl === "string" && + existing.baseUrl + ) { preserved.baseUrl = existing.baseUrl; } mergedProviders[key] = { ...newEntry, ...preserved }; @@ -188,6 +194,7 @@ async function resolveProvidersForMode(params: { targetPath: string; providers: Record; secretRefManagedProviders: ReadonlySet; + explicitBaseUrlProviders: ReadonlySet; }): Promise> { if (params.mode !== "merge") { return params.providers; @@ -204,6 +211,7 @@ async function resolveProvidersForMode(params: { nextProviders: params.providers, existingProviders, secretRefManagedProviders: params.secretRefManagedProviders, + explicitBaseUrlProviders: params.explicitBaseUrlProviders, }); } @@ -278,6 +286,15 @@ export async function ensureOpenClawModelsJson( const mode = cfg.models?.mode ?? DEFAULT_MODE; const secretRefManagedProviders = new Set(); + const explicitBaseUrlProviders = new Set( + Object.entries(cfg.models?.providers ?? {}) + .map(([key, provider]) => [key.trim(), provider] as const) + .filter( + ([key, provider]) => + Boolean(key) && typeof provider?.baseUrl === "string" && provider.baseUrl.trim(), + ) + .map(([key]) => key), + ); const normalizedProviders = normalizeProviders({ @@ -291,6 +308,7 @@ export async function ensureOpenClawModelsJson( targetPath, providers: normalizedProviders, secretRefManagedProviders, + explicitBaseUrlProviders, }); const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; const existingRaw = await readRawFile(targetPath);