diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 50f40998ca1..f428a20546a 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -718,9 +718,14 @@ 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 }`). + - Only used if 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/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 6249eef9954..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 { @@ -205,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 00334d3fd12..6f6e6d10f09 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -1,5 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; -import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import { resolveAgentModelPrimaryValue, toAgentModelListLike } from "../config/model-input.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveAgentConfig, resolveAgentEffectiveModelPrimary } from "./agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; @@ -309,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, }, }, 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/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/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/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 5929d5e3653..830aefdf0af 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -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, @@ -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/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/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/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("/");