import { resolveAgentConfig } from "../../agents/agent-scope.js"; import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import { resolveContextTokensForModel } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/selection.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import { parseConfiguredModelVisibilityEntries } from "../../agents/model-selection-shared.js"; import { buildConfiguredModelCatalog, legacyModelKey, modelKey, normalizeModelRef, normalizeProviderId, resolvePersistedOverrideModelRef, resolveReasoningDefault, resolveThinkingDefault, } from "../../agents/model-selection.js"; import { RUNTIME_MODEL_VISIBILITY_NORMALIZATION, createModelVisibilityPolicy, type ModelVisibilityPolicy, } from "../../agents/model-visibility-policy.js"; import { OPENAI_CODEX_PROVIDER_ID, OPENAI_PROVIDER_ID, listOpenAIAuthProfileProvidersForAgentRuntime, } from "../../agents/openai-routing.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { ThinkLevel } from "./directives.js"; export { resolveModelDirectiveSelection, type ModelDirectiveSelection, } from "./model-selection-directive.js"; import { isStaleHeartbeatAutoFallbackOverride, resolveStoredModelOverride, } from "./stored-model-override.js"; type ModelCatalog = ModelCatalogEntry[]; type ModelSelectionState = { provider: string; model: string; allowedModelKeys: Set; allowedModelCatalog: ModelCatalog; resetModelOverride: boolean; resetModelOverrideRef?: string; resolveThinkingCatalog: () => Promise; resolveDefaultThinkingLevel: () => Promise; /** Default reasoning level from model capability: "on" if model has reasoning, else "off". */ resolveDefaultReasoningLevel: () => Promise<"on" | "off">; needsModelCatalog: boolean; }; export function createFastTestModelSelectionState(params: { agentCfg: NonNullable["defaults"]> | undefined; provider: string; model: string; }): ModelSelectionState { return { provider: params.provider, model: params.model, allowedModelKeys: new Set(), allowedModelCatalog: [], resetModelOverride: false, resetModelOverrideRef: undefined, resolveThinkingCatalog: async () => [], resolveDefaultThinkingLevel: async () => params.agentCfg?.thinkingDefault as ThinkLevel, resolveDefaultReasoningLevel: async () => "off", needsModelCatalog: false, }; } function shouldLogModelSelectionTiming(): boolean { return process.env.OPENCLAW_DEBUG_INGRESS_TIMING === "1"; } const modelCatalogRuntimeLoader = createLazyImportLoader( () => import("../../agents/model-catalog.runtime.js"), ); const sessionStoreRuntimeLoader = createLazyImportLoader( () => import("../../config/sessions/store.runtime.js"), ); function normalizeRuntimeModelRef(provider: string, model: string) { return normalizeModelRef(provider, model, RUNTIME_MODEL_VISIBILITY_NORMALIZATION); } function loadModelCatalogRuntime() { return modelCatalogRuntimeLoader.load(); } function loadSessionStoreRuntime() { return sessionStoreRuntimeLoader.load(); } function findSelectedCatalogEntry(params: { catalog?: readonly ModelCatalogEntry[]; provider: string; model: string; }): ModelCatalogEntry | undefined { const normalizedProvider = normalizeProviderId(params.provider); const selectedKey = modelKey(normalizedProvider, params.model); return params.catalog?.find((entry) => modelKey(entry.provider, entry.id) === selectedKey); } export async function createModelSelectionState(params: { cfg: OpenClawConfig; agentId?: string; agentCfg: NonNullable["defaults"]> | undefined; sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; parentSessionKey?: string; storePath?: string; defaultProvider: string; defaultModel: string; primaryProvider?: string; primaryModel?: string; provider: string; model: string; hasModelDirective: boolean; skipStoredModelOverride?: boolean; /** True when heartbeat.model was explicitly resolved for this run. * In that case, skip session-stored overrides so the heartbeat selection wins. */ hasResolvedHeartbeatModelOverride?: boolean; isHeartbeat?: boolean; }): Promise { const timingEnabled = shouldLogModelSelectionTiming(); const startMs = timingEnabled ? Date.now() : 0; const logStage = (stage: string, extra?: string) => { if (!timingEnabled) { return; } const suffix = extra ? ` ${extra}` : ""; console.log( `[model-selection] session=${params.sessionKey ?? "(no-session)"} stage=${stage} elapsedMs=${Date.now() - startMs}${suffix}`, ); }; const { cfg, agentCfg, sessionEntry, sessionStore, sessionKey, parentSessionKey, storePath, defaultProvider, defaultModel, } = params; let provider = params.provider; let model = params.model; const primaryProvider = params.primaryProvider ?? defaultProvider; const primaryModel = params.primaryModel ?? defaultModel; const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0; const visibility = parseConfiguredModelVisibilityEntries({ cfg }); const defaultProviderVisibleByWildcard = visibility.providerWildcards.has( normalizeProviderId(defaultProvider), ); const configuredModelCatalog = buildConfiguredModelCatalog({ cfg }); const needsModelCatalog = params.hasModelDirective || Boolean( hasAllowlist && visibility.providerWildcards.size > 0 && !defaultProviderVisibleByWildcard, ); let allowedModelKeys = new Set(); let allowedModelCatalog: ModelCatalog = configuredModelCatalog; let visibilityPolicy: ModelVisibilityPolicy = createModelVisibilityPolicy({ cfg, catalog: configuredModelCatalog, defaultProvider, defaultModel, agentId: params.agentId, ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, }); let modelCatalog: ModelCatalog | null = null; let resetModelOverride = false; let resetModelOverrideRef: string | undefined; const agentEntry = params.agentId ? resolveAgentConfig(cfg, params.agentId) : undefined; const directStoredOverride = resolvePersistedOverrideModelRef({ defaultProvider, overrideProvider: sessionEntry?.providerOverride, overrideModel: sessionEntry?.modelOverride, }); const directStoredModelOverride = directStoredOverride ? { ...directStoredOverride, source: "session" as const } : null; const staleHeartbeatAutoFallbackOverride = isStaleHeartbeatAutoFallbackOverride({ isHeartbeat: params.isHeartbeat, hasResolvedHeartbeatModelOverride: params.hasResolvedHeartbeatModelOverride, sessionEntry, storedOverride: directStoredModelOverride, defaultProvider, defaultModel, primaryProvider: params.primaryProvider, primaryModel: params.primaryModel, }); const primaryHarnessPolicy = resolveAgentHarnessPolicy({ provider: primaryProvider, modelId: primaryModel, config: cfg, agentId: params.agentId, sessionKey, }); const staleLegacyOpenAICodexAutoOverride = directStoredModelOverride?.source === "session" && sessionEntry?.modelOverrideSource === "auto" && normalizeProviderId(directStoredModelOverride.provider ?? "") === OPENAI_CODEX_PROVIDER_ID && normalizeProviderId(primaryProvider) === OPENAI_PROVIDER_ID && primaryHarnessPolicy.runtime === "codex" && normalizeRuntimeModelRef(OPENAI_PROVIDER_ID, directStoredModelOverride.model).model === normalizeRuntimeModelRef(OPENAI_PROVIDER_ID, primaryModel).model; const staleDirectStoredOverride = staleHeartbeatAutoFallbackOverride || staleLegacyOpenAICodexAutoOverride; if (needsModelCatalog) { modelCatalog = await (await loadModelCatalogRuntime()).loadModelCatalog({ config: cfg }); logStage("catalog-loaded", `entries=${modelCatalog.length}`); visibilityPolicy = createModelVisibilityPolicy({ cfg, catalog: modelCatalog, defaultProvider, defaultModel, agentId: params.agentId, ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, }); allowedModelCatalog = visibilityPolicy.allowedCatalog; allowedModelKeys = visibilityPolicy.allowedKeys; logStage( "allowlist-built", `allowed=${allowedModelCatalog.length} keys=${allowedModelKeys.size}`, ); } else if (hasAllowlist) { visibilityPolicy = createModelVisibilityPolicy({ cfg, catalog: configuredModelCatalog, defaultProvider, defaultModel, agentId: params.agentId, ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, }); allowedModelCatalog = visibilityPolicy.allowedCatalog; allowedModelKeys = visibilityPolicy.allowedKeys; logStage( "configured-allowlist-built", `allowed=${allowedModelCatalog.length} keys=${allowedModelKeys.size}`, ); } else if (configuredModelCatalog.length > 0) { logStage("configured-catalog-ready", `entries=${configuredModelCatalog.length}`); } if (sessionEntry && sessionStore && sessionKey && directStoredOverride) { const normalizedOverride = normalizeRuntimeModelRef( directStoredOverride.provider, directStoredOverride.model, ); const key = modelKey(normalizedOverride.provider, normalizedOverride.model); if (staleDirectStoredOverride || !visibilityPolicy.allowsKey(key)) { const { updated } = applyModelOverrideToSessionEntry({ entry: sessionEntry, selection: { provider: primaryProvider, model: primaryModel, isDefault: true }, preserveAuthProfileOverride: staleDirectStoredOverride, }); if (updated) { sessionStore[sessionKey] = sessionEntry; if (storePath) { await ( await loadSessionStoreRuntime() ).updateSessionStore(storePath, (store) => { store[sessionKey] = sessionEntry; }); } } resetModelOverride = updated; if (updated) { resetModelOverrideRef = key; } } } if (staleDirectStoredOverride) { const normalizedCurrentSelection = normalizeRuntimeModelRef(provider, model); const currentSelectionKey = modelKey( normalizedCurrentSelection.provider, normalizedCurrentSelection.model, ); const normalizedDirectOverride = directStoredOverride ? normalizeRuntimeModelRef(directStoredOverride.provider, directStoredOverride.model) : null; const directStoredOverrideKey = normalizedDirectOverride ? modelKey(normalizedDirectOverride.provider, normalizedDirectOverride.model) : undefined; if (currentSelectionKey === directStoredOverrideKey) { provider = primaryProvider; model = primaryModel; } } const storedOverride = resolveStoredModelOverride({ sessionEntry, sessionStore, sessionKey, parentSessionKey, defaultProvider, }); // Skip stored session model override only when an explicit heartbeat.model // was resolved. Heartbeats without heartbeat.model still inherit normal // overrides unless a direct auto fallback override is stale for the current // configured default. const skipStoredOverride = params.skipStoredModelOverride === true || params.hasResolvedHeartbeatModelOverride === true || (staleDirectStoredOverride && storedOverride?.source === "session"); if (storedOverride?.model && !skipStoredOverride) { const normalizedStoredOverride = normalizeRuntimeModelRef( storedOverride.provider || defaultProvider, storedOverride.model, ); const key = modelKey(normalizedStoredOverride.provider, normalizedStoredOverride.model); if (visibilityPolicy.allowsKey(key)) { provider = normalizedStoredOverride.provider; model = normalizedStoredOverride.model; } } if (!params.hasModelDirective) { const allowedInitialSelection = visibilityPolicy.resolveSelection({ provider, model, }); if (!allowedInitialSelection) { throw new Error( `Configured default model "${modelKey(provider, model)}" is not allowed by agents.defaults.models, and no allowed model is available.`, ); } provider = allowedInitialSelection.provider; model = allowedInitialSelection.model; } if ( !params.skipStoredModelOverride && sessionEntry && sessionStore && sessionKey && sessionEntry.authProfileOverride ) { const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.runtime.js"); const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false, }); logStage("auth-profile-store-loaded", `profiles=${Object.keys(store.profiles).length}`); const profile = store.profiles[sessionEntry.authProfileOverride]; const profileProvider = profile ? normalizeProviderId(profile.provider) : undefined; const harnessPolicy = resolveAgentHarnessPolicy({ provider, modelId: model, config: cfg, agentId: params.agentId, sessionKey, }); const acceptedAuthProviders = listOpenAIAuthProfileProvidersForAgentRuntime({ provider, harnessRuntime: harnessPolicy.runtime, config: cfg, }).map(normalizeProviderId); if (!profile || !acceptedAuthProviders.includes(profileProvider ?? "")) { await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath, }); } } let thinkingCatalog: ModelCatalog | undefined; let manifestModelCatalog: ModelCatalog | null = null; const buildThinkingCatalog = (catalog: ModelCatalog): ModelCatalog => createModelVisibilityPolicy({ cfg, catalog, defaultProvider, defaultModel, agentId: params.agentId, ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, }).allowedCatalog; const loadManifestCatalog = async () => { if (manifestModelCatalog) { return manifestModelCatalog; } const { loadManifestModelCatalog } = await loadModelCatalogRuntime(); manifestModelCatalog = loadManifestModelCatalog({ config: cfg, fallbackToMetadataScan: false, }); logStage("manifest-catalog-loaded", `entries=${manifestModelCatalog.length}`); return manifestModelCatalog; }; const resolveThinkingCatalog = async () => { if (thinkingCatalog) { return thinkingCatalog; } let catalogForThinking = allowedModelCatalog.length > 0 ? allowedModelCatalog : modelCatalog && modelCatalog.length > 0 ? buildThinkingCatalog(modelCatalog) : []; let selectedCatalogEntry = findSelectedCatalogEntry({ catalog: catalogForThinking, provider, model, }); // Prefer static manifest rows before cold runtime discovery. Synthetic // allowlist rows know only provider/id; manifest rows can prove reasoning // support without opening the Pi auth-backed model registry. if (!modelCatalog && selectedCatalogEntry?.reasoning === undefined) { const manifestCatalog = buildThinkingCatalog(await loadManifestCatalog()); const manifestSelectedEntry = findSelectedCatalogEntry({ catalog: manifestCatalog, provider, model, }); if (manifestSelectedEntry?.reasoning !== undefined) { catalogForThinking = manifestCatalog; selectedCatalogEntry = manifestSelectedEntry; } } const shouldHydrateRuntimeCatalog = !modelCatalog && (!selectedCatalogEntry || selectedCatalogEntry.reasoning === undefined); if (shouldHydrateRuntimeCatalog) { modelCatalog = await (await loadModelCatalogRuntime()).loadModelCatalog({ config: cfg }); logStage("catalog-loaded-for-thinking", `entries=${modelCatalog.length}`); const runtimeCatalog = buildThinkingCatalog(modelCatalog); const runtimeSelectedEntry = findSelectedCatalogEntry({ catalog: runtimeCatalog, provider, model, }); catalogForThinking = runtimeSelectedEntry || !catalogForThinking || catalogForThinking.length === 0 ? runtimeCatalog.length > 0 ? runtimeCatalog : allowedModelCatalog : allowedModelCatalog; } thinkingCatalog = catalogForThinking.length > 0 ? catalogForThinking : undefined; return thinkingCatalog; }; let defaultThinkingLevel: ThinkLevel | undefined; const resolveDefaultThinkingLevel = async () => { if (defaultThinkingLevel) { return defaultThinkingLevel; } const agentThinkingDefault = agentEntry?.thinkingDefault as ThinkLevel | undefined; if (agentThinkingDefault) { defaultThinkingLevel = agentThinkingDefault; return defaultThinkingLevel; } const configuredModels = cfg.agents?.defaults?.models; const canonicalKey = modelKey(provider, model); const legacyKey = legacyModelKey(provider, model); const configuredModelThinkingDefault = configuredModels?.[canonicalKey]?.params?.thinking ?? (legacyKey ? configuredModels?.[legacyKey]?.params?.thinking : undefined); if ( configuredModelThinkingDefault === false || configuredModelThinkingDefault === "disabled" || configuredModelThinkingDefault === "none" ) { defaultThinkingLevel = "off"; return defaultThinkingLevel; } if ( configuredModelThinkingDefault === "off" || configuredModelThinkingDefault === "minimal" || configuredModelThinkingDefault === "low" || configuredModelThinkingDefault === "medium" || configuredModelThinkingDefault === "high" || configuredModelThinkingDefault === "xhigh" || configuredModelThinkingDefault === "adaptive" || configuredModelThinkingDefault === "max" ) { defaultThinkingLevel = configuredModelThinkingDefault; return defaultThinkingLevel; } const configuredThinkingDefault = agentCfg?.thinkingDefault as ThinkLevel | undefined; if (configuredThinkingDefault) { defaultThinkingLevel = configuredThinkingDefault; return defaultThinkingLevel; } const catalogForThinking = await resolveThinkingCatalog(); const resolved = resolveThinkingDefault({ cfg, provider, model, catalog: catalogForThinking, }); defaultThinkingLevel = resolved ?? "off"; return defaultThinkingLevel; }; let defaultReasoningLevel: "on" | "off" | undefined; const resolveDefaultReasoningLevel = async (): Promise<"on" | "off"> => { if (defaultReasoningLevel) { return defaultReasoningLevel; } let catalogForReasoning = modelCatalog ?? allowedModelCatalog; let selectedReasoningEntry = findSelectedCatalogEntry({ catalog: catalogForReasoning, provider, model, }); if (!modelCatalog && selectedReasoningEntry?.reasoning === undefined) { const manifestCatalog = await loadManifestCatalog(); const manifestReasoningCatalog = hasAllowlist ? buildThinkingCatalog(manifestCatalog) : manifestCatalog; const manifestSelectedEntry = findSelectedCatalogEntry({ catalog: manifestReasoningCatalog, provider, model, }); if (manifestSelectedEntry?.reasoning !== undefined) { catalogForReasoning = manifestReasoningCatalog; selectedReasoningEntry = manifestSelectedEntry; } } if ( (!catalogForReasoning || catalogForReasoning.length === 0) && selectedReasoningEntry?.reasoning === undefined ) { modelCatalog = await (await loadModelCatalogRuntime()).loadModelCatalog({ config: cfg }); logStage("catalog-loaded-for-reasoning", `entries=${modelCatalog.length}`); catalogForReasoning = modelCatalog; } defaultReasoningLevel = resolveReasoningDefault({ provider, model, catalog: catalogForReasoning, }); return defaultReasoningLevel; }; return { provider, model, allowedModelKeys, allowedModelCatalog, resetModelOverride, resetModelOverrideRef, resolveThinkingCatalog, resolveDefaultThinkingLevel, resolveDefaultReasoningLevel, needsModelCatalog, }; } export function resolveContextTokens(params: { cfg: OpenClawConfig; agentCfg: NonNullable["defaults"]> | undefined; provider: string; model: string; }): number { const modelContextTokens = resolveContextTokensForModel({ cfg: params.cfg, provider: params.provider, model: params.model, allowAsyncLoad: false, }); const agentContextTokens = typeof params.agentCfg?.contextTokens === "number" && params.agentCfg.contextTokens > 0 ? Math.floor(params.agentCfg.contextTokens) : undefined; if (agentContextTokens !== undefined) { return modelContextTokens !== undefined ? Math.min(agentContextTokens, modelContextTokens) : agentContextTokens; } return modelContextTokens ?? DEFAULT_CONTEXT_TOKENS; }