mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 03:22:44 +00:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user