diff --git a/CHANGELOG.md b/CHANGELOG.md index a73e2888af7..dfff788e944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +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: 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/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 50f40998ca1..209427ca277 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -718,9 +718,15 @@ Time format in system prompt. Default: `auto` (OS preference). } ``` +- `model`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). + - String form sets only the primary model. + - Object form sets primary plus ordered failover models. +- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). + - Used by the `image` tool path as its vision-model config. + - Also used as fallback routing when the selected/default model cannot accept image input. - `model.primary`: format `provider/model` (e.g. `anthropic/claude-opus-4-6`). If you omit the provider, OpenClaw assumes `anthropic` (deprecated). - `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific: `temperature`, `maxTokens`). -- `imageModel`: only used if the primary model lacks image input. +- Config writers that mutate these fields (for example `/models set`, `/models set-image`, and fallback add/remove commands) save canonical object form and preserve existing fallback lists when possible. - `maxConcurrent`: max parallel agent runs across sessions (each session still serialized). Default: 1. **Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`): 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 { }); }); + it("resolves explicit and effective model primary separately", () => { + const cfgWithStringDefault = { + agents: { + defaults: { + model: "anthropic/claude-sonnet-4", + }, + list: [{ id: "main" }], + }, + } as unknown as OpenClawConfig; + expect(resolveAgentExplicitModelPrimary(cfgWithStringDefault, "main")).toBeUndefined(); + expect(resolveAgentEffectiveModelPrimary(cfgWithStringDefault, "main")).toBe( + "anthropic/claude-sonnet-4", + ); + + const cfgWithObjectDefault: OpenClawConfig = { + agents: { + defaults: { + model: { + primary: "openai/gpt-5.2", + fallbacks: ["anthropic/claude-sonnet-4"], + }, + }, + list: [{ id: "main" }], + }, + }; + expect(resolveAgentExplicitModelPrimary(cfgWithObjectDefault, "main")).toBeUndefined(); + expect(resolveAgentEffectiveModelPrimary(cfgWithObjectDefault, "main")).toBe("openai/gpt-5.2"); + + const cfgNoDefaults: OpenClawConfig = { + agents: { + list: [{ id: "main" }], + }, + }; + expect(resolveAgentExplicitModelPrimary(cfgNoDefaults, "main")).toBeUndefined(); + expect(resolveAgentEffectiveModelPrimary(cfgNoDefaults, "main")).toBeUndefined(); + }); + it("supports per-agent model primary+fallbacks", () => { const cfg: OpenClawConfig = { agents: { @@ -81,6 +120,8 @@ describe("resolveAgentConfig", () => { }; expect(resolveAgentModelPrimary(cfg, "linus")).toBe("anthropic/claude-opus-4"); + expect(resolveAgentExplicitModelPrimary(cfg, "linus")).toBe("anthropic/claude-opus-4"); + expect(resolveAgentEffectiveModelPrimary(cfg, "linus")).toBe("anthropic/claude-opus-4"); expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual(["openai/gpt-5.2"]); // If fallbacks isn't present, we don't override the global fallbacks. diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index c1e5774e23a..c48cea9f690 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -1,5 +1,6 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentModelFallbackValues } from "../config/model-input.js"; import { resolveStateDir } from "../config/paths.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { @@ -142,16 +143,43 @@ export function resolveAgentSkillsFilter( return normalizeSkillFilter(resolveAgentConfig(cfg, agentId)?.skills); } -export function resolveAgentModelPrimary(cfg: OpenClawConfig, agentId: string): string | undefined { - const raw = resolveAgentConfig(cfg, agentId)?.model; - if (!raw) { +function resolveModelPrimary(raw: unknown): string | undefined { + if (typeof raw === "string") { + const trimmed = raw.trim(); + return trimmed || undefined; + } + if (!raw || typeof raw !== "object") { return undefined; } - if (typeof raw === "string") { - return raw.trim() || undefined; + const primary = (raw as { primary?: unknown }).primary; + if (typeof primary !== "string") { + return undefined; } - const primary = raw.primary?.trim(); - return primary || undefined; + const trimmed = primary.trim(); + return trimmed || undefined; +} + +export function resolveAgentExplicitModelPrimary( + cfg: OpenClawConfig, + agentId: string, +): string | undefined { + const raw = resolveAgentConfig(cfg, agentId)?.model; + return resolveModelPrimary(raw); +} + +export function resolveAgentEffectiveModelPrimary( + cfg: OpenClawConfig, + agentId: string, +): string | undefined { + return ( + resolveAgentExplicitModelPrimary(cfg, agentId) ?? + resolveModelPrimary(cfg.agents?.defaults?.model) + ); +} + +// Backward-compatible alias. Prefer explicit/effective helpers at new call sites. +export function resolveAgentModelPrimary(cfg: OpenClawConfig, agentId: string): string | undefined { + return resolveAgentExplicitModelPrimary(cfg, agentId); } export function resolveAgentModelFallbacksOverride( @@ -178,10 +206,7 @@ export function resolveEffectiveModelFallbacks(params: { if (!params.hasSessionModelOverride) { return agentFallbacksOverride; } - const defaultFallbacks = - typeof params.cfg.agents?.defaults?.model === "object" - ? (params.cfg.agents.defaults.model.fallbacks ?? []) - : []; + const defaultFallbacks = resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model); return agentFallbacksOverride ?? defaultFallbacks; } diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 7a7a192e8d4..b0050602590 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -1,4 +1,8 @@ import type { OpenClawConfig } from "../config/config.js"; +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, +} from "../config/model-input.js"; import { ensureAuthProfileStore, getSoonestCooldownExpiry, @@ -151,26 +155,13 @@ function resolveImageFallbackCandidates(params: { if (params.modelOverride?.trim()) { addRaw(params.modelOverride, false); } else { - const imageModel = params.cfg?.agents?.defaults?.imageModel as - | { primary?: string } - | string - | undefined; - const primary = typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary; + const primary = resolveAgentModelPrimaryValue(params.cfg?.agents?.defaults?.imageModel); if (primary?.trim()) { addRaw(primary, false); } } - const imageFallbacks = (() => { - const imageModel = params.cfg?.agents?.defaults?.imageModel as - | { fallbacks?: string[] } - | string - | undefined; - if (imageModel && typeof imageModel === "object") { - return imageModel.fallbacks ?? []; - } - return []; - })(); + const imageFallbacks = resolveAgentModelFallbackValues(params.cfg?.agents?.defaults?.imageModel); for (const raw of imageFallbacks) { addRaw(raw, true); @@ -220,14 +211,7 @@ function resolveFallbackCandidates(params: { if (!sameModelCandidate(normalizedPrimary, configuredPrimary)) { return []; // Override model failed → go straight to configured default } - const model = params.cfg?.agents?.defaults?.model as - | { fallbacks?: string[] } - | string - | undefined; - if (model && typeof model === "object") { - return model.fallbacks ?? []; - } - return []; + return resolveAgentModelFallbackValues(params.cfg?.agents?.defaults?.model); })(); for (const raw of modelFallbacks) { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 6f6773d5c61..6f6e6d10f09 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentModelPrimaryValue, toAgentModelListLike } from "../config/model-input.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { resolveAgentConfig, resolveAgentModelPrimary } from "./agent-scope.js"; +import { resolveAgentConfig, resolveAgentEffectiveModelPrimary } from "./agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.js"; import { normalizeGoogleModelId } from "./models-config.providers.js"; @@ -259,13 +260,7 @@ export function resolveConfiguredModelRef(params: { defaultProvider: string; defaultModel: string; }): ModelRef { - const rawModel = (() => { - const raw = params.cfg.agents?.defaults?.model as { primary?: string } | string | undefined; - if (typeof raw === "string") { - return raw.trim(); - } - return raw?.primary?.trim() ?? ""; - })(); + const rawModel = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model) ?? ""; if (rawModel) { const trimmed = rawModel.trim(); const aliasIndex = buildModelAliasIndex({ @@ -303,7 +298,7 @@ export function resolveDefaultModelForAgent(params: { agentId?: string; }): ModelRef { const agentModelOverride = params.agentId - ? resolveAgentModelPrimary(params.cfg, params.agentId) + ? resolveAgentEffectiveModelPrimary(params.cfg, params.agentId) : undefined; const cfg = agentModelOverride && agentModelOverride.length > 0 @@ -314,9 +309,7 @@ export function resolveDefaultModelForAgent(params: { defaults: { ...params.cfg.agents?.defaults, model: { - ...(typeof params.cfg.agents?.defaults?.model === "object" - ? params.cfg.agents.defaults.model - : undefined), + ...toAgentModelListLike(params.cfg.agents?.defaults?.model), primary: agentModelOverride, }, }, @@ -357,7 +350,7 @@ export function resolveSubagentSpawnModelSelection(params: { cfg: params.cfg, agentId: params.agentId, }) ?? - normalizeModelSelection(params.cfg.agents?.defaults?.model?.primary) ?? + normalizeModelSelection(resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model)) ?? `${runtimeDefault.provider}/${runtimeDefault.model}` ); } diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 9ae15591b1b..aa603b171ed 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import fs from "node:fs/promises"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import { resolveAgentModelFallbackValues } from "../../config/model-input.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { PluginHookBeforeAgentStartResult } from "../../plugins/types.js"; @@ -231,7 +232,7 @@ export async function runEmbeddedPiAgent( let modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); const fallbackConfigured = - (params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 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/agents/tools/image-tool.helpers.ts b/src/agents/tools/image-tool.helpers.ts index ae98e40ba26..a1581cb2b94 100644 --- a/src/agents/tools/image-tool.helpers.ts +++ b/src/agents/tools/image-tool.helpers.ts @@ -1,5 +1,9 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { OpenClawConfig } from "../../config/config.js"; +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, +} from "../../config/model-input.js"; import { extractAssistantText } from "../pi-embedded-utils.js"; export type ImageModelConfig = { primary?: string; fallbacks?: string[] }; @@ -51,12 +55,8 @@ export function coerceImageAssistantText(params: { } export function coerceImageModelConfig(cfg?: OpenClawConfig): ImageModelConfig { - const imageModel = cfg?.agents?.defaults?.imageModel as - | { primary?: string; fallbacks?: string[] } - | string - | undefined; - const primary = typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary; - const fallbacks = typeof imageModel === "object" ? (imageModel?.fallbacks ?? []) : []; + const primary = resolveAgentModelPrimaryValue(cfg?.agents?.defaults?.imageModel); + const fallbacks = resolveAgentModelFallbackValues(cfg?.agents?.defaults?.imageModel); return { ...(primary?.trim() ? { primary: primary.trim() } : {}), ...(fallbacks.length > 0 ? { fallbacks } : {}), 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/agent.ts b/src/commands/agent.ts index 6eddcfe7d67..7ca8591faa4 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -2,7 +2,6 @@ import { listAgentIds, resolveAgentDir, resolveEffectiveModelFallbacks, - resolveAgentModelPrimary, resolveSessionAgentId, resolveAgentSkillsFilter, resolveAgentWorkspaceDir, @@ -21,6 +20,7 @@ import { modelKey, normalizeModelRef, resolveConfiguredModelRef, + resolveDefaultModelForAgent, resolveThinkingDefault, } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; @@ -372,29 +372,9 @@ export async function agentCommand( sessionEntry = next; } - const agentModelPrimary = resolveAgentModelPrimary(cfg, sessionAgentId); - const cfgForModelSelection = agentModelPrimary - ? { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - model: { - ...(typeof cfg.agents?.defaults?.model === "object" - ? cfg.agents.defaults.model - : undefined), - primary: agentModelPrimary, - }, - }, - }, - } - : cfg; - - const configuredDefaultRef = resolveConfiguredModelRef({ - cfg: cfgForModelSelection, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, + const configuredDefaultRef = resolveDefaultModelForAgent({ + cfg, + agentId: sessionAgentId, }); const { provider: defaultProvider, model: defaultModel } = normalizeModelRef( configuredDefaultRef.provider, diff --git a/src/commands/auth-choice.apply.github-copilot.ts b/src/commands/auth-choice.apply.github-copilot.ts index cd67ae1cbdf..1ef474682af 100644 --- a/src/commands/auth-choice.apply.github-copilot.ts +++ b/src/commands/auth-choice.apply.github-copilot.ts @@ -1,3 +1,4 @@ +import { toAgentModelListLike } from "../config/model-input.js"; import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyAuthProfileConfig } from "./onboard-auth.js"; @@ -49,9 +50,7 @@ export async function applyAuthChoiceGitHubCopilot( defaults: { ...nextConfig.agents?.defaults, model: { - ...(typeof nextConfig.agents?.defaults?.model === "object" - ? nextConfig.agents.defaults.model - : undefined), + ...toAgentModelListLike(nextConfig.agents?.defaults?.model), primary: model, }, }, 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.model-check.ts b/src/commands/auth-choice.model-check.ts index b1579ea3483..ea7da2f9d6d 100644 --- a/src/commands/auth-choice.model-check.ts +++ b/src/commands/auth-choice.model-check.ts @@ -1,9 +1,7 @@ -import { resolveAgentModelPrimary } from "../agents/agent-scope.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; -import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { OPENAI_CODEX_DEFAULT_MODEL } from "./openai-codex-model-default.js"; @@ -13,35 +11,13 @@ export async function warnIfModelConfigLooksOff( prompter: WizardPrompter, options?: { agentId?: string; agentDir?: string }, ) { - const agentModelOverride = options?.agentId - ? resolveAgentModelPrimary(config, options.agentId) - : undefined; - const configWithModel = - agentModelOverride && agentModelOverride.length > 0 - ? { - ...config, - agents: { - ...config.agents, - defaults: { - ...config.agents?.defaults, - model: { - ...(typeof config.agents?.defaults?.model === "object" - ? config.agents.defaults.model - : undefined), - primary: agentModelOverride, - }, - }, - }, - } - : config; - const ref = resolveConfiguredModelRef({ - cfg: configWithModel, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, + const ref = resolveDefaultModelForAgent({ + cfg: config, + agentId: options?.agentId, }); const warnings: string[] = []; const catalog = await loadModelCatalog({ - config: configWithModel, + config, useCache: false, }); if (catalog.length > 0) { 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/model-picker.ts b/src/commands/model-picker.ts index 6b1c8691e02..db794210354 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -10,6 +10,7 @@ import { resolveConfiguredModelRef, } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; import { formatTokenK } from "./models/shared.js"; import { OPENAI_CODEX_DEFAULT_MODEL } from "./openai-codex-model-default.js"; @@ -77,11 +78,7 @@ function createProviderAuthChecker(params: { } function resolveConfiguredModelRaw(cfg: OpenClawConfig): string { - const raw = cfg.agents?.defaults?.model as { primary?: string } | string | undefined; - if (typeof raw === "string") { - return raw.trim(); - } - return raw?.primary?.trim() ?? ""; + return resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model) ?? ""; } function resolveConfiguredModelKeys(cfg: OpenClawConfig): string[] { diff --git a/src/commands/models.set.test.ts b/src/commands/models.set.test.ts index 70f8e2272fb..6671c6bb1f0 100644 --- a/src/commands/models.set.test.ts +++ b/src/commands/models.set.test.ts @@ -82,6 +82,25 @@ describe("models set + fallbacks", () => { }); }); + it("preserves primary when adding fallbacks to string defaults.model", async () => { + mockConfigSnapshot({ agents: { defaults: { model: "openai/gpt-4.1-mini" } } }); + const runtime = makeRuntime(); + + await modelsFallbacksAddCommand("anthropic/claude-opus-4-6", runtime); + + expect(writeConfigFile).toHaveBeenCalledTimes(1); + const written = getWrittenConfig(); + expect(written.agents).toEqual({ + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-opus-4-6"], + }, + models: { "anthropic/claude-opus-4-6": {} }, + }, + }); + }); + it("normalizes provider casing in models set", async () => { mockConfigSnapshot({}); const runtime = makeRuntime(); @@ -90,4 +109,20 @@ describe("models set + fallbacks", () => { expectWrittenPrimaryModel("zai/glm-4.7"); }); + + it("rewrites string defaults.model to object form when setting primary", async () => { + mockConfigSnapshot({ agents: { defaults: { model: "openai/gpt-4.1-mini" } } }); + const runtime = makeRuntime(); + + await modelsSetCommand("anthropic/claude-opus-4-6", runtime); + + expect(writeConfigFile).toHaveBeenCalledTimes(1); + const written = getWrittenConfig(); + expect(written.agents).toEqual({ + defaults: { + model: { primary: "anthropic/claude-opus-4-6" }, + models: { "anthropic/claude-opus-4-6": {} }, + }, + }); + }); }); diff --git a/src/commands/models/fallbacks-shared.ts b/src/commands/models/fallbacks-shared.ts index dc540d9425b..736998fb4ec 100644 --- a/src/commands/models/fallbacks-shared.ts +++ b/src/commands/models/fallbacks-shared.ts @@ -2,12 +2,12 @@ import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/mo import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; +import { resolveAgentModelFallbackValues, toAgentModelListLike } from "../../config/model-input.js"; import type { RuntimeEnv } from "../../runtime.js"; import { DEFAULT_PROVIDER, ensureFlagCompatibility, mergePrimaryFallbackConfig, - type PrimaryFallbackConfig, modelKey, resolveModelTarget, resolveModelKeysFromEntries, @@ -17,17 +17,14 @@ import { type DefaultsFallbackKey = "model" | "imageModel"; function getFallbacks(cfg: OpenClawConfig, key: DefaultsFallbackKey): string[] { - const entry = cfg.agents?.defaults?.[key] as unknown as PrimaryFallbackConfig | undefined; - return entry?.fallbacks ?? []; + return resolveAgentModelFallbackValues(cfg.agents?.defaults?.[key]); } function patchDefaultsFallbacks( cfg: OpenClawConfig, params: { key: DefaultsFallbackKey; fallbacks: string[]; models?: Record }, ): OpenClawConfig { - const existing = cfg.agents?.defaults?.[params.key] as unknown as - | PrimaryFallbackConfig - | undefined; + const existing = toAgentModelListLike(cfg.agents?.defaults?.[params.key]); return { ...cfg, agents: { diff --git a/src/commands/models/list.configured.ts b/src/commands/models/list.configured.ts index a4300ea563a..fed70a4fe47 100644 --- a/src/commands/models/list.configured.ts +++ b/src/commands/models/list.configured.ts @@ -5,6 +5,10 @@ import { resolveModelRefFromString, } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, +} from "../../config/model-input.js"; import type { ConfiguredEntry } from "./list.types.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER, modelKey } from "./shared.js"; @@ -37,16 +41,9 @@ export function resolveConfiguredEntries(cfg: OpenClawConfig) { addEntry(resolvedDefault, "default"); - const modelConfig = cfg.agents?.defaults?.model as - | { primary?: string; fallbacks?: string[] } - | undefined; - const imageModelConfig = cfg.agents?.defaults?.imageModel as - | { primary?: string; fallbacks?: string[] } - | undefined; - const modelFallbacks = typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : []; - const imageFallbacks = - typeof imageModelConfig === "object" ? (imageModelConfig?.fallbacks ?? []) : []; - const imagePrimary = imageModelConfig?.primary?.trim() ?? ""; + const modelFallbacks = resolveAgentModelFallbackValues(cfg.agents?.defaults?.model); + const imageFallbacks = resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel); + const imagePrimary = resolveAgentModelPrimaryValue(cfg.agents?.defaults?.imageModel) ?? ""; modelFallbacks.forEach((raw, idx) => { const resolved = resolveModelRefFromString({ diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 7e46c170cc0..830aefdf0af 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -2,8 +2,8 @@ import path from "node:path"; import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; import { resolveAgentDir, + resolveAgentExplicitModelPrimary, resolveAgentModelFallbacksOverride, - resolveAgentModelPrimary, } from "../../agents/agent-scope.js"; import { buildAuthHealthSummary, @@ -26,6 +26,10 @@ import { import { formatCliCommand } from "../../cli/command-format.js"; import { withProgressTotals } from "../../cli/progress.js"; import { CONFIG_PATH, loadConfig } from "../../config/config.js"; +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, +} from "../../config/model-input.js"; import { formatUsageWindowSummary, loadProviderUsageSummary, @@ -75,7 +79,7 @@ export async function modelsStatusCommand( const cfg = loadConfig(); const agentId = resolveKnownAgentId({ cfg, rawAgentId: opts.agent }); const agentDir = agentId ? resolveAgentDir(cfg, agentId) : resolveOpenClawAgentDir(); - const agentModelPrimary = agentId ? resolveAgentModelPrimary(cfg, agentId) : undefined; + const agentModelPrimary = agentId ? resolveAgentExplicitModelPrimary(cfg, agentId) : undefined; const agentFallbacksOverride = agentId ? resolveAgentModelFallbacksOverride(cfg, agentId) : undefined; @@ -87,24 +91,14 @@ export async function modelsStatusCommand( defaultModel: DEFAULT_MODEL, }); - const modelConfig = cfg.agents?.defaults?.model as - | { primary?: string; fallbacks?: string[] } - | string - | undefined; - const imageConfig = cfg.agents?.defaults?.imageModel as - | { primary?: string; fallbacks?: string[] } - | string - | undefined; - const rawDefaultsModel = - typeof modelConfig === "string" ? modelConfig.trim() : (modelConfig?.primary?.trim() ?? ""); + const rawDefaultsModel = resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model) ?? ""; const rawModel = agentModelPrimary ?? rawDefaultsModel; const resolvedLabel = `${resolved.provider}/${resolved.model}`; const defaultLabel = rawModel || resolvedLabel; - const defaultsFallbacks = typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : []; + const defaultsFallbacks = resolveAgentModelFallbackValues(cfg.agents?.defaults?.model); const fallbacks = agentFallbacksOverride ?? defaultsFallbacks; - const imageModel = - typeof imageConfig === "string" ? imageConfig.trim() : (imageConfig?.primary?.trim() ?? ""); - const imageFallbacks = typeof imageConfig === "object" ? (imageConfig?.fallbacks ?? []) : []; + const imageModel = resolveAgentModelPrimaryValue(cfg.agents?.defaults?.imageModel) ?? ""; + const imageFallbacks = resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel); const aliases = Object.entries(cfg.agents?.defaults?.models ?? {}).reduce>( (acc, [key, entry]) => { const alias = typeof entry?.alias === "string" ? entry.alias.trim() : undefined; diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index d8b3f8d4f12..b99cacc1cd4 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -32,7 +32,8 @@ const mocks = vi.hoisted(() => { store, resolveOpenClawAgentDir: vi.fn().mockReturnValue("/tmp/openclaw-agent"), resolveAgentDir: vi.fn().mockReturnValue("/tmp/openclaw-agent"), - resolveAgentModelPrimary: vi.fn().mockReturnValue(undefined), + resolveAgentExplicitModelPrimary: vi.fn().mockReturnValue(undefined), + resolveAgentEffectiveModelPrimary: vi.fn().mockReturnValue(undefined), resolveAgentModelFallbacksOverride: vi.fn().mockReturnValue(undefined), listAgentIds: vi.fn().mockReturnValue(["main", "jeremiah"]), ensureAuthProfileStore: vi.fn().mockReturnValue(store), @@ -83,7 +84,8 @@ vi.mock("../../agents/agent-paths.js", () => ({ vi.mock("../../agents/agent-scope.js", () => ({ resolveAgentDir: mocks.resolveAgentDir, - resolveAgentModelPrimary: mocks.resolveAgentModelPrimary, + resolveAgentExplicitModelPrimary: mocks.resolveAgentExplicitModelPrimary, + resolveAgentEffectiveModelPrimary: mocks.resolveAgentEffectiveModelPrimary, resolveAgentModelFallbacksOverride: mocks.resolveAgentModelFallbacksOverride, listAgentIds: mocks.listAgentIds, })); @@ -153,11 +155,13 @@ async function withAgentScopeOverrides( }, run: () => Promise, ) { - const originalPrimary = mocks.resolveAgentModelPrimary.getMockImplementation(); + const originalPrimary = mocks.resolveAgentExplicitModelPrimary.getMockImplementation(); + const originalEffectivePrimary = mocks.resolveAgentEffectiveModelPrimary.getMockImplementation(); const originalFallbacks = mocks.resolveAgentModelFallbacksOverride.getMockImplementation(); const originalAgentDir = mocks.resolveAgentDir.getMockImplementation(); - mocks.resolveAgentModelPrimary.mockReturnValue(overrides.primary); + mocks.resolveAgentExplicitModelPrimary.mockReturnValue(overrides.primary); + mocks.resolveAgentEffectiveModelPrimary.mockReturnValue(overrides.primary); mocks.resolveAgentModelFallbacksOverride.mockReturnValue(overrides.fallbacks); if (overrides.agentDir) { mocks.resolveAgentDir.mockReturnValue(overrides.agentDir); @@ -167,9 +171,14 @@ async function withAgentScopeOverrides( return await run(); } finally { if (originalPrimary) { - mocks.resolveAgentModelPrimary.mockImplementation(originalPrimary); + mocks.resolveAgentExplicitModelPrimary.mockImplementation(originalPrimary); } else { - mocks.resolveAgentModelPrimary.mockReturnValue(undefined); + mocks.resolveAgentExplicitModelPrimary.mockReturnValue(undefined); + } + if (originalEffectivePrimary) { + mocks.resolveAgentEffectiveModelPrimary.mockImplementation(originalEffectivePrimary); + } else { + mocks.resolveAgentEffectiveModelPrimary.mockReturnValue(undefined); } if (originalFallbacks) { mocks.resolveAgentModelFallbacksOverride.mockImplementation(originalFallbacks); @@ -262,6 +271,24 @@ describe("modelsStatusCommand auth overview", () => { ); }); + it("reports defaults source in JSON when --agent has no overrides", async () => { + const localRuntime = createRuntime(); + await withAgentScopeOverrides( + { + primary: undefined, + fallbacks: undefined, + }, + async () => { + await modelsStatusCommand({ json: true, agent: "main" }, localRuntime as never); + const payload = JSON.parse(String((localRuntime.log as Mock).mock.calls[0]?.[0])); + expect(payload.modelConfig).toEqual({ + defaultSource: "defaults", + fallbacksSource: "defaults", + }); + }, + ); + }); + it("throws when agent id is unknown", async () => { const localRuntime = createRuntime(); await expect(modelsStatusCommand({ agent: "unknown" }, localRuntime as never)).rejects.toThrow( diff --git a/src/commands/models/scan.ts b/src/commands/models/scan.ts index 74b17b8ab86..c62ca0e107a 100644 --- a/src/commands/models/scan.ts +++ b/src/commands/models/scan.ts @@ -4,6 +4,7 @@ import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-s import { withProgressTotals } from "../../cli/progress.js"; import { loadConfig } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; +import { toAgentModelListLike } from "../../config/model-input.js"; import type { RuntimeEnv } from "../../runtime.js"; import { stylePromptHint, @@ -297,9 +298,7 @@ export async function modelsScanCommand( nextModels[entry] = {}; } } - const existingImageModel = cfg.agents?.defaults?.imageModel as - | { primary?: string; fallbacks?: string[] } - | undefined; + const existingImageModel = toAgentModelListLike(cfg.agents?.defaults?.imageModel); const nextImageModel = selectedImages.length > 0 ? { @@ -308,9 +307,7 @@ export async function modelsScanCommand( ...(opts.setImage ? { primary: selectedImages[0] } : {}), } : cfg.agents?.defaults?.imageModel; - const existingModel = cfg.agents?.defaults?.model as - | { primary?: string; fallbacks?: string[] } - | undefined; + const existingModel = toAgentModelListLike(cfg.agents?.defaults?.model); const defaults = { ...cfg.agents?.defaults, model: { 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/models/shared.ts b/src/commands/models/shared.ts index 53836192e7d..925558aad11 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -12,6 +12,8 @@ import { readConfigFileSnapshot, writeConfigFile, } from "../../config/config.js"; +import { toAgentModelListLike } from "../../config/model-input.js"; +import type { AgentModelConfig } from "../../config/types.agents-shared.js"; import { normalizeAgentId } from "../../routing/session-key.js"; export const ensureFlagCompatibility = (opts: { json?: boolean; plain?: boolean }) => { @@ -164,7 +166,8 @@ export function mergePrimaryFallbackConfig( existing: PrimaryFallbackConfig | undefined, patch: { primary?: string; fallbacks?: string[] }, ): PrimaryFallbackConfig { - const next: PrimaryFallbackConfig = { ...existing }; + const base = existing && typeof existing === "object" ? existing : undefined; + const next: PrimaryFallbackConfig = { ...base }; if (patch.primary !== undefined) { next.primary = patch.primary; } @@ -188,9 +191,9 @@ export function applyDefaultModelPrimaryUpdate(params: { } const defaults = params.cfg.agents?.defaults ?? {}; - const existing = (defaults as Record)[params.field] as - | PrimaryFallbackConfig - | undefined; + const existing = toAgentModelListLike( + (defaults as Record)[params.field] as AgentModelConfig | undefined, + ); return { ...params.cfg, 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/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 48f9e87c31e..6e029531f50 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -6,6 +6,7 @@ import { cancel, isCancel } from "@clack/prompts"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js"; import type { OpenClawConfig } from "../config/config.js"; import { CONFIG_PATH } from "../config/config.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; @@ -43,7 +44,7 @@ export function summarizeExistingConfig(config: OpenClawConfig): string { rows.push(shortenHomeInString(`workspace: ${defaults.workspace}`)); } if (defaults?.model) { - const model = typeof defaults.model === "string" ? defaults.model : defaults.model.primary; + const model = resolveAgentModelPrimaryValue(defaults.model); if (model) { rows.push(shortenHomeInString(`model: ${model}`)); } 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/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 95eb4219455..ff42403f868 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -91,6 +91,19 @@ describe("config schema regressions", () => { expect(res.ok).toBe(true); }); + it("accepts string values for agents defaults model inputs", () => { + const res = validateConfigObject({ + agents: { + defaults: { + model: "anthropic/claude-opus-4-6", + imageModel: "openai/gpt-4.1-mini", + }, + }, + }); + + expect(res.ok).toBe(true); + }); + it("rejects relative iMessage attachment roots", () => { const res = validateConfigObject({ channels: { 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/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 76386659018..a4fb3c2443b 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -15,20 +15,8 @@ import { export const AgentDefaultsSchema = z .object({ - model: z - .object({ - primary: z.string().optional(), - fallbacks: z.array(z.string()).optional(), - }) - .strict() - .optional(), - imageModel: z - .object({ - primary: z.string().optional(), - fallbacks: z.array(z.string()).optional(), - }) - .strict() - .optional(), + model: AgentModelSchema.optional(), + imageModel: AgentModelSchema.optional(), models: z .record( z.string(), diff --git a/src/media-understanding/runner.ts b/src/media-understanding/runner.ts index ad7b28328d9..c2ffe584448 100644 --- a/src/media-understanding/runner.ts +++ b/src/media-understanding/runner.ts @@ -10,6 +10,10 @@ import { } from "../agents/model-catalog.js"; import type { MsgContext } from "../auto-reply/templating.js"; import type { OpenClawConfig } from "../config/config.js"; +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, +} from "../config/model-input.js"; import type { MediaUnderstandingConfig, MediaUnderstandingModelConfig, @@ -418,28 +422,19 @@ async function resolveKeyEntry(params: { } function resolveImageModelFromAgentDefaults(cfg: OpenClawConfig): MediaUnderstandingModelConfig[] { - const imageModel = cfg.agents?.defaults?.imageModel as - | { primary?: string; fallbacks?: string[] } - | string - | undefined; - if (!imageModel) { - return []; - } const refs: string[] = []; - if (typeof imageModel === "string") { - if (imageModel.trim()) { - refs.push(imageModel.trim()); - } - } else { - if (imageModel.primary?.trim()) { - refs.push(imageModel.primary.trim()); - } - for (const fb of imageModel.fallbacks ?? []) { - if (fb?.trim()) { - refs.push(fb.trim()); - } + const primary = resolveAgentModelPrimaryValue(cfg.agents?.defaults?.imageModel); + if (primary?.trim()) { + refs.push(primary.trim()); + } + for (const fb of resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel)) { + if (fb?.trim()) { + refs.push(fb.trim()); } } + if (refs.length === 0) { + return []; + } const entries: MediaUnderstandingModelConfig[] = []; for (const ref of refs) { const slashIdx = ref.indexOf("/"); 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"); }