From 310d2db3124126331b412df68ddd9ca14556b728 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 22:41:40 +0100 Subject: [PATCH] refactor: share model selection helpers --- src/agents/model-selection-resolve.ts | 693 ++---------------------- src/agents/model-selection-shared.ts | 717 +++++++++++++++++++++++++ src/agents/model-selection.ts | 725 +++----------------------- 3 files changed, 815 insertions(+), 1320 deletions(-) create mode 100644 src/agents/model-selection-shared.ts diff --git a/src/agents/model-selection-resolve.ts b/src/agents/model-selection-resolve.ts index eda53e881a9..fd02eaead42 100644 --- a/src/agents/model-selection-resolve.ts +++ b/src/agents/model-selection-resolve.ts @@ -1,456 +1,26 @@ -import { - resolveAgentModelFallbackValues, - resolveAgentModelPrimaryValue, -} from "../config/model-input.js"; +import { resolveAgentModelFallbackValues } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "../shared/string-coerce.js"; -import { sanitizeForLog, stripAnsi } from "../terminal/ansi.js"; -import { resolveConfiguredProviderFallback } from "./configured-provider-fallback.js"; -import { DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; -import { splitTrailingAuthProfile } from "./model-ref-profile.js"; +import type { ModelRef } from "./model-selection-normalize.js"; import { - type ModelRef, - findNormalizedProviderValue, - modelKey, - normalizeModelRef, - normalizeProviderId, - parseModelRef, -} from "./model-selection-normalize.js"; + buildAllowedModelSetWithFallbacks, + buildModelAliasIndex, + getModelRefStatusWithFallbackModels, + resolveAllowedModelRefFromAliasIndex, + type ModelRefStatus, +} from "./model-selection-shared.js"; -let log: ReturnType | null = null; - -const OPENROUTER_COMPAT_FREE_ALIAS = "openrouter:free"; - -function getLog(): ReturnType { - log ??= createSubsystemLogger("model-selection"); - return log; -} - -export type ModelAliasIndex = { - byAlias: Map; - byKey: Map; -}; - -function isConcreteOpenRouterFreeModelRef(ref: ModelRef): boolean { - return ref.provider === "openrouter" && ref.model.includes("/") && ref.model.endsWith(":free"); -} - -function resolveConfiguredOpenRouterCompatFreeRef(params: { - cfg: OpenClawConfig; - defaultProvider: string; - allowPluginNormalization?: boolean; -}): ModelRef | null { - const configuredModels = params.cfg.agents?.defaults?.models ?? {}; - for (const raw of Object.keys(configuredModels)) { - if (!raw.includes("/")) { - continue; - } - const parsed = parseModelRef(raw, params.defaultProvider, { - allowPluginNormalization: params.allowPluginNormalization, - }); - if (parsed && isConcreteOpenRouterFreeModelRef(parsed)) { - return parsed; - } - } - - const openrouterProviderConfig = findNormalizedProviderValue( - params.cfg.models?.providers, - "openrouter", - ); - for (const entry of openrouterProviderConfig?.models ?? []) { - const modelId = entry?.id?.trim(); - if (!modelId || !modelId.includes("/") || !modelId.endsWith(":free")) { - continue; - } - return normalizeModelRef("openrouter", modelId, { - allowPluginNormalization: params.allowPluginNormalization, - }); - } - - return null; -} - -function resolveConfiguredOpenRouterCompatAlias(params: { - cfg?: OpenClawConfig; - raw: string; - defaultProvider: string; - allowPluginNormalization?: boolean; -}): ModelRef | null { - const normalized = normalizeLowercaseStringOrEmpty(params.raw); - if (normalized === "openrouter:auto") { - return normalizeModelRef("openrouter", "auto", { - allowPluginNormalization: params.allowPluginNormalization, - }); - } - if (normalized !== OPENROUTER_COMPAT_FREE_ALIAS || !params.cfg) { - return null; - } - return resolveConfiguredOpenRouterCompatFreeRef({ - cfg: params.cfg, - defaultProvider: params.defaultProvider, - allowPluginNormalization: params.allowPluginNormalization, - }); -} - -function parseModelRefWithCompatAlias(params: { - cfg?: OpenClawConfig; - raw: string; - defaultProvider: string; - allowPluginNormalization?: boolean; -}): ModelRef | null { - return ( - resolveConfiguredOpenRouterCompatAlias(params) ?? - parseModelRef(params.raw, params.defaultProvider, { - allowPluginNormalization: params.allowPluginNormalization, - }) - ); -} - -function sanitizeModelWarningValue(value: string): string { - const stripped = value ? stripAnsi(value) : ""; - let controlBoundary = -1; - for (let index = 0; index < stripped.length; index += 1) { - const code = stripped.charCodeAt(index); - if (code <= 0x1f || code === 0x7f) { - controlBoundary = index; - break; - } - } - if (controlBoundary === -1) { - return sanitizeForLog(stripped); - } - return sanitizeForLog(stripped.slice(0, controlBoundary)); -} - -export function inferUniqueProviderFromConfiguredModels(params: { - cfg: OpenClawConfig; - model: string; -}): string | undefined { - const model = params.model.trim(); - if (!model) { - return undefined; - } - const normalized = normalizeLowercaseStringOrEmpty(model); - const providers = new Set(); - const addProvider = (provider: string) => { - const normalizedProvider = normalizeProviderId(provider); - if (!normalizedProvider) { - return; - } - providers.add(normalizedProvider); - }; - const configuredModels = params.cfg.agents?.defaults?.models; - if (configuredModels) { - for (const key of Object.keys(configuredModels)) { - const ref = key.trim(); - if (!ref || !ref.includes("/")) { - continue; - } - const parsed = parseModelRef(ref, DEFAULT_PROVIDER, { - allowPluginNormalization: false, - }); - if (!parsed) { - continue; - } - if (parsed.model === model || normalizeLowercaseStringOrEmpty(parsed.model) === normalized) { - addProvider(parsed.provider); - if (providers.size > 1) { - return undefined; - } - } - } - } - const configuredProviders = params.cfg.models?.providers; - if (configuredProviders) { - for (const [providerId, providerConfig] of Object.entries(configuredProviders)) { - const models = providerConfig?.models; - if (!Array.isArray(models)) { - continue; - } - for (const entry of models) { - const modelId = entry?.id?.trim(); - if (!modelId) { - continue; - } - if (modelId === model || normalizeLowercaseStringOrEmpty(modelId) === normalized) { - addProvider(providerId); - } - } - if (providers.size > 1) { - return undefined; - } - } - } - if (providers.size !== 1) { - return undefined; - } - return providers.values().next().value; -} - -function resolveAllowlistModelKey(params: { - cfg?: OpenClawConfig; - raw: string; - defaultProvider: string; -}): string | null { - const parsed = parseModelRefWithCompatAlias({ - cfg: params.cfg, - raw: params.raw, - defaultProvider: params.defaultProvider, - }); - if (!parsed) { - return null; - } - return modelKey(parsed.provider, parsed.model); -} - -export function buildConfiguredAllowlistKeys(params: { - cfg: OpenClawConfig | undefined; - defaultProvider: string; -}): Set | null { - const rawAllowlist = Object.keys(params.cfg?.agents?.defaults?.models ?? {}); - if (rawAllowlist.length === 0) { - return null; - } - - const keys = new Set(); - for (const raw of rawAllowlist) { - const key = resolveAllowlistModelKey({ - cfg: params.cfg, - raw, - defaultProvider: params.defaultProvider, - }); - if (key) { - keys.add(key); - } - } - return keys.size > 0 ? keys : null; -} - -export function buildModelAliasIndex(params: { - cfg: OpenClawConfig; - defaultProvider: string; - allowPluginNormalization?: boolean; -}): ModelAliasIndex { - const byAlias = new Map(); - const byKey = new Map(); - - const rawModels = params.cfg.agents?.defaults?.models ?? {}; - for (const [keyRaw, entryRaw] of Object.entries(rawModels)) { - const parsed = parseModelRefWithCompatAlias({ - cfg: params.cfg, - raw: keyRaw, - defaultProvider: params.defaultProvider, - allowPluginNormalization: params.allowPluginNormalization, - }); - if (!parsed) { - continue; - } - const alias = - normalizeOptionalString((entryRaw as { alias?: string } | undefined)?.alias) ?? ""; - if (!alias) { - continue; - } - const aliasKey = normalizeLowercaseStringOrEmpty(alias); - byAlias.set(aliasKey, { alias, ref: parsed }); - const key = modelKey(parsed.provider, parsed.model); - const existing = byKey.get(key) ?? []; - existing.push(alias); - byKey.set(key, existing); - } - - return { byAlias, byKey }; -} - -type ModelCatalogMetadata = { - configuredByKey: Map; - aliasByKey: Map; -}; - -function buildModelCatalogMetadata(params: { - cfg: OpenClawConfig; - defaultProvider: string; -}): ModelCatalogMetadata { - const configuredByKey = new Map(); - for (const entry of buildConfiguredModelCatalog({ cfg: params.cfg })) { - configuredByKey.set(modelKey(entry.provider, entry.id), entry); - } - - const aliasByKey = new Map(); - const configuredModels = params.cfg.agents?.defaults?.models ?? {}; - for (const [rawKey, entryRaw] of Object.entries(configuredModels)) { - const key = resolveAllowlistModelKey({ - cfg: params.cfg, - raw: rawKey, - defaultProvider: params.defaultProvider, - }); - if (!key) { - continue; - } - const alias = ((entryRaw as { alias?: string } | undefined)?.alias ?? "").trim(); - if (!alias) { - continue; - } - aliasByKey.set(key, alias); - } - - return { configuredByKey, aliasByKey }; -} - -function applyModelCatalogMetadata(params: { - entry: ModelCatalogEntry; - metadata: ModelCatalogMetadata; -}): ModelCatalogEntry { - const key = modelKey(params.entry.provider, params.entry.id); - const configuredEntry = params.metadata.configuredByKey.get(key); - const alias = params.metadata.aliasByKey.get(key); - if (!configuredEntry && !alias) { - return params.entry; - } - const nextAlias = alias ?? params.entry.alias; - const nextContextWindow = configuredEntry?.contextWindow ?? params.entry.contextWindow; - const nextReasoning = configuredEntry?.reasoning ?? params.entry.reasoning; - const nextInput = configuredEntry?.input ?? params.entry.input; - - return { - ...params.entry, - name: configuredEntry?.name ?? params.entry.name, - ...(nextAlias ? { alias: nextAlias } : {}), - ...(nextContextWindow !== undefined ? { contextWindow: nextContextWindow } : {}), - ...(nextReasoning !== undefined ? { reasoning: nextReasoning } : {}), - ...(nextInput ? { input: nextInput } : {}), - }; -} - -function buildSyntheticAllowedCatalogEntry(params: { - parsed: ModelRef; - metadata: ModelCatalogMetadata; -}): ModelCatalogEntry { - const key = modelKey(params.parsed.provider, params.parsed.model); - const configuredEntry = params.metadata.configuredByKey.get(key); - const alias = params.metadata.aliasByKey.get(key); - const nextContextWindow = configuredEntry?.contextWindow; - const nextReasoning = configuredEntry?.reasoning; - const nextInput = configuredEntry?.input; - - return { - id: params.parsed.model, - name: configuredEntry?.name ?? params.parsed.model, - provider: params.parsed.provider, - ...(alias ? { alias } : {}), - ...(nextContextWindow !== undefined ? { contextWindow: nextContextWindow } : {}), - ...(nextReasoning !== undefined ? { reasoning: nextReasoning } : {}), - ...(nextInput ? { input: nextInput } : {}), - }; -} - -export function resolveModelRefFromString(params: { - cfg?: OpenClawConfig; - raw: string; - defaultProvider: string; - aliasIndex?: ModelAliasIndex; - allowPluginNormalization?: boolean; -}): { ref: ModelRef; alias?: string } | null { - const { model } = splitTrailingAuthProfile(params.raw); - if (!model) { - return null; - } - if (!model.includes("/")) { - const aliasKey = normalizeLowercaseStringOrEmpty(model); - const aliasMatch = params.aliasIndex?.byAlias.get(aliasKey); - if (aliasMatch) { - return { ref: aliasMatch.ref, alias: aliasMatch.alias }; - } - } - const parsed = parseModelRefWithCompatAlias({ - cfg: params.cfg, - raw: model, - defaultProvider: params.defaultProvider, - allowPluginNormalization: params.allowPluginNormalization, - }); - if (!parsed) { - return null; - } - return { ref: parsed }; -} - -export function resolveConfiguredModelRef(params: { - cfg: OpenClawConfig; - defaultProvider: string; - defaultModel: string; - allowPluginNormalization?: boolean; -}): ModelRef { - const rawModel = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model) ?? ""; - if (rawModel) { - const trimmed = rawModel.trim(); - const aliasIndex = buildModelAliasIndex({ - cfg: params.cfg, - defaultProvider: params.defaultProvider, - allowPluginNormalization: params.allowPluginNormalization, - }); - if (!trimmed.includes("/")) { - const openRouterCompatRef = resolveConfiguredOpenRouterCompatAlias({ - cfg: params.cfg, - raw: trimmed, - defaultProvider: params.defaultProvider, - allowPluginNormalization: params.allowPluginNormalization, - }); - if (openRouterCompatRef) { - return openRouterCompatRef; - } - - const aliasKey = normalizeLowercaseStringOrEmpty(trimmed); - const aliasMatch = aliasIndex.byAlias.get(aliasKey); - if (aliasMatch) { - return aliasMatch.ref; - } - - const inferredProvider = inferUniqueProviderFromConfiguredModels({ - cfg: params.cfg, - model: trimmed, - }); - if (inferredProvider) { - return { provider: inferredProvider, model: trimmed }; - } - - const safeTrimmed = sanitizeModelWarningValue(trimmed); - const safeResolved = sanitizeForLog(`${params.defaultProvider}/${safeTrimmed}`); - getLog().warn( - `Model "${safeTrimmed}" specified without provider. Falling back to "${safeResolved}". Please use "${safeResolved}" in your config.`, - ); - return { provider: params.defaultProvider, model: trimmed }; - } - - const resolved = resolveModelRefFromString({ - cfg: params.cfg, - raw: trimmed, - defaultProvider: params.defaultProvider, - aliasIndex, - allowPluginNormalization: params.allowPluginNormalization, - }); - if (resolved) { - return resolved.ref; - } - - const safe = sanitizeForLog(trimmed); - const safeFallback = sanitizeForLog(`${params.defaultProvider}/${params.defaultModel}`); - getLog().warn( - `Model "${safe}" could not be resolved. Falling back to default "${safeFallback}".`, - ); - } - const fallbackProvider = resolveConfiguredProviderFallback({ - cfg: params.cfg, - defaultProvider: params.defaultProvider, - }); - if (fallbackProvider) { - return fallbackProvider; - } - return { provider: params.defaultProvider, model: params.defaultModel }; -} +export { + buildConfiguredAllowlistKeys, + buildConfiguredModelCatalog, + buildModelAliasIndex, + inferUniqueProviderFromConfiguredModels, + normalizeModelSelection, + resolveConfiguredModelRef, + resolveHooksGmailModel, + resolveModelRefFromString, +} from "./model-selection-shared.js"; +export type { ModelAliasIndex, ModelRefStatus } from "./model-selection-shared.js"; export function buildAllowedModelSet(params: { cfg: OpenClawConfig; @@ -462,143 +32,15 @@ export function buildAllowedModelSet(params: { allowedCatalog: ModelCatalogEntry[]; allowedKeys: Set; } { - const metadata = buildModelCatalogMetadata({ + return buildAllowedModelSetWithFallbacks({ cfg: params.cfg, + catalog: params.catalog, defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + fallbackModels: resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model), }); - const catalog = params.catalog.map((entry) => applyModelCatalogMetadata({ entry, metadata })); - const rawAllowlist = (() => { - const modelMap = params.cfg.agents?.defaults?.models ?? {}; - return Object.keys(modelMap); - })(); - const allowAny = rawAllowlist.length === 0; - const defaultModel = params.defaultModel?.trim(); - const defaultRef = - defaultModel && params.defaultProvider - ? parseModelRefWithCompatAlias({ - cfg: params.cfg, - raw: defaultModel, - defaultProvider: params.defaultProvider, - }) - : null; - const defaultKey = defaultRef ? modelKey(defaultRef.provider, defaultRef.model) : undefined; - const catalogKeys = new Set(catalog.map((entry) => modelKey(entry.provider, entry.id))); - - if (allowAny) { - if (defaultKey) { - catalogKeys.add(defaultKey); - } - return { - allowAny: true, - allowedCatalog: catalog, - allowedKeys: catalogKeys, - }; - } - - const allowedKeys = new Set(); - const syntheticCatalogEntries = new Map(); - for (const raw of rawAllowlist) { - const parsed = parseModelRefWithCompatAlias({ - cfg: params.cfg, - raw, - defaultProvider: params.defaultProvider, - }); - if (!parsed) { - continue; - } - const key = modelKey(parsed.provider, parsed.model); - allowedKeys.add(key); - - if (!catalogKeys.has(key) && !syntheticCatalogEntries.has(key)) { - syntheticCatalogEntries.set(key, buildSyntheticAllowedCatalogEntry({ parsed, metadata })); - } - } - - for (const fallback of resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model)) { - const parsed = parseModelRefWithCompatAlias({ - cfg: params.cfg, - raw: fallback, - defaultProvider: params.defaultProvider, - }); - if (!parsed) { - continue; - } - const key = modelKey(parsed.provider, parsed.model); - allowedKeys.add(key); - - if (!catalogKeys.has(key) && !syntheticCatalogEntries.has(key)) { - syntheticCatalogEntries.set(key, buildSyntheticAllowedCatalogEntry({ parsed, metadata })); - } - } - - if (defaultKey) { - allowedKeys.add(defaultKey); - } - - const allowedCatalog = [ - ...catalog.filter((entry) => allowedKeys.has(modelKey(entry.provider, entry.id))), - ...syntheticCatalogEntries.values(), - ]; - - if (allowedCatalog.length === 0 && allowedKeys.size === 0) { - if (defaultKey) { - catalogKeys.add(defaultKey); - } - return { - allowAny: true, - allowedCatalog: catalog, - allowedKeys: catalogKeys, - }; - } - - return { allowAny: false, allowedCatalog, allowedKeys }; } -export function buildConfiguredModelCatalog(params: { cfg: OpenClawConfig }): ModelCatalogEntry[] { - const providers = params.cfg.models?.providers; - if (!providers || typeof providers !== "object") { - return []; - } - - const catalog: ModelCatalogEntry[] = []; - for (const [providerRaw, provider] of Object.entries(providers)) { - const providerId = normalizeProviderId(providerRaw); - if (!providerId || !Array.isArray(provider?.models)) { - continue; - } - for (const model of provider.models) { - const id = normalizeOptionalString(model?.id) ?? ""; - if (!id) { - continue; - } - const name = normalizeOptionalString(model?.name) || id; - const contextWindow = - typeof model?.contextWindow === "number" && model.contextWindow > 0 - ? model.contextWindow - : undefined; - const reasoning = typeof model?.reasoning === "boolean" ? model.reasoning : undefined; - const input = Array.isArray(model?.input) ? model.input : undefined; - catalog.push({ - provider: providerId, - id, - name, - contextWindow, - reasoning, - input, - }); - } - } - - return catalog; -} - -export type ModelRefStatus = { - key: string; - inCatalog: boolean; - allowAny: boolean; - allowed: boolean; -}; - export function getModelRefStatus(params: { cfg: OpenClawConfig; catalog: ModelCatalogEntry[]; @@ -606,19 +48,14 @@ export function getModelRefStatus(params: { defaultProvider: string; defaultModel?: string; }): ModelRefStatus { - const allowed = buildAllowedModelSet({ + return getModelRefStatusWithFallbackModels({ cfg: params.cfg, catalog: params.catalog, + ref: params.ref, defaultProvider: params.defaultProvider, defaultModel: params.defaultModel, + fallbackModels: resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model), }); - const key = modelKey(params.ref.provider, params.ref.model); - return { - key, - inCatalog: params.catalog.some((entry) => modelKey(entry.provider, entry.id) === key), - allowAny: allowed.allowAny, - allowed: allowed.allowAny || allowed.allowedKeys.has(key), - }; } export function resolveAllowedModelRef(params: { @@ -632,80 +69,22 @@ export function resolveAllowedModelRef(params: { | { error: string; } { - const trimmed = params.raw.trim(); - if (!trimmed) { - return { error: "invalid model: empty" }; - } - const aliasIndex = buildModelAliasIndex({ cfg: params.cfg, defaultProvider: params.defaultProvider, }); - - const effectiveDefaultProvider = !trimmed.includes("/") - ? (inferUniqueProviderFromConfiguredModels({ cfg: params.cfg, model: trimmed }) ?? - params.defaultProvider) - : params.defaultProvider; - - const resolved = resolveModelRefFromString({ + return resolveAllowedModelRefFromAliasIndex({ cfg: params.cfg, - raw: trimmed, - defaultProvider: effectiveDefaultProvider, - aliasIndex, - }); - if (!resolved) { - return { error: `invalid model: ${trimmed}` }; - } - - const status = getModelRefStatus({ - cfg: params.cfg, - catalog: params.catalog, - ref: resolved.ref, - defaultProvider: params.defaultProvider, - defaultModel: params.defaultModel, - }); - if (!status.allowed) { - return { error: `model not allowed: ${status.key}` }; - } - - return { ref: resolved.ref, key: status.key }; -} - -export function resolveHooksGmailModel(params: { - cfg: OpenClawConfig; - defaultProvider: string; -}): ModelRef | null { - const hooksModel = params.cfg.hooks?.gmail?.model; - if (!hooksModel?.trim()) { - return null; - } - - const aliasIndex = buildModelAliasIndex({ - cfg: params.cfg, - defaultProvider: params.defaultProvider, - }); - - const resolved = resolveModelRefFromString({ - cfg: params.cfg, - raw: hooksModel, + raw: params.raw, defaultProvider: params.defaultProvider, aliasIndex, + getStatus: (ref) => + getModelRefStatus({ + cfg: params.cfg, + catalog: params.catalog, + ref, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + }), }); - - return resolved?.ref ?? null; -} - -export function normalizeModelSelection(value: unknown): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed || undefined; - } - if (!value || typeof value !== "object") { - return undefined; - } - const primary = (value as { primary?: unknown }).primary; - if (typeof primary === "string" && primary.trim()) { - return primary.trim(); - } - return undefined; } diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts new file mode 100644 index 00000000000..5bbd4c5e86b --- /dev/null +++ b/src/agents/model-selection-shared.ts @@ -0,0 +1,717 @@ +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; +import { sanitizeForLog, stripAnsi } from "../terminal/ansi.js"; +import { resolveConfiguredProviderFallback } from "./configured-provider-fallback.js"; +import { DEFAULT_PROVIDER } from "./defaults.js"; +import type { ModelCatalogEntry } from "./model-catalog.types.js"; +import { splitTrailingAuthProfile } from "./model-ref-profile.js"; +import { + type ModelRef, + findNormalizedProviderValue, + modelKey, + normalizeModelRef, + normalizeProviderId, + parseModelRef, +} from "./model-selection-normalize.js"; + +let log: ReturnType | null = null; + +function getLog(): ReturnType { + log ??= createSubsystemLogger("model-selection"); + return log; +} + +const OPENROUTER_COMPAT_FREE_ALIAS = "openrouter:free"; + +export type ModelAliasIndex = { + byAlias: Map; + byKey: Map; +}; + +function sanitizeModelWarningValue(value: string): string { + const stripped = value ? stripAnsi(value) : ""; + let controlBoundary = -1; + for (let index = 0; index < stripped.length; index += 1) { + const code = stripped.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) { + controlBoundary = index; + break; + } + } + if (controlBoundary === -1) { + return sanitizeForLog(stripped); + } + return sanitizeForLog(stripped.slice(0, controlBoundary)); +} + +export function inferUniqueProviderFromConfiguredModels(params: { + cfg: OpenClawConfig; + model: string; +}): string | undefined { + const model = params.model.trim(); + if (!model) { + return undefined; + } + const normalized = normalizeLowercaseStringOrEmpty(model); + const providers = new Set(); + const addProvider = (provider: string) => { + const normalizedProvider = normalizeProviderId(provider); + if (!normalizedProvider) { + return; + } + providers.add(normalizedProvider); + }; + const configuredModels = params.cfg.agents?.defaults?.models; + if (configuredModels) { + for (const key of Object.keys(configuredModels)) { + const ref = key.trim(); + if (!ref || !ref.includes("/")) { + continue; + } + const parsed = parseModelRef(ref, DEFAULT_PROVIDER, { + allowPluginNormalization: false, + }); + if (!parsed) { + continue; + } + if (parsed.model === model || normalizeLowercaseStringOrEmpty(parsed.model) === normalized) { + addProvider(parsed.provider); + if (providers.size > 1) { + return undefined; + } + } + } + } + const configuredProviders = params.cfg.models?.providers; + if (configuredProviders) { + for (const [providerId, providerConfig] of Object.entries(configuredProviders)) { + const models = providerConfig?.models; + if (!Array.isArray(models)) { + continue; + } + for (const entry of models) { + const modelId = entry?.id?.trim(); + if (!modelId) { + continue; + } + if (modelId === model || normalizeLowercaseStringOrEmpty(modelId) === normalized) { + addProvider(providerId); + } + } + if (providers.size > 1) { + return undefined; + } + } + } + if (providers.size !== 1) { + return undefined; + } + return providers.values().next().value; +} + +function isConcreteOpenRouterFreeModelRef(ref: ModelRef): boolean { + return ref.provider === "openrouter" && ref.model.includes("/") && ref.model.endsWith(":free"); +} + +function resolveConfiguredOpenRouterCompatFreeRef(params: { + cfg: OpenClawConfig; + defaultProvider: string; + allowPluginNormalization?: boolean; +}): ModelRef | null { + const configuredModels = params.cfg.agents?.defaults?.models ?? {}; + for (const raw of Object.keys(configuredModels)) { + if (!raw.includes("/")) { + continue; + } + const parsed = parseModelRef(raw, params.defaultProvider, { + allowPluginNormalization: params.allowPluginNormalization, + }); + if (parsed && isConcreteOpenRouterFreeModelRef(parsed)) { + return parsed; + } + } + + const openrouterProviderConfig = findNormalizedProviderValue( + params.cfg.models?.providers, + "openrouter", + ); + for (const entry of openrouterProviderConfig?.models ?? []) { + const modelId = entry?.id?.trim(); + if (!modelId || !modelId.includes("/") || !modelId.endsWith(":free")) { + continue; + } + return normalizeModelRef("openrouter", modelId, { + allowPluginNormalization: params.allowPluginNormalization, + }); + } + + return null; +} + +export function resolveConfiguredOpenRouterCompatAlias(params: { + cfg?: OpenClawConfig; + raw: string; + defaultProvider: string; + allowPluginNormalization?: boolean; +}): ModelRef | null { + const normalized = normalizeLowercaseStringOrEmpty(params.raw); + if (normalized === "openrouter:auto") { + return normalizeModelRef("openrouter", "auto", { + allowPluginNormalization: params.allowPluginNormalization, + }); + } + if (normalized !== OPENROUTER_COMPAT_FREE_ALIAS || !params.cfg) { + return null; + } + return resolveConfiguredOpenRouterCompatFreeRef({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + allowPluginNormalization: params.allowPluginNormalization, + }); +} + +export function parseModelRefWithCompatAlias(params: { + cfg?: OpenClawConfig; + raw: string; + defaultProvider: string; + allowPluginNormalization?: boolean; +}): ModelRef | null { + return ( + resolveConfiguredOpenRouterCompatAlias(params) ?? + parseModelRef(params.raw, params.defaultProvider, { + allowPluginNormalization: params.allowPluginNormalization, + }) + ); +} + +export function resolveAllowlistModelKey(params: { + cfg?: OpenClawConfig; + raw: string; + defaultProvider: string; +}): string | null { + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: params.raw, + defaultProvider: params.defaultProvider, + }); + if (!parsed) { + return null; + } + return modelKey(parsed.provider, parsed.model); +} + +export function buildConfiguredAllowlistKeys(params: { + cfg: OpenClawConfig | undefined; + defaultProvider: string; +}): Set | null { + const rawAllowlist = Object.keys(params.cfg?.agents?.defaults?.models ?? {}); + if (rawAllowlist.length === 0) { + return null; + } + + const keys = new Set(); + for (const raw of rawAllowlist) { + const key = resolveAllowlistModelKey({ + cfg: params.cfg, + raw, + defaultProvider: params.defaultProvider, + }); + if (key) { + keys.add(key); + } + } + return keys.size > 0 ? keys : null; +} + +export function buildModelAliasIndex(params: { + cfg: OpenClawConfig; + defaultProvider: string; + allowPluginNormalization?: boolean; +}): ModelAliasIndex { + const byAlias = new Map(); + const byKey = new Map(); + + const rawModels = params.cfg.agents?.defaults?.models ?? {}; + for (const [keyRaw, entryRaw] of Object.entries(rawModels)) { + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: keyRaw, + defaultProvider: params.defaultProvider, + allowPluginNormalization: params.allowPluginNormalization, + }); + if (!parsed) { + continue; + } + const alias = + normalizeOptionalString((entryRaw as { alias?: string } | undefined)?.alias) ?? ""; + if (!alias) { + continue; + } + const aliasKey = normalizeLowercaseStringOrEmpty(alias); + byAlias.set(aliasKey, { alias, ref: parsed }); + const key = modelKey(parsed.provider, parsed.model); + const existing = byKey.get(key) ?? []; + existing.push(alias); + byKey.set(key, existing); + } + + return { byAlias, byKey }; +} + +type ModelCatalogMetadata = { + configuredByKey: Map; + aliasByKey: Map; +}; + +function buildModelCatalogMetadata(params: { + cfg: OpenClawConfig; + defaultProvider: string; +}): ModelCatalogMetadata { + const configuredByKey = new Map(); + for (const entry of buildConfiguredModelCatalog({ cfg: params.cfg })) { + configuredByKey.set(modelKey(entry.provider, entry.id), entry); + } + + const aliasByKey = new Map(); + const configuredModels = params.cfg.agents?.defaults?.models ?? {}; + for (const [rawKey, entryRaw] of Object.entries(configuredModels)) { + const key = resolveAllowlistModelKey({ + cfg: params.cfg, + raw: rawKey, + defaultProvider: params.defaultProvider, + }); + if (!key) { + continue; + } + const alias = ((entryRaw as { alias?: string } | undefined)?.alias ?? "").trim(); + if (!alias) { + continue; + } + aliasByKey.set(key, alias); + } + + return { configuredByKey, aliasByKey }; +} + +function applyModelCatalogMetadata(params: { + entry: ModelCatalogEntry; + metadata: ModelCatalogMetadata; +}): ModelCatalogEntry { + const key = modelKey(params.entry.provider, params.entry.id); + const configuredEntry = params.metadata.configuredByKey.get(key); + const alias = params.metadata.aliasByKey.get(key); + if (!configuredEntry && !alias) { + return params.entry; + } + const nextAlias = alias ?? params.entry.alias; + const nextContextWindow = configuredEntry?.contextWindow ?? params.entry.contextWindow; + const nextReasoning = configuredEntry?.reasoning ?? params.entry.reasoning; + const nextInput = configuredEntry?.input ?? params.entry.input; + + return { + ...params.entry, + name: configuredEntry?.name ?? params.entry.name, + ...(nextAlias ? { alias: nextAlias } : {}), + ...(nextContextWindow !== undefined ? { contextWindow: nextContextWindow } : {}), + ...(nextReasoning !== undefined ? { reasoning: nextReasoning } : {}), + ...(nextInput ? { input: nextInput } : {}), + }; +} + +function buildSyntheticAllowedCatalogEntry(params: { + parsed: ModelRef; + metadata: ModelCatalogMetadata; +}): ModelCatalogEntry { + const key = modelKey(params.parsed.provider, params.parsed.model); + const configuredEntry = params.metadata.configuredByKey.get(key); + const alias = params.metadata.aliasByKey.get(key); + const nextContextWindow = configuredEntry?.contextWindow; + const nextReasoning = configuredEntry?.reasoning; + const nextInput = configuredEntry?.input; + + return { + id: params.parsed.model, + name: configuredEntry?.name ?? params.parsed.model, + provider: params.parsed.provider, + ...(alias ? { alias } : {}), + ...(nextContextWindow !== undefined ? { contextWindow: nextContextWindow } : {}), + ...(nextReasoning !== undefined ? { reasoning: nextReasoning } : {}), + ...(nextInput ? { input: nextInput } : {}), + }; +} + +export function resolveModelRefFromString(params: { + cfg?: OpenClawConfig; + raw: string; + defaultProvider: string; + aliasIndex?: ModelAliasIndex; + allowPluginNormalization?: boolean; +}): { ref: ModelRef; alias?: string } | null { + const { model } = splitTrailingAuthProfile(params.raw); + if (!model) { + return null; + } + if (!model.includes("/")) { + const aliasKey = normalizeLowercaseStringOrEmpty(model); + const aliasMatch = params.aliasIndex?.byAlias.get(aliasKey); + if (aliasMatch) { + return { ref: aliasMatch.ref, alias: aliasMatch.alias }; + } + } + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: model, + defaultProvider: params.defaultProvider, + allowPluginNormalization: params.allowPluginNormalization, + }); + if (!parsed) { + return null; + } + return { ref: parsed }; +} + +export function resolveConfiguredModelRef(params: { + cfg: OpenClawConfig; + defaultProvider: string; + defaultModel: string; + allowPluginNormalization?: boolean; +}): ModelRef { + const rawModel = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model) ?? ""; + if (rawModel) { + const trimmed = rawModel.trim(); + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + allowPluginNormalization: params.allowPluginNormalization, + }); + if (!trimmed.includes("/")) { + const openrouterCompatRef = resolveConfiguredOpenRouterCompatAlias({ + cfg: params.cfg, + raw: trimmed, + defaultProvider: params.defaultProvider, + allowPluginNormalization: params.allowPluginNormalization, + }); + if (openrouterCompatRef) { + return openrouterCompatRef; + } + + const aliasKey = normalizeLowercaseStringOrEmpty(trimmed); + const aliasMatch = aliasIndex.byAlias.get(aliasKey); + if (aliasMatch) { + return aliasMatch.ref; + } + + const inferredProvider = inferUniqueProviderFromConfiguredModels({ + cfg: params.cfg, + model: trimmed, + }); + if (inferredProvider) { + return { provider: inferredProvider, model: trimmed }; + } + + const safeTrimmed = sanitizeModelWarningValue(trimmed); + const safeResolved = sanitizeForLog(`${params.defaultProvider}/${safeTrimmed}`); + getLog().warn( + `Model "${safeTrimmed}" specified without provider. Falling back to "${safeResolved}". Please use "${safeResolved}" in your config.`, + ); + return { provider: params.defaultProvider, model: trimmed }; + } + + const resolved = resolveModelRefFromString({ + cfg: params.cfg, + raw: trimmed, + defaultProvider: params.defaultProvider, + aliasIndex, + allowPluginNormalization: params.allowPluginNormalization, + }); + if (resolved) { + return resolved.ref; + } + + const safe = sanitizeForLog(trimmed); + const safeFallback = sanitizeForLog(`${params.defaultProvider}/${params.defaultModel}`); + getLog().warn( + `Model "${safe}" could not be resolved. Falling back to default "${safeFallback}".`, + ); + } + const fallbackProvider = resolveConfiguredProviderFallback({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + }); + if (fallbackProvider) { + return fallbackProvider; + } + return { provider: params.defaultProvider, model: params.defaultModel }; +} + +export function buildAllowedModelSetWithFallbacks(params: { + cfg: OpenClawConfig; + catalog: ModelCatalogEntry[]; + defaultProvider: string; + defaultModel?: string; + fallbackModels: readonly string[]; +}): { + allowAny: boolean; + allowedCatalog: ModelCatalogEntry[]; + allowedKeys: Set; +} { + const metadata = buildModelCatalogMetadata({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + }); + const catalog = params.catalog.map((entry) => applyModelCatalogMetadata({ entry, metadata })); + const rawAllowlist = (() => { + const modelMap = params.cfg.agents?.defaults?.models ?? {}; + return Object.keys(modelMap); + })(); + const allowAny = rawAllowlist.length === 0; + const defaultModel = params.defaultModel?.trim(); + const defaultRef = + defaultModel && params.defaultProvider + ? parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: defaultModel, + defaultProvider: params.defaultProvider, + }) + : null; + const defaultKey = defaultRef ? modelKey(defaultRef.provider, defaultRef.model) : undefined; + const catalogKeys = new Set(catalog.map((entry) => modelKey(entry.provider, entry.id))); + + if (allowAny) { + if (defaultKey) { + catalogKeys.add(defaultKey); + } + return { + allowAny: true, + allowedCatalog: catalog, + allowedKeys: catalogKeys, + }; + } + + const allowedKeys = new Set(); + const syntheticCatalogEntries = new Map(); + for (const raw of rawAllowlist) { + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw, + defaultProvider: params.defaultProvider, + }); + if (!parsed) { + continue; + } + const key = modelKey(parsed.provider, parsed.model); + allowedKeys.add(key); + + if (!catalogKeys.has(key) && !syntheticCatalogEntries.has(key)) { + syntheticCatalogEntries.set(key, buildSyntheticAllowedCatalogEntry({ parsed, metadata })); + } + } + + for (const fallback of params.fallbackModels) { + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: fallback, + defaultProvider: params.defaultProvider, + }); + if (!parsed) { + continue; + } + const key = modelKey(parsed.provider, parsed.model); + allowedKeys.add(key); + + if (!catalogKeys.has(key) && !syntheticCatalogEntries.has(key)) { + syntheticCatalogEntries.set(key, buildSyntheticAllowedCatalogEntry({ parsed, metadata })); + } + } + + if (defaultKey) { + allowedKeys.add(defaultKey); + } + + const allowedCatalog = [ + ...catalog.filter((entry) => allowedKeys.has(modelKey(entry.provider, entry.id))), + ...syntheticCatalogEntries.values(), + ]; + + if (allowedCatalog.length === 0 && allowedKeys.size === 0) { + if (defaultKey) { + catalogKeys.add(defaultKey); + } + return { + allowAny: true, + allowedCatalog: catalog, + allowedKeys: catalogKeys, + }; + } + + return { allowAny: false, allowedCatalog, allowedKeys }; +} + +export type ModelRefStatus = { + key: string; + inCatalog: boolean; + allowAny: boolean; + allowed: boolean; +}; + +export type ResolveAllowedModelRefResult = + | { ref: ModelRef; key: string } + | { + error: string; + }; + +export function getModelRefStatusFromAllowedSet(params: { + catalog: ModelCatalogEntry[]; + ref: ModelRef; + allowed: { + allowAny: boolean; + allowedKeys: Set; + }; +}): ModelRefStatus { + const key = modelKey(params.ref.provider, params.ref.model); + return { + key, + inCatalog: params.catalog.some((entry) => modelKey(entry.provider, entry.id) === key), + allowAny: params.allowed.allowAny, + allowed: params.allowed.allowAny || params.allowed.allowedKeys.has(key), + }; +} + +export function getModelRefStatusWithFallbackModels(params: { + cfg: OpenClawConfig; + catalog: ModelCatalogEntry[]; + ref: ModelRef; + defaultProvider: string; + defaultModel?: string; + fallbackModels: readonly string[]; +}): ModelRefStatus { + const allowed = buildAllowedModelSetWithFallbacks({ + cfg: params.cfg, + catalog: params.catalog, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + fallbackModels: params.fallbackModels, + }); + return getModelRefStatusFromAllowedSet({ + catalog: params.catalog, + ref: params.ref, + allowed, + }); +} + +export function resolveAllowedModelRefFromAliasIndex(params: { + cfg: OpenClawConfig; + raw: string; + defaultProvider: string; + aliasIndex: ModelAliasIndex; + getStatus: (ref: ModelRef) => ModelRefStatus; +}): ResolveAllowedModelRefResult { + const trimmed = params.raw.trim(); + if (!trimmed) { + return { error: "invalid model: empty" }; + } + + const effectiveDefaultProvider = !trimmed.includes("/") + ? (inferUniqueProviderFromConfiguredModels({ cfg: params.cfg, model: trimmed }) ?? + params.defaultProvider) + : params.defaultProvider; + + const resolved = resolveModelRefFromString({ + cfg: params.cfg, + raw: trimmed, + defaultProvider: effectiveDefaultProvider, + aliasIndex: params.aliasIndex, + }); + if (!resolved) { + return { error: `invalid model: ${trimmed}` }; + } + + const status = params.getStatus(resolved.ref); + if (!status.allowed) { + return { error: `model not allowed: ${status.key}` }; + } + + return { ref: resolved.ref, key: status.key }; +} + +export function buildConfiguredModelCatalog(params: { cfg: OpenClawConfig }): ModelCatalogEntry[] { + const providers = params.cfg.models?.providers; + if (!providers || typeof providers !== "object") { + return []; + } + + const catalog: ModelCatalogEntry[] = []; + for (const [providerRaw, provider] of Object.entries(providers)) { + const providerId = normalizeProviderId(providerRaw); + if (!providerId || !Array.isArray(provider?.models)) { + continue; + } + for (const model of provider.models) { + const id = normalizeOptionalString(model?.id) ?? ""; + if (!id) { + continue; + } + const name = normalizeOptionalString(model?.name) || id; + const contextWindow = + typeof model?.contextWindow === "number" && model.contextWindow > 0 + ? model.contextWindow + : undefined; + const reasoning = typeof model?.reasoning === "boolean" ? model.reasoning : undefined; + const input = Array.isArray(model?.input) ? model.input : undefined; + catalog.push({ + provider: providerId, + id, + name, + contextWindow, + reasoning, + input, + }); + } + } + + return catalog; +} + +export function resolveHooksGmailModel(params: { + cfg: OpenClawConfig; + defaultProvider: string; +}): ModelRef | null { + const hooksModel = params.cfg.hooks?.gmail?.model; + if (!hooksModel?.trim()) { + return null; + } + + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + }); + + const resolved = resolveModelRefFromString({ + cfg: params.cfg, + raw: hooksModel, + defaultProvider: params.defaultProvider, + aliasIndex, + }); + + return resolved?.ref ?? null; +} + +export function normalizeModelSelection(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || undefined; + } + if (!value || typeof value !== "object") { + return undefined; + } + const primary = (value as { primary?: unknown }).primary; + if (typeof primary === "string" && primary.trim()) { + return primary.trim(); + } + return undefined; +} diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 2c94d2a5499..f36ad7af3db 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -4,21 +4,13 @@ import { toAgentModelListLike, } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "../shared/string-coerce.js"; -import { sanitizeForLog, stripAnsi } from "../terminal/ansi.js"; import { resolveAgentConfig, resolveAgentEffectiveModelPrimary, resolveAgentModelFallbacksOverride, } from "./agent-scope.js"; -import { resolveConfiguredProviderFallback } from "./configured-provider-fallback.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; -import { splitTrailingAuthProfile } from "./model-ref-profile.js"; export { resolveThinkingDefault } from "./model-thinking-default.js"; import { type ModelRef, @@ -31,50 +23,46 @@ import { normalizeProviderIdForAuth, parseModelRef, } from "./model-selection-normalize.js"; +import { + buildAllowedModelSetWithFallbacks, + buildConfiguredAllowlistKeys, + buildConfiguredModelCatalog, + buildModelAliasIndex, + getModelRefStatusWithFallbackModels, + inferUniqueProviderFromConfiguredModels, + normalizeModelSelection, + resolveAllowedModelRefFromAliasIndex, + resolveAllowlistModelKey as resolveAllowlistModelKeyFromShared, + resolveConfiguredModelRef, + resolveConfiguredOpenRouterCompatAlias, + resolveHooksGmailModel, + resolveModelRefFromString, + type ModelAliasIndex, + type ModelRefStatus, +} from "./model-selection-shared.js"; -let log: ReturnType | null = null; - -function getLog(): ReturnType { - log ??= createSubsystemLogger("model-selection"); - return log; -} - -const OPENROUTER_COMPAT_FREE_ALIAS = "openrouter:free"; +export type { ModelAliasIndex, ModelRef, ModelRefStatus }; export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; -export type ModelAliasIndex = { - byAlias: Map; - byKey: Map; -}; - -function sanitizeModelWarningValue(value: string): string { - const stripped = value ? stripAnsi(value) : ""; - let controlBoundary = -1; - for (let index = 0; index < stripped.length; index += 1) { - const code = stripped.charCodeAt(index); - if (code <= 0x1f || code === 0x7f) { - controlBoundary = index; - break; - } - } - if (controlBoundary === -1) { - return sanitizeForLog(stripped); - } - return sanitizeForLog(stripped.slice(0, controlBoundary)); -} - export { + buildConfiguredAllowlistKeys, + buildConfiguredModelCatalog, + buildModelAliasIndex, findNormalizedProviderKey, findNormalizedProviderValue, + inferUniqueProviderFromConfiguredModels, legacyModelKey, modelKey, normalizeModelRef, + normalizeModelSelection, normalizeProviderId, normalizeProviderIdForAuth, parseModelRef, + resolveConfiguredModelRef, + resolveHooksGmailModel, + resolveModelRefFromString, }; -export type { ModelRef }; export { isCliProvider } from "./model-selection-cli.js"; export function resolvePersistedOverrideModelRef(params: { @@ -178,409 +166,12 @@ export function normalizeStoredOverrideModel(params: { }; } -export function inferUniqueProviderFromConfiguredModels(params: { - cfg: OpenClawConfig; - model: string; -}): string | undefined { - const model = params.model.trim(); - if (!model) { - return undefined; - } - const normalized = normalizeLowercaseStringOrEmpty(model); - const providers = new Set(); - const addProvider = (provider: string) => { - const normalizedProvider = normalizeProviderId(provider); - if (!normalizedProvider) { - return; - } - providers.add(normalizedProvider); - }; - const configuredModels = params.cfg.agents?.defaults?.models; - if (configuredModels) { - for (const key of Object.keys(configuredModels)) { - const ref = key.trim(); - if (!ref || !ref.includes("/")) { - continue; - } - const parsed = parseModelRef(ref, DEFAULT_PROVIDER, { - allowPluginNormalization: false, - }); - if (!parsed) { - continue; - } - if (parsed.model === model || normalizeLowercaseStringOrEmpty(parsed.model) === normalized) { - addProvider(parsed.provider); - if (providers.size > 1) { - return undefined; - } - } - } - } - const configuredProviders = params.cfg.models?.providers; - if (configuredProviders) { - for (const [providerId, providerConfig] of Object.entries(configuredProviders)) { - const models = providerConfig?.models; - if (!Array.isArray(models)) { - continue; - } - for (const entry of models) { - const modelId = entry?.id?.trim(); - if (!modelId) { - continue; - } - if (modelId === model || normalizeLowercaseStringOrEmpty(modelId) === normalized) { - addProvider(providerId); - } - } - if (providers.size > 1) { - return undefined; - } - } - } - if (providers.size !== 1) { - return undefined; - } - return providers.values().next().value; -} - export function resolveAllowlistModelKey( raw: string, defaultProvider: string, cfg?: OpenClawConfig, ): string | null { - const parsed = parseModelRefWithCompatAlias({ - cfg, - raw, - defaultProvider, - }); - if (!parsed) { - return null; - } - return modelKey(parsed.provider, parsed.model); -} - -function isConcreteOpenRouterFreeModelRef(ref: ModelRef): boolean { - return ref.provider === "openrouter" && ref.model.includes("/") && ref.model.endsWith(":free"); -} - -function resolveConfiguredOpenRouterCompatFreeRef(params: { - cfg: OpenClawConfig; - defaultProvider: string; - allowPluginNormalization?: boolean; -}): ModelRef | null { - const configuredModels = params.cfg.agents?.defaults?.models ?? {}; - for (const raw of Object.keys(configuredModels)) { - if (!raw.includes("/")) { - continue; - } - const parsed = parseModelRef(raw, params.defaultProvider, { - allowPluginNormalization: params.allowPluginNormalization, - }); - if (parsed && isConcreteOpenRouterFreeModelRef(parsed)) { - return parsed; - } - } - - const openrouterProviderConfig = findNormalizedProviderValue( - params.cfg.models?.providers, - "openrouter", - ); - for (const entry of openrouterProviderConfig?.models ?? []) { - const modelId = entry?.id?.trim(); - if (!modelId || !modelId.includes("/") || !modelId.endsWith(":free")) { - continue; - } - return normalizeModelRef("openrouter", modelId, { - allowPluginNormalization: params.allowPluginNormalization, - }); - } - - return null; -} - -function resolveConfiguredOpenRouterCompatAlias(params: { - cfg: OpenClawConfig; - raw: string; - defaultProvider: string; - allowPluginNormalization?: boolean; -}): ModelRef | null { - const normalized = normalizeLowercaseStringOrEmpty(params.raw); - if (normalized === "openrouter:auto") { - return normalizeModelRef("openrouter", "auto", { - allowPluginNormalization: params.allowPluginNormalization, - }); - } - if (normalized !== OPENROUTER_COMPAT_FREE_ALIAS) { - return null; - } - return resolveConfiguredOpenRouterCompatFreeRef({ - cfg: params.cfg, - defaultProvider: params.defaultProvider, - allowPluginNormalization: params.allowPluginNormalization, - }); -} - -function parseModelRefWithCompatAlias(params: { - cfg?: OpenClawConfig; - raw: string; - defaultProvider: string; - allowPluginNormalization?: boolean; -}): ModelRef | null { - return ( - (params.cfg - ? resolveConfiguredOpenRouterCompatAlias({ - cfg: params.cfg, - raw: params.raw, - defaultProvider: params.defaultProvider, - allowPluginNormalization: params.allowPluginNormalization, - }) - : null) ?? - parseModelRef(params.raw, params.defaultProvider, { - allowPluginNormalization: params.allowPluginNormalization, - }) - ); -} - -export function buildConfiguredAllowlistKeys(params: { - cfg: OpenClawConfig | undefined; - defaultProvider: string; -}): Set | null { - const rawAllowlist = Object.keys(params.cfg?.agents?.defaults?.models ?? {}); - if (rawAllowlist.length === 0) { - return null; - } - - const keys = new Set(); - for (const raw of rawAllowlist) { - const key = resolveAllowlistModelKey(raw, params.defaultProvider, params.cfg); - if (key) { - keys.add(key); - } - } - return keys.size > 0 ? keys : null; -} - -export function buildModelAliasIndex(params: { - cfg: OpenClawConfig; - defaultProvider: string; - allowPluginNormalization?: boolean; -}): ModelAliasIndex { - const byAlias = new Map(); - const byKey = new Map(); - - const rawModels = params.cfg.agents?.defaults?.models ?? {}; - for (const [keyRaw, entryRaw] of Object.entries(rawModels)) { - const parsed = parseModelRefWithCompatAlias({ - cfg: params.cfg, - raw: keyRaw, - defaultProvider: params.defaultProvider, - allowPluginNormalization: params.allowPluginNormalization, - }); - if (!parsed) { - continue; - } - const alias = - normalizeOptionalString((entryRaw as { alias?: string } | undefined)?.alias) ?? ""; - if (!alias) { - continue; - } - const aliasKey = normalizeLowercaseStringOrEmpty(alias); - byAlias.set(aliasKey, { alias, ref: parsed }); - const key = modelKey(parsed.provider, parsed.model); - const existing = byKey.get(key) ?? []; - existing.push(alias); - byKey.set(key, existing); - } - - return { byAlias, byKey }; -} - -type ModelCatalogMetadata = { - configuredByKey: Map; - aliasByKey: Map; -}; - -function buildModelCatalogMetadata(params: { - cfg: OpenClawConfig; - defaultProvider: string; -}): ModelCatalogMetadata { - const configuredByKey = new Map(); - for (const entry of buildConfiguredModelCatalog({ cfg: params.cfg })) { - configuredByKey.set(modelKey(entry.provider, entry.id), entry); - } - - const aliasByKey = new Map(); - const configuredModels = params.cfg.agents?.defaults?.models ?? {}; - for (const [rawKey, entryRaw] of Object.entries(configuredModels)) { - const key = resolveAllowlistModelKey(rawKey, params.defaultProvider, params.cfg); - if (!key) { - continue; - } - const alias = ((entryRaw as { alias?: string } | undefined)?.alias ?? "").trim(); - if (!alias) { - continue; - } - aliasByKey.set(key, alias); - } - - return { configuredByKey, aliasByKey }; -} - -function applyModelCatalogMetadata(params: { - entry: ModelCatalogEntry; - metadata: ModelCatalogMetadata; -}): ModelCatalogEntry { - const key = modelKey(params.entry.provider, params.entry.id); - const configuredEntry = params.metadata.configuredByKey.get(key); - const alias = params.metadata.aliasByKey.get(key); - if (!configuredEntry && !alias) { - return params.entry; - } - const nextAlias = alias ?? params.entry.alias; - const nextContextWindow = configuredEntry?.contextWindow ?? params.entry.contextWindow; - const nextReasoning = configuredEntry?.reasoning ?? params.entry.reasoning; - const nextInput = configuredEntry?.input ?? params.entry.input; - - return { - ...params.entry, - name: configuredEntry?.name ?? params.entry.name, - ...(nextAlias ? { alias: nextAlias } : {}), - ...(nextContextWindow !== undefined ? { contextWindow: nextContextWindow } : {}), - ...(nextReasoning !== undefined ? { reasoning: nextReasoning } : {}), - ...(nextInput ? { input: nextInput } : {}), - }; -} - -function buildSyntheticAllowedCatalogEntry(params: { - parsed: ModelRef; - metadata: ModelCatalogMetadata; -}): ModelCatalogEntry { - const key = modelKey(params.parsed.provider, params.parsed.model); - const configuredEntry = params.metadata.configuredByKey.get(key); - const alias = params.metadata.aliasByKey.get(key); - const nextContextWindow = configuredEntry?.contextWindow; - const nextReasoning = configuredEntry?.reasoning; - const nextInput = configuredEntry?.input; - - return { - id: params.parsed.model, - name: configuredEntry?.name ?? params.parsed.model, - provider: params.parsed.provider, - ...(alias ? { alias } : {}), - ...(nextContextWindow !== undefined ? { contextWindow: nextContextWindow } : {}), - ...(nextReasoning !== undefined ? { reasoning: nextReasoning } : {}), - ...(nextInput ? { input: nextInput } : {}), - }; -} - -export function resolveModelRefFromString(params: { - cfg?: OpenClawConfig; - raw: string; - defaultProvider: string; - aliasIndex?: ModelAliasIndex; - allowPluginNormalization?: boolean; -}): { ref: ModelRef; alias?: string } | null { - const { model } = splitTrailingAuthProfile(params.raw); - if (!model) { - return null; - } - if (!model.includes("/")) { - const aliasKey = normalizeLowercaseStringOrEmpty(model); - const aliasMatch = params.aliasIndex?.byAlias.get(aliasKey); - if (aliasMatch) { - return { ref: aliasMatch.ref, alias: aliasMatch.alias }; - } - } - const parsed = parseModelRefWithCompatAlias({ - cfg: params.cfg, - raw: model, - defaultProvider: params.defaultProvider, - allowPluginNormalization: params.allowPluginNormalization, - }); - if (!parsed) { - return null; - } - return { ref: parsed }; -} - -export function resolveConfiguredModelRef(params: { - cfg: OpenClawConfig; - defaultProvider: string; - defaultModel: string; - allowPluginNormalization?: boolean; -}): ModelRef { - const rawModel = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model) ?? ""; - if (rawModel) { - const trimmed = rawModel.trim(); - const aliasIndex = buildModelAliasIndex({ - cfg: params.cfg, - defaultProvider: params.defaultProvider, - allowPluginNormalization: params.allowPluginNormalization, - }); - if (!trimmed.includes("/")) { - const openrouterCompatRef = resolveConfiguredOpenRouterCompatAlias({ - cfg: params.cfg, - raw: trimmed, - defaultProvider: params.defaultProvider, - allowPluginNormalization: params.allowPluginNormalization, - }); - if (openrouterCompatRef) { - return openrouterCompatRef; - } - - const aliasKey = normalizeLowercaseStringOrEmpty(trimmed); - const aliasMatch = aliasIndex.byAlias.get(aliasKey); - if (aliasMatch) { - return aliasMatch.ref; - } - - const inferredProvider = inferUniqueProviderFromConfiguredModels({ - cfg: params.cfg, - model: trimmed, - }); - if (inferredProvider) { - return { provider: inferredProvider, model: trimmed }; - } - - // Default to the configured provider if no provider is specified, but warn as this is deprecated. - const safeTrimmed = sanitizeModelWarningValue(trimmed); - const safeResolved = sanitizeForLog(`${params.defaultProvider}/${safeTrimmed}`); - getLog().warn( - `Model "${safeTrimmed}" specified without provider. Falling back to "${safeResolved}". Please use "${safeResolved}" in your config.`, - ); - return { provider: params.defaultProvider, model: trimmed }; - } - - const resolved = resolveModelRefFromString({ - cfg: params.cfg, - raw: trimmed, - defaultProvider: params.defaultProvider, - aliasIndex, - allowPluginNormalization: params.allowPluginNormalization, - }); - if (resolved) { - return resolved.ref; - } - - // User specified a model but it could not be resolved — warn before falling back. - const safe = sanitizeForLog(trimmed); - const safeFallback = sanitizeForLog(`${params.defaultProvider}/${params.defaultModel}`); - getLog().warn( - `Model "${safe}" could not be resolved. Falling back to default "${safeFallback}".`, - ); - } - // Before falling back to the hardcoded default, check if the default provider - // is actually available. If it isn't but other providers are configured, prefer - // the first configured provider's first model to avoid reporting a stale default - // from a removed provider. (See #38880) - const fallbackProvider = resolveConfiguredProviderFallback({ - cfg: params.cfg, - defaultProvider: params.defaultProvider, - }); - if (fallbackProvider) { - return fallbackProvider; - } - return { provider: params.defaultProvider, model: params.defaultModel }; + return resolveAllowlistModelKeyFromShared({ cfg, raw, defaultProvider }); } export function resolveDefaultModelForAgent(params: { @@ -666,147 +257,18 @@ export function buildAllowedModelSet(params: { allowedCatalog: ModelCatalogEntry[]; allowedKeys: Set; } { - const metadata = buildModelCatalogMetadata({ + return buildAllowedModelSetWithFallbacks({ cfg: params.cfg, + catalog: params.catalog, defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + fallbackModels: resolveAllowedFallbacks({ + cfg: params.cfg, + agentId: params.agentId, + }), }); - const catalog = params.catalog.map((entry) => applyModelCatalogMetadata({ entry, metadata })); - const rawAllowlist = (() => { - const modelMap = params.cfg.agents?.defaults?.models ?? {}; - return Object.keys(modelMap); - })(); - const allowAny = rawAllowlist.length === 0; - const defaultModel = params.defaultModel?.trim(); - const defaultRef = - defaultModel && params.defaultProvider - ? parseModelRefWithCompatAlias({ - cfg: params.cfg, - raw: defaultModel, - defaultProvider: params.defaultProvider, - }) - : null; - const defaultKey = defaultRef ? modelKey(defaultRef.provider, defaultRef.model) : undefined; - const catalogKeys = new Set(catalog.map((entry) => modelKey(entry.provider, entry.id))); - - if (allowAny) { - if (defaultKey) { - catalogKeys.add(defaultKey); - } - return { - allowAny: true, - allowedCatalog: catalog, - allowedKeys: catalogKeys, - }; - } - - const allowedKeys = new Set(); - const syntheticCatalogEntries = new Map(); - for (const raw of rawAllowlist) { - const parsed = parseModelRefWithCompatAlias({ - cfg: params.cfg, - raw, - defaultProvider: params.defaultProvider, - }); - if (!parsed) { - continue; - } - const key = modelKey(parsed.provider, parsed.model); - // Explicit allowlist entries are always trusted, even when bundled catalog - // data is stale and does not include the configured model yet. - allowedKeys.add(key); - - if (!catalogKeys.has(key) && !syntheticCatalogEntries.has(key)) { - syntheticCatalogEntries.set(key, buildSyntheticAllowedCatalogEntry({ parsed, metadata })); - } - } - - for (const fallback of resolveAllowedFallbacks({ - cfg: params.cfg, - agentId: params.agentId, - })) { - const parsed = parseModelRefWithCompatAlias({ - cfg: params.cfg, - raw: fallback, - defaultProvider: params.defaultProvider, - }); - if (parsed) { - const key = modelKey(parsed.provider, parsed.model); - allowedKeys.add(key); - - if (!catalogKeys.has(key) && !syntheticCatalogEntries.has(key)) { - syntheticCatalogEntries.set(key, buildSyntheticAllowedCatalogEntry({ parsed, metadata })); - } - } - } - - if (defaultKey) { - allowedKeys.add(defaultKey); - } - - const allowedCatalog = [ - ...catalog.filter((entry) => allowedKeys.has(modelKey(entry.provider, entry.id))), - ...syntheticCatalogEntries.values(), - ]; - - if (allowedCatalog.length === 0 && allowedKeys.size === 0) { - if (defaultKey) { - catalogKeys.add(defaultKey); - } - return { - allowAny: true, - allowedCatalog: catalog, - allowedKeys: catalogKeys, - }; - } - - return { allowAny: false, allowedCatalog, allowedKeys }; } -export function buildConfiguredModelCatalog(params: { cfg: OpenClawConfig }): ModelCatalogEntry[] { - const providers = params.cfg.models?.providers; - if (!providers || typeof providers !== "object") { - return []; - } - - const catalog: ModelCatalogEntry[] = []; - for (const [providerRaw, provider] of Object.entries(providers)) { - const providerId = normalizeProviderId(providerRaw); - if (!providerId || !Array.isArray(provider?.models)) { - continue; - } - for (const model of provider.models) { - const id = normalizeOptionalString(model?.id) ?? ""; - if (!id) { - continue; - } - const name = normalizeOptionalString(model?.name) || id; - const contextWindow = - typeof model?.contextWindow === "number" && model.contextWindow > 0 - ? model.contextWindow - : undefined; - const reasoning = typeof model?.reasoning === "boolean" ? model.reasoning : undefined; - const input = Array.isArray(model?.input) ? model.input : undefined; - catalog.push({ - provider: providerId, - id, - name, - contextWindow, - reasoning, - input, - }); - } - } - - return catalog; -} - -export type ModelRefStatus = { - key: string; - inCatalog: boolean; - allowAny: boolean; - allowed: boolean; -}; - export function getModelRefStatus(params: { cfg: OpenClawConfig; catalog: ModelCatalogEntry[]; @@ -814,19 +276,34 @@ export function getModelRefStatus(params: { defaultProvider: string; defaultModel?: string; }): ModelRefStatus { - const allowed = buildAllowedModelSet({ + return getModelRefStatusWithFallbackModels({ cfg: params.cfg, catalog: params.catalog, + ref: params.ref, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + fallbackModels: resolveAllowedFallbacks({ + cfg: params.cfg, + }), + }); +} + +function getModelRefStatusForResolve( + params: { + cfg: OpenClawConfig; + catalog: ModelCatalogEntry[]; + defaultProvider: string; + defaultModel?: string; + }, + ref: ModelRef, +): ModelRefStatus { + return getModelRefStatus({ + cfg: params.cfg, + catalog: params.catalog, + ref, defaultProvider: params.defaultProvider, defaultModel: params.defaultModel, }); - const key = modelKey(params.ref.provider, params.ref.model); - return { - key, - inCatalog: params.catalog.some((entry) => modelKey(entry.provider, entry.id) === key), - allowAny: allowed.allowAny, - allowed: allowed.allowAny || allowed.allowedKeys.has(key), - }; } export function resolveAllowedModelRef(params: { @@ -856,50 +333,20 @@ export function resolveAllowedModelRef(params: { defaultProvider: params.defaultProvider, }); if (openrouterCompatRef) { - const status = getModelRefStatus({ - cfg: params.cfg, - catalog: params.catalog, - ref: openrouterCompatRef, - defaultProvider: params.defaultProvider, - defaultModel: params.defaultModel, - }); + const status = getModelRefStatusForResolve(params, openrouterCompatRef); if (!status.allowed) { return { error: `model not allowed: ${status.key}` }; } return { ref: openrouterCompatRef, key: status.key }; } - // When the model string has no provider prefix ("/"), try to infer the - // correct provider from the configured allowlist before falling back to the - // session's current default provider. This prevents provider prefix drift - // when switching models across different providers (see #48369). - const effectiveDefaultProvider = !trimmed.includes("/") - ? (inferUniqueProviderFromConfiguredModels({ cfg: params.cfg, model: trimmed }) ?? - params.defaultProvider) - : params.defaultProvider; - - const resolved = resolveModelRefFromString({ + return resolveAllowedModelRefFromAliasIndex({ cfg: params.cfg, - raw: trimmed, - defaultProvider: effectiveDefaultProvider, - aliasIndex, - }); - if (!resolved) { - return { error: `invalid model: ${trimmed}` }; - } - - const status = getModelRefStatus({ - cfg: params.cfg, - catalog: params.catalog, - ref: resolved.ref, + raw: params.raw, defaultProvider: params.defaultProvider, - defaultModel: params.defaultModel, + aliasIndex, + getStatus: (ref) => getModelRefStatusForResolve(params, ref), }); - if (!status.allowed) { - return { error: `model not allowed: ${status.key}` }; - } - - return { ref: resolved.ref, key: status.key }; } /** Default reasoning level when session/directive do not set it: "on" if model supports reasoning, else "off". */ @@ -916,51 +363,3 @@ export function resolveReasoningDefault(params: { ); return candidate?.reasoning === true ? "on" : "off"; } - -/** - * Resolve the model configured for Gmail hook processing. - * Returns null if hooks.gmail.model is not set. - */ -export function resolveHooksGmailModel(params: { - cfg: OpenClawConfig; - defaultProvider: string; -}): ModelRef | null { - const hooksModel = params.cfg.hooks?.gmail?.model; - if (!hooksModel?.trim()) { - return null; - } - - const aliasIndex = buildModelAliasIndex({ - cfg: params.cfg, - defaultProvider: params.defaultProvider, - }); - - const resolved = resolveModelRefFromString({ - cfg: params.cfg, - raw: hooksModel, - defaultProvider: params.defaultProvider, - aliasIndex, - }); - - return resolved?.ref ?? null; -} - -/** - * Normalize a model selection value (string or `{primary?: string}`) to a - * plain trimmed string. Returns `undefined` when the input is empty/missing. - * Shared by sessions-spawn and cron isolated-agent model resolution. - */ -export function normalizeModelSelection(value: unknown): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed || undefined; - } - if (!value || typeof value !== "object") { - return undefined; - } - const primary = (value as { primary?: unknown }).primary; - if (typeof primary === "string" && primary.trim()) { - return primary.trim(); - } - return undefined; -}