From eeb29efebb504f321b1b2784a9b1121768b704cb Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 23 Feb 2026 02:34:45 -0500 Subject: [PATCH] fix: codify agent model config input boundary --- CHANGELOG.md | 2 +- extensions/llm-task/src/llm-task-tool.ts | 6 +- extensions/tlon/src/monitor/index.ts | 3 +- src/agents/model-selection.ts | 3 +- src/agents/pi-embedded-runner/run.ts | 3 +- src/auto-reply/reply/commands-status.ts | 3 +- .../auth-choice.apply.huggingface.test.ts | 9 ++- .../auth-choice.apply.minimax.test.ts | 13 +++- src/commands/auth-choice.moonshot.test.ts | 9 ++- src/commands/auth-choice.test.ts | 73 ++++++++++++++----- src/commands/models/set-image.ts | 5 +- src/commands/models/set.ts | 5 +- src/commands/onboard-auth.config-minimax.ts | 3 +- src/commands/onboard-auth.test.ts | 26 +++++-- ...etection.accepts-imessage-dmpolicy.test.ts | 15 +++- src/config/defaults.ts | 5 +- src/config/model-input.ts | 36 +++++++++ src/config/types.agent-defaults.ts | 8 +- src/security/audit-extra.sync.ts | 20 ++++- 19 files changed, 191 insertions(+), 56 deletions(-) create mode 100644 src/config/model-input.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 372722e9ff2..dfff788e944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Docs: https://docs.openclaw.ai - Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. - Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg. -- Agents/Models: split explicit vs effective agent model resolution and fix `models status --agent` source attribution so defaults-inherited agents are labeled as `defaults` while runtime selection still honors defaults fallback. (#24210) thanks @bianbiandashen. +- Agents/Models: codify `agents.defaults.model` / `agents.defaults.imageModel` config-boundary input as `string | {primary,fallbacks}`, split explicit vs effective model resolution, and fix `models status --agent` source attribution so defaults-inherited agents are labeled as `defaults` while runtime selection still honors defaults fallback. (#24210) thanks @bianbiandashen. - Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x. - Security/Skills: harden `skill-creator` packaging by skipping symlink entries and rejecting files whose resolved paths escape the selected skill root. (#24260, #16959) Thanks @CornBrother0x and @vincentkoc. - Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index f40d0351fec..87588d7adbd 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -96,7 +96,11 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg; - const primary = api.config?.agents?.defaults?.model?.primary; + const defaultsModel = api.config?.agents?.defaults?.model; + const primary = + typeof defaultsModel === "string" + ? defaultsModel.trim() + : (defaultsModel?.primary?.trim() ?? undefined); const primaryProvider = typeof primary === "string" ? primary.split("/")[0] : undefined; const primaryModel = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined; diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index e9d9750537b..bbcfa3fedc7 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -422,11 +422,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise 0; + resolveAgentModelFallbackValues(params.config?.agents?.defaults?.model).length > 0; await ensureOpenClawModelsJson(params.config, agentDir); // Run before_model_resolve hooks early so plugins can override the diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index 5cbc406ce92..50d007321c4 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -10,6 +10,7 @@ import { resolveMainSessionAlias, } from "../../agents/tools/sessions-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { toAgentModelListLike } from "../../config/model-input.js"; import type { SessionEntry, SessionScope } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { @@ -164,7 +165,7 @@ export async function buildStatusReply(params: { agent: { ...agentDefaults, model: { - ...agentDefaults.model, + ...toAgentModelListLike(agentDefaults.model), primary: `${provider}/${model}`, }, contextTokens, diff --git a/src/commands/auth-choice.apply.huggingface.test.ts b/src/commands/auth-choice.apply.huggingface.test.ts index 0758d84b0fb..9cc77fceb43 100644 --- a/src/commands/auth-choice.apply.huggingface.test.ts +++ b/src/commands/auth-choice.apply.huggingface.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js"; import { @@ -87,7 +88,9 @@ describe("applyAuthChoiceHuggingface", () => { provider: "huggingface", mode: "api_key", }); - expect(result?.config.agents?.defaults?.model?.primary).toMatch(/^huggingface\/.+/); + expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toMatch( + /^huggingface\/.+/, + ); expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("Hugging Face") }), ); @@ -173,7 +176,9 @@ describe("applyAuthChoiceHuggingface", () => { }); expect(result).not.toBeNull(); - expect(String(result?.config.agents?.defaults?.model?.primary)).toContain(":cheapest"); + expect(String(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model))).toContain( + ":cheapest", + ); expect(note).toHaveBeenCalledWith( "Provider locked — router will choose backend by cost or speed.", "Hugging Face", diff --git a/src/commands/auth-choice.apply.minimax.test.ts b/src/commands/auth-choice.apply.minimax.test.ts index 43677529a7a..78ae5d5fa12 100644 --- a/src/commands/auth-choice.apply.minimax.test.ts +++ b/src/commands/auth-choice.apply.minimax.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; import { @@ -114,7 +115,9 @@ describe("applyAuthChoiceMiniMax", () => { provider, mode: "api_key", }); - expect(result?.config.agents?.defaults?.model?.primary).toBe(expectedModel); + expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe( + expectedModel, + ); expect(text).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); @@ -144,7 +147,9 @@ describe("applyAuthChoiceMiniMax", () => { provider: "minimax-cn", mode: "api_key", }); - expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax-cn/MiniMax-M2.5"); + expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe( + "minimax-cn/MiniMax-M2.5", + ); expect(text).not.toHaveBeenCalled(); expect(confirm).toHaveBeenCalled(); @@ -176,7 +181,9 @@ describe("applyAuthChoiceMiniMax", () => { provider: "minimax", mode: "api_key", }); - expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5-Lightning"); + expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe( + "minimax/MiniMax-M2.5-Lightning", + ); expect(text).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); diff --git a/src/commands/auth-choice.moonshot.test.ts b/src/commands/auth-choice.moonshot.test.ts index 647694c9ce4..780c0e8e71b 100644 --- a/src/commands/auth-choice.moonshot.test.ts +++ b/src/commands/auth-choice.moonshot.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoice } from "./auth-choice.js"; import { @@ -72,7 +73,9 @@ describe("applyAuthChoice (moonshot)", () => { expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Enter Moonshot API key (.cn)" }), ); - expect(result.config.agents?.defaults?.model?.primary).toBe("anthropic/claude-opus-4-5"); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( + "anthropic/claude-opus-4-5", + ); expect(result.config.models?.providers?.moonshot?.baseUrl).toBe("https://api.moonshot.cn/v1"); expect(result.agentModelOverride).toBe("moonshot/kimi-k2.5"); @@ -88,7 +91,9 @@ describe("applyAuthChoice (moonshot)", () => { setDefaultModel: true, }); - expect(result.config.agents?.defaults?.model?.primary).toBe("moonshot/kimi-k2.5"); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( + "moonshot/kimi-k2.5", + ); expect(result.config.models?.providers?.moonshot?.baseUrl).toBe("https://api.moonshot.cn/v1"); expect(result.agentModelOverride).toBeUndefined(); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 308e6527065..0b8dfaeade2 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; import { GOOGLE_GEMINI_DEFAULT_MODEL } from "./google-gemini-model-default.js"; @@ -278,7 +279,9 @@ describe("applyAuthChoice", () => { provider: "huggingface", mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary).toMatch(/^huggingface\/.+/); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toMatch( + /^huggingface\/.+/, + ); expect((await readAuthProfile("huggingface:default"))?.key).toBe("hf-test-token"); }); @@ -310,7 +313,7 @@ describe("applyAuthChoice", () => { expect.objectContaining({ message: "Select Z.AI endpoint", initialValue: "global" }), ); expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL); - expect(result.config.agents?.defaults?.model?.primary).toBe("zai/glm-5"); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe("zai/glm-5"); expect((await readAuthProfile("zai:default"))?.key).toBe("zai-test-key"); }); @@ -368,7 +371,9 @@ describe("applyAuthChoice", () => { expect.objectContaining({ message: "Select Z.AI endpoint" }), ); expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_GLOBAL_BASE_URL); - expect(result.config.agents?.defaults?.model?.primary).toBe("zai/glm-4.5"); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( + "zai/glm-4.5", + ); }); it("maps apiKey + tokenProvider=huggingface to huggingface-api-key flow", async () => { @@ -396,7 +401,9 @@ describe("applyAuthChoice", () => { provider: "huggingface", mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary).toMatch(/^huggingface\/.+/); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toMatch( + /^huggingface\/.+/, + ); expect(text).not.toHaveBeenCalled(); expect((await readAuthProfile("huggingface:default"))?.key).toBe("hf-token-provider-test"); @@ -425,7 +432,9 @@ describe("applyAuthChoice", () => { provider: "together", mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary).toMatch(/^together\/.+/); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toMatch( + /^together\/.+/, + ); expect(text).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); expect((await readAuthProfile("together:default"))?.key).toBe( @@ -456,7 +465,9 @@ describe("applyAuthChoice", () => { provider: "kimi-coding", mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary).toMatch(/^kimi-coding\/.+/); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toMatch( + /^kimi-coding\/.+/, + ); expect(text).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); expect((await readAuthProfile("kimi-coding:default"))?.key).toBe("sk-kimi-token-provider-test"); @@ -485,7 +496,9 @@ describe("applyAuthChoice", () => { provider: "google", mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary).toBe(GOOGLE_GEMINI_DEFAULT_MODEL); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( + GOOGLE_GEMINI_DEFAULT_MODEL, + ); expect(text).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); expect((await readAuthProfile("google:default"))?.key).toBe("sk-gemini-token-provider-test"); @@ -514,7 +527,9 @@ describe("applyAuthChoice", () => { provider: "litellm", mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary).toMatch(/^litellm\/.+/); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toMatch( + /^litellm\/.+/, + ); expect(text).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); expect((await readAuthProfile("litellm:default"))?.key).toBe("sk-litellm-token-provider-test"); @@ -612,7 +627,11 @@ describe("applyAuthChoice", () => { provider, mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary?.startsWith(modelPrefix)).toBe(true); + expect( + resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)?.startsWith( + modelPrefix, + ), + ).toBe(true); expect((await readAuthProfile(profileId))?.key).toBe(token); }, ); @@ -642,7 +661,9 @@ describe("applyAuthChoice", () => { provider: "google", mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary).toBe("openai/gpt-4o-mini"); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( + "openai/gpt-4o-mini", + ); expect(result.agentModelOverride).toBe(GOOGLE_GEMINI_DEFAULT_MODEL); expect((await readAuthProfile("google:default"))?.key).toBe("sk-gemini-test"); }); @@ -706,7 +727,9 @@ describe("applyAuthChoice", () => { provider: "synthetic", mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary).toMatch(/^synthetic\/.+/); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toMatch( + /^synthetic\/.+/, + ); expect((await readAuthProfile("synthetic:default"))?.key).toBe("sk-synthetic-env"); }); @@ -731,7 +754,9 @@ describe("applyAuthChoice", () => { provider: "xai", mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary).toBe("openai/gpt-4o-mini"); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( + "openai/gpt-4o-mini", + ); expect(result.agentModelOverride).toBe("xai/grok-4"); expect((await readAuthProfile("xai:default"))?.key).toBe("sk-xai-test"); @@ -761,7 +786,9 @@ describe("applyAuthChoice", () => { setDefaultModel: true, }); - expect(result.config.agents?.defaults?.model?.primary).toBe("github-copilot/gpt-4o"); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( + "github-copilot/gpt-4o", + ); } finally { if (previousIsTTYDescriptor) { Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); @@ -794,7 +821,9 @@ describe("applyAuthChoice", () => { expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Enter OpenCode Zen API key" }), ); - expect(result.config.agents?.defaults?.model?.primary).toBe("anthropic/claude-opus-4-5"); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( + "anthropic/claude-opus-4-5", + ); expect(result.config.models?.providers?.["opencode-zen"]).toBeUndefined(); expect(result.agentModelOverride).toBe("opencode/claude-opus-4-6"); }); @@ -868,7 +897,9 @@ describe("applyAuthChoice", () => { provider: "openrouter", mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary).toBe("openrouter/auto"); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( + "openrouter/auto", + ); expect((await readAuthProfile("openrouter:default"))?.key).toBe("sk-openrouter-test"); @@ -963,7 +994,7 @@ describe("applyAuthChoice", () => { provider: "vercel-ai-gateway", mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary).toBe( + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( "vercel-ai-gateway/anthropic/claude-opus-4.6", ); @@ -1001,7 +1032,7 @@ describe("applyAuthChoice", () => { provider: "cloudflare-ai-gateway", mode: "api_key", }); - expect(result.config.agents?.defaults?.model?.primary).toBe( + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( "cloudflare-ai-gateway/claude-sonnet-4-5", ); @@ -1178,7 +1209,9 @@ describe("applyAuthChoice", () => { provider: "qwen-portal", mode: "oauth", }); - expect(result.config.agents?.defaults?.model?.primary).toBe("qwen-portal/coder-model"); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( + "qwen-portal/coder-model", + ); expect(result.config.models?.providers?.["qwen-portal"]).toMatchObject({ baseUrl: "https://portal.qwen.ai/v1", apiKey: "qwen-oauth", @@ -1252,7 +1285,9 @@ describe("applyAuthChoice", () => { provider: "minimax-portal", mode: "oauth", }); - expect(result.config.agents?.defaults?.model?.primary).toBe("minimax-portal/MiniMax-M2.1"); + expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( + "minimax-portal/MiniMax-M2.1", + ); expect(result.config.models?.providers?.["minimax-portal"]).toMatchObject({ baseUrl: "https://api.minimax.io/anthropic", apiKey: "minimax-oauth", diff --git a/src/commands/models/set-image.ts b/src/commands/models/set-image.ts index f4013141101..5fdf0c5f4e0 100644 --- a/src/commands/models/set-image.ts +++ b/src/commands/models/set-image.ts @@ -1,4 +1,5 @@ import { logConfigUpdated } from "../../config/logging.js"; +import { resolveAgentModelPrimaryValue } from "../../config/model-input.js"; import type { RuntimeEnv } from "../../runtime.js"; import { applyDefaultModelPrimaryUpdate, updateConfig } from "./shared.js"; @@ -8,5 +9,7 @@ export async function modelsSetImageCommand(modelRaw: string, runtime: RuntimeEn }); logConfigUpdated(runtime); - runtime.log(`Image model: ${updated.agents?.defaults?.imageModel?.primary ?? modelRaw}`); + runtime.log( + `Image model: ${resolveAgentModelPrimaryValue(updated.agents?.defaults?.imageModel) ?? modelRaw}`, + ); } diff --git a/src/commands/models/set.ts b/src/commands/models/set.ts index 6b0e79e8c33..3316b05dd8e 100644 --- a/src/commands/models/set.ts +++ b/src/commands/models/set.ts @@ -1,4 +1,5 @@ import { logConfigUpdated } from "../../config/logging.js"; +import { resolveAgentModelPrimaryValue } from "../../config/model-input.js"; import type { RuntimeEnv } from "../../runtime.js"; import { applyDefaultModelPrimaryUpdate, updateConfig } from "./shared.js"; @@ -8,5 +9,7 @@ export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) { }); logConfigUpdated(runtime); - runtime.log(`Default model: ${updated.agents?.defaults?.model?.primary ?? modelRaw}`); + runtime.log( + `Default model: ${resolveAgentModelPrimaryValue(updated.agents?.defaults?.model) ?? modelRaw}`, + ); } diff --git a/src/commands/onboard-auth.config-minimax.ts b/src/commands/onboard-auth.config-minimax.ts index 1f3ca04a173..6314a641dbb 100644 --- a/src/commands/onboard-auth.config-minimax.ts +++ b/src/commands/onboard-auth.config-minimax.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; +import { toAgentModelListLike } from "../config/model-input.js"; import type { ModelProviderConfig } from "../config/types.models.js"; import { applyAgentDefaultModelPrimary, @@ -100,7 +101,7 @@ export function applyMinimaxHostedConfig( defaults: { ...next.agents?.defaults, model: { - ...next.agents?.defaults?.model, + ...toAgentModelListLike(next.agents?.defaults?.model), primary: MINIMAX_HOSTED_MODEL_REF, }, }, diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 032a249b0d4..91a60c1eac6 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -4,6 +4,10 @@ import path from "node:path"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, +} from "../config/model-input.js"; import { applyAuthProfileConfig, applyLitellmProviderConfig, @@ -84,11 +88,15 @@ function createConfigWithFallbacks() { } function expectFallbacksPreserved(cfg: ReturnType) { - expect(cfg.agents?.defaults?.model?.fallbacks).toEqual([...EXPECTED_FALLBACKS]); + expect(resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)).toEqual([ + ...EXPECTED_FALLBACKS, + ]); } function expectPrimaryModelPreserved(cfg: ReturnType) { - expect(cfg.agents?.defaults?.model?.primary).toBe("anthropic/claude-opus-4-5"); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe( + "anthropic/claude-opus-4-5", + ); } function expectAllowlistContains( @@ -431,7 +439,7 @@ describe("applyZaiConfig", () => { for (const modelId of ["glm-4.7-flash", "glm-4.7-flashx"] as const) { const cfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId }); expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL); - expect(cfg.agents?.defaults?.model?.primary).toBe(`zai/${modelId}`); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(`zai/${modelId}`); } }); }); @@ -479,7 +487,7 @@ describe("primary model defaults", () => { ] as const; for (const { getConfig, primaryModel } of configCases) { const cfg = getConfig(); - expect(cfg.agents?.defaults?.model?.primary).toBe(primaryModel); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(primaryModel); } }); }); @@ -491,7 +499,7 @@ describe("applyXiaomiConfig", () => { baseUrl: "https://api.xiaomimimo.com/anthropic", api: "anthropic-messages", }); - expect(cfg.agents?.defaults?.model?.primary).toBe("xiaomi/mimo-v2-flash"); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe("xiaomi/mimo-v2-flash"); }); it("merges Xiaomi models and keeps existing provider overrides", () => { @@ -521,7 +529,7 @@ describe("applyXaiConfig", () => { baseUrl: "https://api.x.ai/v1", api: "openai-completions", }); - expect(cfg.agents?.defaults?.model?.primary).toBe(XAI_DEFAULT_MODEL_REF); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(XAI_DEFAULT_MODEL_REF); }); }); @@ -550,7 +558,9 @@ describe("applyMistralConfig", () => { baseUrl: "https://api.mistral.ai/v1", api: "openai-completions", }); - expect(cfg.agents?.defaults?.model?.primary).toBe(MISTRAL_DEFAULT_MODEL_REF); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe( + MISTRAL_DEFAULT_MODEL_REF, + ); }); }); @@ -685,7 +695,7 @@ describe("default-model config helpers", () => { ] as const; for (const { applyConfig, primaryModel } of configCases) { const cfg = applyConfig({}); - expect(cfg.agents?.defaults?.model?.primary).toBe(primaryModel); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(primaryModel); const cfgWithFallbacks = applyConfig(createConfigWithFallbacks()); expectFallbacksPreserved(cfgWithFallbacks); diff --git a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts index 1fec5ba6d60..87df6130336 100644 --- a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts +++ b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue } from "./model-input.js"; const { loadConfig, migrateLegacyConfig, readConfigFileSnapshot, validateConfigObject } = await vi.importActual("./config.js"); @@ -241,10 +242,16 @@ describe("legacy config detection", () => { }, }); - expect(res.config?.agents?.defaults?.model?.primary).toBe("anthropic/claude-opus-4-5"); - expect(res.config?.agents?.defaults?.model?.fallbacks).toEqual(["openai/gpt-4.1-mini"]); - expect(res.config?.agents?.defaults?.imageModel?.primary).toBe("openai/gpt-4.1-mini"); - expect(res.config?.agents?.defaults?.imageModel?.fallbacks).toEqual([ + expect(resolveAgentModelPrimaryValue(res.config?.agents?.defaults?.model)).toBe( + "anthropic/claude-opus-4-5", + ); + expect(resolveAgentModelFallbackValues(res.config?.agents?.defaults?.model)).toEqual([ + "openai/gpt-4.1-mini", + ]); + expect(resolveAgentModelPrimaryValue(res.config?.agents?.defaults?.imageModel)).toBe( + "openai/gpt-4.1-mini", + ); + expect(resolveAgentModelFallbackValues(res.config?.agents?.defaults?.imageModel)).toEqual([ "anthropic/claude-opus-4-5", ]); expect(res.config?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]).toMatchObject({ diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 3af51ba38d8..55d7093dde0 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,6 +1,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; import { normalizeProviderId, parseModelRef } from "../agents/model-selection.js"; import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js"; +import { resolveAgentModelPrimaryValue } from "./model-input.js"; import { resolveTalkApiKey } from "./talk.js"; import type { OpenClawConfig } from "./types.js"; import type { ModelDefinitionConfig } from "./types.models.js"; @@ -427,7 +428,9 @@ export function applyContextPruningDefaults(cfg: OpenClawConfig): OpenClawConfig modelsMutated = true; } - const primary = resolvePrimaryModelRef(defaults.model?.primary ?? undefined); + const primary = resolvePrimaryModelRef( + resolveAgentModelPrimaryValue(defaults.model) ?? undefined, + ); if (primary) { const parsedPrimary = parseModelRef(primary, "anthropic"); if (parsedPrimary?.provider === "anthropic") { diff --git a/src/config/model-input.ts b/src/config/model-input.ts new file mode 100644 index 00000000000..197947ab853 --- /dev/null +++ b/src/config/model-input.ts @@ -0,0 +1,36 @@ +import type { AgentModelConfig } from "./types.agents-shared.js"; + +type AgentModelListLike = { + primary?: string; + fallbacks?: string[]; +}; + +export function resolveAgentModelPrimaryValue(model?: AgentModelConfig): string | undefined { + if (typeof model === "string") { + const trimmed = model.trim(); + return trimmed || undefined; + } + if (!model || typeof model !== "object") { + return undefined; + } + const primary = model.primary?.trim(); + return primary || undefined; +} + +export function resolveAgentModelFallbackValues(model?: AgentModelConfig): string[] { + if (!model || typeof model !== "object") { + return []; + } + return Array.isArray(model.fallbacks) ? model.fallbacks : []; +} + +export function toAgentModelListLike(model?: AgentModelConfig): AgentModelListLike | undefined { + if (typeof model === "string") { + const primary = model.trim(); + return primary ? { primary } : undefined; + } + if (!model || typeof model !== "object") { + return undefined; + } + return model; +} diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 3af07f83a18..7ecfc6d4193 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -118,10 +118,10 @@ export type CliBackendConfig = { }; export type AgentDefaultsConfig = { - /** Primary model and fallbacks (provider/model). */ - model?: AgentModelListConfig; - /** Optional image-capable model and fallbacks (provider/model). */ - imageModel?: AgentModelListConfig; + /** Primary model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ + model?: AgentModelConfig; + /** Optional image-capable model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ + imageModel?: AgentModelConfig; /** Model catalog with optional aliases (full provider/model keys). */ models?: Record; /** Agent working directory (preferred). Used as the default cwd for agent runs. */ diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index fa13e9b53f7..6d36341f80d 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -14,6 +14,10 @@ import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { resolveBrowserConfig } from "../browser/config.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, +} from "../config/model-input.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { @@ -106,12 +110,20 @@ function addModel(models: ModelRef[], raw: unknown, source: string) { function collectModels(cfg: OpenClawConfig): ModelRef[] { const out: ModelRef[] = []; - addModel(out, cfg.agents?.defaults?.model?.primary, "agents.defaults.model.primary"); - for (const f of cfg.agents?.defaults?.model?.fallbacks ?? []) { + addModel( + out, + resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model), + "agents.defaults.model.primary", + ); + for (const f of resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)) { addModel(out, f, "agents.defaults.model.fallbacks"); } - addModel(out, cfg.agents?.defaults?.imageModel?.primary, "agents.defaults.imageModel.primary"); - for (const f of cfg.agents?.defaults?.imageModel?.fallbacks ?? []) { + addModel( + out, + resolveAgentModelPrimaryValue(cfg.agents?.defaults?.imageModel), + "agents.defaults.imageModel.primary", + ); + for (const f of resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel)) { addModel(out, f, "agents.defaults.imageModel.fallbacks"); }