From 22411e17cb99bc006265689df816713686c86ff3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 13 May 2026 08:56:02 +0100 Subject: [PATCH] fix(cli): normalize Gemini config mutation refs --- CHANGELOG.md | 1 + src/cli/config-cli.test.ts | 58 +++++++++++++++ src/cli/config-cli.ts | 145 ++++++++++++++++++++++++++++++++----- 3 files changed, 185 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb5fc0077bd..380eaa4c038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai - Google/Gemini: normalize retired Gemini 3 Pro Preview ids returned by direct `openclaw models auth login --set-default` provider auth flows before writing config, so Gemini testing targets `google/gemini-3.1-pro-preview`. - Google/Gemini: normalize retired Gemini 3 Pro Preview ids in per-agent config defaults and auth patches, so agent-specific emitted config keeps targeting `google/gemini-3.1-pro-preview`. - Google/Gemini: normalize retired Gemini 3 Pro Preview ids in provider catalog rows when API-key onboarding only reapplies the agent default, so emitted config keeps testing `google/gemini-3.1-pro-preview`. +- Google/Gemini: normalize retired Gemini 3 Pro Preview ids in `config set` mutation output for agent overrides and provider catalog rows, so current config emits `google/gemini-3.1-pro-preview`. - Google/Gemini: canonicalize provider-qualified retired Gemini 3 Pro Preview refs during Google forward-compatible model resolution, so emitted config uses `google/gemini-3.1-pro-preview` for Gemini 3.1 testing. - Google/Gemini: normalize proxy-prefixed retired Gemini 3 Pro Preview catalog rows, so emitted configs use `google/gemini-3.1-pro-preview` for Gemini 3.1 testing. - Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside per-agent model overrides before writing config, so agent-specific config emits `google/gemini-3.1-pro-preview` for Gemini 3.1 testing. diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 1e6a069da9e..cf90697ab68 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -479,6 +479,64 @@ describe("config cli", () => { ]); }); + it("normalizes agent-list model refs before writing config mutations", async () => { + const resolved: OpenClawConfig = { + agents: { + list: [ + { + id: "tester", + model: { primary: "google/gemini-3-pro-preview" }, + models: { + "google/gemini-3-pro-preview": { alias: "gemini" }, + }, + }, + ], + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "set", "gateway.port", "18790"]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const agent = firstWrittenConfig().agents?.list?.[0]; + expect(agent?.model).toEqual({ primary: "google/gemini-3.1-pro-preview" }); + expect(agent?.models).toEqual({ + "google/gemini-3.1-pro-preview": { alias: "gemini" }, + }); + }); + + it("normalizes provider catalog model refs before writing config mutations", async () => { + const resolved: OpenClawConfig = { + models: { + providers: { + google: { + api: "google-generative-ai", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + models: [ + { + id: "google/gemini-3-pro-preview", + name: "Gemini 3 Pro", + contextWindow: 1_048_576, + maxTokens: 65_536, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: true, + }, + ], + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "set", "gateway.port", "18790"]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + expect(firstWrittenConfig().models?.providers?.google?.models?.[0]?.id).toBe( + "google/gemini-3.1-pro-preview", + ); + }); + it("rejects plugin install record config updates", async () => { await expect( runConfigCommand([ diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 9e6489732aa..61d737abf48 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import type { Command } from "commander"; import JSON5 from "json5"; +import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js"; import { readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; import { AUTO_MANAGED_CONFIG_META_PATHS } from "../config/io.meta.js"; import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js"; @@ -116,30 +117,136 @@ function normalizeAgentDefaultModelValueForConfigMutation(value: unknown): unkno return next; } +function normalizeAgentListModelRefsForConfigMutation(value: unknown): unknown { + if (!Array.isArray(value)) { + return value; + } + + let mutated = false; + const next = value.map((agent) => { + if (!isPlainRecord(agent)) { + return agent; + } + + let nextAgent = agent; + if (Object.prototype.hasOwnProperty.call(agent, "model")) { + const model = normalizeAgentDefaultModelValueForConfigMutation(agent.model); + if (model !== agent.model) { + nextAgent = { ...nextAgent, model }; + mutated = true; + } + } + if (isPlainRecord(agent.models)) { + const models = normalizeAgentModelMapForConfig(agent.models); + if (models !== agent.models) { + nextAgent = { ...nextAgent, models }; + mutated = true; + } + } + return nextAgent; + }); + + return mutated ? next : value; +} + +function normalizeProviderCatalogModelsForConfigMutation( + provider: string, + models: unknown, +): unknown { + if (!Array.isArray(models)) { + return models; + } + + let mutated = false; + const next = models.map((model) => { + if (!isPlainRecord(model) || typeof model.id !== "string") { + return model; + } + const trimmed = model.id.trim(); + if (!trimmed) { + return model; + } + const id = normalizeConfiguredProviderCatalogModelId(provider, trimmed); + if (id === model.id) { + return model; + } + mutated = true; + return { ...model, id }; + }); + + return mutated ? next : models; +} + +function normalizeModelProviderRefsForConfigMutation( + providers: NonNullable["providers"] | undefined, +): unknown { + if (!isPlainRecord(providers)) { + return providers; + } + + let mutated = false; + const nextProviders: Record = { ...providers }; + for (const [provider, providerConfig] of Object.entries(providers)) { + if (!isPlainRecord(providerConfig)) { + continue; + } + const models = normalizeProviderCatalogModelsForConfigMutation(provider, providerConfig.models); + if (models === providerConfig.models) { + continue; + } + nextProviders[provider] = { ...providerConfig, models }; + mutated = true; + } + + return mutated ? nextProviders : providers; +} + function normalizeConfigMutationModelRefs(cfg: OpenClawConfig): OpenClawConfig { const defaults = cfg.agents?.defaults; - if (!defaults) { - return cfg; - } + const agentList = cfg.agents?.list; + const providers = cfg.models?.providers; + const normalizedAgentList = normalizeAgentListModelRefsForConfigMutation(agentList); + const normalizedProviders = normalizeModelProviderRefsForConfigMutation(providers) as + | typeof providers + | undefined; return { ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...defaults, - ...(defaults.model !== undefined - ? { - model: normalizeAgentDefaultModelValueForConfigMutation( - defaults.model, - ) as typeof defaults.model, - } - : undefined), - ...(defaults.models !== undefined - ? { models: normalizeAgentModelMapForConfig(defaults.models) } - : undefined), - }, - }, + ...(defaults || normalizedAgentList !== agentList + ? { + agents: { + ...cfg.agents, + ...(defaults + ? { + defaults: { + ...defaults, + ...(defaults.model !== undefined + ? { + model: normalizeAgentDefaultModelValueForConfigMutation( + defaults.model, + ) as typeof defaults.model, + } + : undefined), + ...(defaults.models !== undefined + ? { models: normalizeAgentModelMapForConfig(defaults.models) } + : undefined), + }, + } + : undefined), + ...(normalizedAgentList !== agentList + ? { list: normalizedAgentList as typeof agentList } + : undefined), + }, + } + : undefined), + ...(normalizedProviders !== providers + ? { + models: { + ...cfg.models, + providers: normalizedProviders, + }, + } + : undefined), }; }