From 35fb3f7e1c7b06ba8bd1d777fa12cf78119da9e2 Mon Sep 17 00:00:00 2001 From: chaoliang yan Date: Fri, 17 Apr 2026 13:02:35 +1000 Subject: [PATCH] fix: preserve models.json baseUrls on regen (#67893) (thanks @lawrence3699) * models-config: preserve existing models.json baseUrls * test: distill models-config baseUrl regression test * fix: preserve models.json baseUrls on regen (#67893) (thanks @lawrence3699) --------- Co-authored-by: lawrence3699 Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + src/agents/models-config.merge.test.ts | 18 ++----- src/agents/models-config.merge.ts | 16 ++---- src/agents/models-config.plan.ts | 17 ------- ...els-config.runtime-source-snapshot.test.ts | 21 ++++++-- ...-github-copilot-profile-env-tokens.test.ts | 49 +++++++++++++++++++ 6 files changed, 76 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb93c8e317c..da539df58f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - Onboarding/non-interactive: preserve existing gateway auth tokens during re-onboard so active local gateway clients are not disconnected by an implicit token rotation. (#67821) Thanks @BKF-Gitty. - OpenAI Codex/Responses: unify native Responses API capability detection so Codex OAuth requests emit the required `store: false` field on the native Responses path. (#67918) Thanks @obviyus. - WhatsApp/setup: guard personal-phone and allowlist prompt values so setup fails with clear validation errors instead of crashing on undefined prompt text. (#67895) Thanks @lawrence3699. +- Models/config: preserve an existing `models.json` provider `baseUrl` during merge-mode regeneration so custom endpoints do not get reset on restart. (#67893) Thanks @lawrence3699. ## 2026.4.15 diff --git a/src/agents/models-config.merge.test.ts b/src/agents/models-config.merge.test.ts index 52afcac1ee2..b76928c6d5e 100644 --- a/src/agents/models-config.merge.test.ts +++ b/src/agents/models-config.merge.test.ts @@ -134,14 +134,13 @@ describe("models-config merge helpers", () => { } as ExistingProviderConfig, }, secretRefManagedProviders: new Set(), - explicitBaseUrlProviders: new Set(["custom-proxy"]), }); expect(merged.existing?.baseUrl).toBe("http://localhost:1234/v1"); expect(merged["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1"); }); - it("preserves non-empty existing apiKey while explicit baseUrl wins", async () => { + it("preserves non-empty existing apiKey and baseUrl from models.json", async () => { const merged = mergeWithExistingProviderSecrets({ nextProviders: { custom: createConfigProvider(), @@ -150,14 +149,13 @@ describe("models-config merge helpers", () => { custom: createExistingProvider(), }, secretRefManagedProviders: new Set(), - explicitBaseUrlProviders: new Set(["custom"]), }); expect(merged.custom?.apiKey).toBe(preservedApiKey); - expect(merged.custom?.baseUrl).toBe("https://config.example/v1"); + expect(merged.custom?.baseUrl).toBe("https://agent.example/v1"); }); - it("preserves existing apiKey after explicit provider key normalization", async () => { + it("preserves existing baseUrl after explicit provider key normalization", async () => { const normalized = mergeProviders({ explicit: { " custom ": createConfigProvider(), @@ -169,11 +167,10 @@ describe("models-config merge helpers", () => { custom: createExistingProvider(), }, secretRefManagedProviders: new Set(), - explicitBaseUrlProviders: new Set(["custom"]), }); expect(merged.custom?.apiKey).toBe(preservedApiKey); - expect(merged.custom?.baseUrl).toBe("https://config.example/v1"); + expect(merged.custom?.baseUrl).toBe("https://agent.example/v1"); }); it("preserves implicit provider headers when explicit config adds extra headers", async () => { @@ -228,7 +225,6 @@ describe("models-config merge helpers", () => { } as ExistingProviderConfig, }, secretRefManagedProviders: new Set(), - explicitBaseUrlProviders: new Set(), }); expect(merged.custom).toEqual( @@ -255,7 +251,6 @@ describe("models-config merge helpers", () => { custom: existingProvider, }, secretRefManagedProviders: new Set(), - explicitBaseUrlProviders: new Set(["custom"]), }); expect(merged.custom?.apiKey).toBe(preservedApiKey); @@ -277,7 +272,6 @@ describe("models-config merge helpers", () => { } as ExistingProviderConfig, }, secretRefManagedProviders: new Set(), - explicitBaseUrlProviders: new Set(), }); expect(merged.custom?.apiKey).toBe("GOOGLE_API_KEY"); // pragma: allowlist secret @@ -294,11 +288,10 @@ describe("models-config merge helpers", () => { }), }, secretRefManagedProviders: new Set(), - explicitBaseUrlProviders: new Set(["custom"]), }); expect(merged.custom?.apiKey).toBe("ALLCAPS_SAMPLE"); // pragma: allowlist secret - expect(merged.custom?.baseUrl).toBe("https://config.example/v1"); + expect(merged.custom?.baseUrl).toBe("https://agent.example/v1"); }); it("uses config apiKey/baseUrl when existing values are empty", async () => { @@ -313,7 +306,6 @@ describe("models-config merge helpers", () => { }), }, secretRefManagedProviders: new Set(), - explicitBaseUrlProviders: new Set(["custom"]), }); expect(merged.custom?.apiKey).toBe(configApiKey); diff --git a/src/agents/models-config.merge.ts b/src/agents/models-config.merge.ts index 38bedf4615f..25fcef8547c 100644 --- a/src/agents/models-config.merge.ts +++ b/src/agents/models-config.merge.ts @@ -196,17 +196,11 @@ function shouldPreserveExistingApiKey(params: { } function shouldPreserveExistingBaseUrl(params: { - providerKey: string; existing: ExistingProviderConfig; nextEntry: ProviderConfig; - explicitBaseUrlProviders: ReadonlySet; }): boolean { - const { providerKey, existing, nextEntry, explicitBaseUrlProviders } = params; - if ( - explicitBaseUrlProviders.has(providerKey) || - typeof existing.baseUrl !== "string" || - existing.baseUrl.length === 0 - ) { + const { existing, nextEntry } = params; + if (typeof existing.baseUrl !== "string" || existing.baseUrl.length === 0) { return false; } @@ -219,10 +213,8 @@ export function mergeWithExistingProviderSecrets(params: { nextProviders: Record; existingProviders: Record; secretRefManagedProviders: ReadonlySet; - explicitBaseUrlProviders: ReadonlySet; }): Record { - const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } = - params; + const { nextProviders, existingProviders, secretRefManagedProviders } = params; const mergedProviders: Record = {}; for (const [key, entry] of Object.entries(existingProviders)) { mergedProviders[key] = entry; @@ -246,10 +238,8 @@ export function mergeWithExistingProviderSecrets(params: { } if ( shouldPreserveExistingBaseUrl({ - providerKey: key, existing, nextEntry: newEntry, - explicitBaseUrlProviders, }) ) { preserved.baseUrl = existing.baseUrl; diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts index 81c9bf26910..aedd8667759 100644 --- a/src/agents/models-config.plan.ts +++ b/src/agents/models-config.plan.ts @@ -58,26 +58,11 @@ export async function resolveProvidersForModelsJsonWithDeps( }); } -function resolveExplicitBaseUrlProviders( - providers: OpenClawConfig["models"] | undefined, -): ReadonlySet { - return new Set( - Object.entries(providers?.providers ?? {}) - .map(([key, provider]) => [key.trim(), provider] as const) - .filter( - ([key, provider]) => - Boolean(key) && typeof provider?.baseUrl === "string" && provider.baseUrl.trim(), - ) - .map(([key]) => key), - ); -} - function resolveProvidersForMode(params: { mode: NonNullable; existingParsed: unknown; providers: Record; secretRefManagedProviders: ReadonlySet; - explicitBaseUrlProviders: ReadonlySet; }): Record { if (params.mode !== "merge") { return params.providers; @@ -94,7 +79,6 @@ function resolveProvidersForMode(params: { nextProviders: params.providers, existingProviders: existingProviders as Record, secretRefManagedProviders: params.secretRefManagedProviders, - explicitBaseUrlProviders: params.explicitBaseUrlProviders, }); } @@ -135,7 +119,6 @@ export async function planOpenClawModelsJsonWithDeps( existingParsed: params.existingParsed, providers: normalizedProviders, secretRefManagedProviders, - explicitBaseUrlProviders: resolveExplicitBaseUrlProviders(cfg.models), }); const secretEnforcedProviders = enforceSourceManagedProviderSecrets({ diff --git a/src/agents/models-config.runtime-source-snapshot.test.ts b/src/agents/models-config.runtime-source-snapshot.test.ts index f1b77232e91..422946a4fb7 100644 --- a/src/agents/models-config.runtime-source-snapshot.test.ts +++ b/src/agents/models-config.runtime-source-snapshot.test.ts @@ -279,6 +279,9 @@ describe("models-config runtime source snapshot", () => { openai: { ...runtimeConfig.models!.providers!.openai, baseUrl: "https://api.openai.com/v1", + headers: { + "X-OpenClaw-Test": "one", + }, }, }, }, @@ -290,6 +293,9 @@ describe("models-config runtime source snapshot", () => { openai: { ...runtimeConfig.models!.providers!.openai, baseUrl: "https://mirror.example/v1", + headers: { + "X-OpenClaw-Test": "two", + }, }, }, }, @@ -299,17 +305,26 @@ describe("models-config runtime source snapshot", () => { setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); await ensureOpenClawModelsJson(firstCandidate); let parsed = await readGeneratedModelsJson<{ - providers: Record; + providers: Record< + string, + { baseUrl?: string; apiKey?: string; headers?: Record } + >; }>(); expect(parsed.providers.openai?.baseUrl).toBe("https://api.openai.com/v1"); expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret + expect(parsed.providers.openai?.headers?.["X-OpenClaw-Test"]).toBe("one"); + // Header changes still rewrite models.json, but merge mode preserves the existing baseUrl. await ensureOpenClawModelsJson(secondCandidate); parsed = await readGeneratedModelsJson<{ - providers: Record; + providers: Record< + string, + { baseUrl?: string; apiKey?: string; headers?: Record } + >; }>(); - expect(parsed.providers.openai?.baseUrl).toBe("https://mirror.example/v1"); + expect(parsed.providers.openai?.baseUrl).toBe("https://api.openai.com/v1"); expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret + expect(parsed.providers.openai?.headers?.["X-OpenClaw-Test"]).toBe("two"); } finally { clearRuntimeConfigSnapshot(); clearConfigCache(); diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts index 8ee7d4dcc38..7a12e7082a4 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts @@ -124,6 +124,55 @@ describe("models-config", () => { }); }); + it("keeps a non-empty existing models.json baseUrl when merge mode regenerates the provider", async () => { + const kilocodeProvider = { + baseUrl: "https://api.kilo.ai/api/gateway/v1", + api: "openai-completions" as const, + models: [], + }; + const existingContents = `${JSON.stringify( + { + providers: { + kilocode: { + baseUrl: "https://api.kilo.ai/api/gateway", + api: "openai-completions", + models: [], + }, + }, + }, + null, + 2, + )}\n`; + + const plan = await planOpenClawModelsJsonWithDeps( + { + cfg: { + models: { + providers: { + kilocode: kilocodeProvider, + }, + }, + }, + sourceConfigForSecrets: { + models: { + providers: { + kilocode: kilocodeProvider, + }, + }, + }, + agentDir: "/tmp/openclaw-agent", + env: {} as NodeJS.ProcessEnv, + existingRaw: existingContents, + existingParsed: JSON.parse(existingContents), + }, + { + resolveImplicitProviders: async () => ({}), + }, + ); + + expect(plan).toEqual({ action: "noop" }); + }); + it("uses tokenRef env var when github-copilot profile omits plaintext token", () => { const auth = createProviderAuthResolver( {