fix(config): preserve agent-level apiKey/baseUrl during models.json merge (#27293)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 6b4b37b03d
Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Sid
2026-02-26 16:46:36 +08:00
committed by GitHub
parent 92c309f2e1
commit c289b5ff9f
6 changed files with 146 additions and 2 deletions

View File

@@ -134,6 +134,116 @@ describe("models-config", () => {
});
});
it("preserves non-empty agent apiKey/baseUrl for matching providers in merge mode", async () => {
await withTempHome(async () => {
const agentDir = resolveOpenClawAgentDir();
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
path.join(agentDir, "models.json"),
JSON.stringify(
{
providers: {
custom: {
baseUrl: "https://agent.example/v1",
apiKey: "AGENT_KEY",
api: "openai-responses",
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
},
},
},
null,
2,
),
"utf8",
);
await ensureOpenClawModelsJson({
models: {
mode: "merge",
providers: {
custom: {
baseUrl: "https://config.example/v1",
apiKey: "CONFIG_KEY",
api: "openai-responses",
models: [
{
id: "config-model",
name: "Config model",
input: ["text"],
reasoning: false,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
},
],
},
},
},
});
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string; baseUrl?: string }>;
}>();
expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY");
expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1");
});
});
it("uses config apiKey/baseUrl when existing agent values are empty", async () => {
await withTempHome(async () => {
const agentDir = resolveOpenClawAgentDir();
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
path.join(agentDir, "models.json"),
JSON.stringify(
{
providers: {
custom: {
baseUrl: "",
apiKey: "",
api: "openai-responses",
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
},
},
},
null,
2,
),
"utf8",
);
await ensureOpenClawModelsJson({
models: {
mode: "merge",
providers: {
custom: {
baseUrl: "https://config.example/v1",
apiKey: "CONFIG_KEY",
api: "openai-responses",
models: [
{
id: "config-model",
name: "Config model",
input: ["text"],
reasoning: false,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
},
],
},
},
},
});
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string; baseUrl?: string }>;
}>();
expect(parsed.providers.custom?.apiKey).toBe("CONFIG_KEY");
expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1");
});
});
it("refreshes stale explicit moonshot model capabilities from implicit catalog", async () => {
await withTempHome(async () => {
const prevKey = process.env.MOONSHOT_API_KEY;

View File

@@ -142,7 +142,30 @@ export async function ensureOpenClawModelsJson(
string,
NonNullable<ModelsConfig["providers"]>[string]
>;
mergedProviders = { ...existingProviders, ...providers };
mergedProviders = {};
for (const [key, entry] of Object.entries(existingProviders)) {
mergedProviders[key] = entry;
}
for (const [key, newEntry] of Object.entries(providers)) {
const existing = existingProviders[key] as
| (NonNullable<ModelsConfig["providers"]>[string] & {
apiKey?: string;
baseUrl?: string;
})
| undefined;
if (existing) {
const preserved: Record<string, unknown> = {};
if (typeof existing.apiKey === "string" && existing.apiKey) {
preserved.apiKey = existing.apiKey;
}
if (typeof existing.baseUrl === "string" && existing.baseUrl) {
preserved.baseUrl = existing.baseUrl;
}
mergedProviders[key] = { ...newEntry, ...preserved };
} else {
mergedProviders[key] = newEntry;
}
}
}
}

View File

@@ -608,7 +608,7 @@ export const FIELD_HELP: Record<string, string> = {
models:
"Model catalog root for provider definitions, merge/replace behavior, and optional Bedrock discovery integration. Keep provider definitions explicit and validated before relying on production failover paths.",
"models.mode":
'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. Keep "merge" unless you intentionally want a strict custom list.',
'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json apiKey/baseUrl values and fall back to config when agent values are empty or missing.',
"models.providers":
"Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.",
"models.providers.*.baseUrl":