fix(agents): respect explicit provider baseUrl in merge mode (#39103)

Land #39103 by @BigUncle.

Co-authored-by: BigUncle <biguncle2017@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-07 19:22:21 +00:00
parent 537c97cce9
commit c06014d50c
3 changed files with 66 additions and 20 deletions

View File

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

View File

@@ -60,18 +60,24 @@ function createMergeConfigProvider() {
}; };
} }
async function runCustomProviderMergeTest(seedProvider: { async function runCustomProviderMergeTest(params: {
seedProvider: {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
api: string; api: string;
models: Array<{ id: string; name: string; input: 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({ await ensureOpenClawModelsJson({
models: { models: {
mode: "merge", mode: "merge",
providers: { 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 () => { await withTempHome(async () => {
const parsed = await runCustomProviderMergeTest({ const parsed = await runCustomProviderMergeTest({
seedProvider: {
baseUrl: "https://agent.example/v1", baseUrl: "https://agent.example/v1",
apiKey: "AGENT_KEY", // pragma: allowlist secret apiKey: "AGENT_KEY", // pragma: allowlist secret
api: "openai-responses", api: "openai-responses",
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
},
}); });
expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); 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<string, { apiKey?: string; baseUrl?: string }>; providers: Record<string, { apiKey?: string; baseUrl?: string }>;
}>(); }>();
expect(parsed.providers.custom?.apiKey).toBe("CUSTOM_PROVIDER_API_KEY"); // pragma: allowlist secret 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 () => { it("uses config apiKey/baseUrl when existing agent values are empty", async () => {
await withTempHome(async () => { await withTempHome(async () => {
const parsed = await runCustomProviderMergeTest({ const parsed = await runCustomProviderMergeTest({
seedProvider: {
baseUrl: "", baseUrl: "",
apiKey: "", apiKey: "",
api: "openai-responses", api: "openai-responses",
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
},
}); });
expect(parsed.providers.custom?.apiKey).toBe("CONFIG_KEY"); expect(parsed.providers.custom?.apiKey).toBe("CONFIG_KEY");
expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1");

View File

@@ -149,8 +149,10 @@ function mergeWithExistingProviderSecrets(params: {
nextProviders: Record<string, ProviderConfig>; nextProviders: Record<string, ProviderConfig>;
existingProviders: Record<string, NonNullable<ModelsConfig["providers"]>[string]>; existingProviders: Record<string, NonNullable<ModelsConfig["providers"]>[string]>;
secretRefManagedProviders: ReadonlySet<string>; secretRefManagedProviders: ReadonlySet<string>;
explicitBaseUrlProviders: ReadonlySet<string>;
}): Record<string, ProviderConfig> { }): Record<string, ProviderConfig> {
const { nextProviders, existingProviders, secretRefManagedProviders } = params; const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } =
params;
const mergedProviders: Record<string, ProviderConfig> = {}; const mergedProviders: Record<string, ProviderConfig> = {};
for (const [key, entry] of Object.entries(existingProviders)) { for (const [key, entry] of Object.entries(existingProviders)) {
mergedProviders[key] = entry; mergedProviders[key] = entry;
@@ -175,7 +177,11 @@ function mergeWithExistingProviderSecrets(params: {
) { ) {
preserved.apiKey = existing.apiKey; 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; preserved.baseUrl = existing.baseUrl;
} }
mergedProviders[key] = { ...newEntry, ...preserved }; mergedProviders[key] = { ...newEntry, ...preserved };
@@ -188,6 +194,7 @@ async function resolveProvidersForMode(params: {
targetPath: string; targetPath: string;
providers: Record<string, ProviderConfig>; providers: Record<string, ProviderConfig>;
secretRefManagedProviders: ReadonlySet<string>; secretRefManagedProviders: ReadonlySet<string>;
explicitBaseUrlProviders: ReadonlySet<string>;
}): Promise<Record<string, ProviderConfig>> { }): Promise<Record<string, ProviderConfig>> {
if (params.mode !== "merge") { if (params.mode !== "merge") {
return params.providers; return params.providers;
@@ -204,6 +211,7 @@ async function resolveProvidersForMode(params: {
nextProviders: params.providers, nextProviders: params.providers,
existingProviders, existingProviders,
secretRefManagedProviders: params.secretRefManagedProviders, secretRefManagedProviders: params.secretRefManagedProviders,
explicitBaseUrlProviders: params.explicitBaseUrlProviders,
}); });
} }
@@ -278,6 +286,15 @@ export async function ensureOpenClawModelsJson(
const mode = cfg.models?.mode ?? DEFAULT_MODE; const mode = cfg.models?.mode ?? DEFAULT_MODE;
const secretRefManagedProviders = new Set<string>(); const secretRefManagedProviders = new Set<string>();
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 = const normalizedProviders =
normalizeProviders({ normalizeProviders({
@@ -291,6 +308,7 @@ export async function ensureOpenClawModelsJson(
targetPath, targetPath,
providers: normalizedProviders, providers: normalizedProviders,
secretRefManagedProviders, secretRefManagedProviders,
explicitBaseUrlProviders,
}); });
const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`;
const existingRaw = await readRawFile(targetPath); const existingRaw = await readRawFile(targetPath);