From 4629ab3d8af52482f2b32ded040b241aa2a94eb9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 27 Mar 2026 01:50:01 +0000 Subject: [PATCH] refactor: move model picker logic into flow module --- src/commands/model-picker.test.ts | 2 +- src/commands/model-picker.ts | 647 +--------------------------- src/flows/model-picker.ts | 690 ++++++++++++++++++++++++++++++ 3 files changed, 703 insertions(+), 636 deletions(-) create mode 100644 src/flows/model-picker.ts diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index c3ea1428ad3..0d657bd7cc2 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -168,7 +168,7 @@ describe("promptDefaultModel", () => { { id: "provider:model-picker:ollama", kind: "provider", - surface: "models", + surface: "model-picker", option: { value: "ollama", label: "Ollama", diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index 1f85e5f488c..3e9e4f5364d 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -1,635 +1,12 @@ -import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; -import { loadModelCatalog } from "../agents/model-catalog.js"; -import { - buildAllowedModelSet, - buildModelAliasIndex, - modelKey, - normalizeProviderId, - resolveConfiguredModelRef, -} from "../agents/model-selection.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; -import { applyPrimaryModel } from "../plugins/provider-model-primary.js"; -import type { ProviderPlugin } from "../plugins/types.js"; -import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; -import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; -import { formatTokenK } from "./models/shared.js"; - -export { applyPrimaryModel } from "../plugins/provider-model-primary.js"; - -const KEEP_VALUE = "__keep__"; -const MANUAL_VALUE = "__manual__"; -const PROVIDER_FILTER_THRESHOLD = 30; - -// Models that are internal routing features and should not be shown in selection lists. -// These may be valid as defaults (e.g., set automatically during auth flow) but are not -// directly callable via API and would cause "Unknown model" errors if selected manually. -const HIDDEN_ROUTER_MODELS = new Set(["openrouter/auto"]); - -type PromptDefaultModelParams = { - config: OpenClawConfig; - prompter: WizardPrompter; - allowKeep?: boolean; - includeManual?: boolean; - includeProviderPluginSetups?: boolean; - ignoreAllowlist?: boolean; - preferredProvider?: string; - agentDir?: string; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - runtime?: import("../runtime.js").RuntimeEnv; - message?: string; -}; - -type PromptDefaultModelResult = { model?: string; config?: OpenClawConfig }; -type PromptModelAllowlistResult = { models?: string[] }; - -async function loadModelPickerRuntime() { - return import("./model-picker.runtime.js"); -} - -const loadResolvedModelPickerRuntime = createLazyRuntimeSurface( - loadModelPickerRuntime, - ({ modelPickerRuntime }) => modelPickerRuntime, -); - -function hasAuthForProvider( - provider: string, - cfg: OpenClawConfig, - store: ReturnType, -) { - if (listProfilesForProvider(store, provider).length > 0) { - return true; - } - if (resolveEnvApiKey(provider)) { - return true; - } - if (hasUsableCustomProviderApiKey(cfg, provider)) { - return true; - } - return false; -} - -function createProviderAuthChecker(params: { - cfg: OpenClawConfig; - agentDir?: string; -}): (provider: string) => boolean { - const authStore = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const authCache = new Map(); - return (provider: string) => { - const cached = authCache.get(provider); - if (cached !== undefined) { - return cached; - } - const value = hasAuthForProvider(provider, params.cfg, authStore); - authCache.set(provider, value); - return value; - }; -} - -function resolveConfiguredModelRaw(cfg: OpenClawConfig): string { - return resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model) ?? ""; -} - -function resolveConfiguredModelKeys(cfg: OpenClawConfig): string[] { - const models = cfg.agents?.defaults?.models ?? {}; - return Object.keys(models) - .map((key) => String(key ?? "").trim()) - .filter((key) => key.length > 0); -} - -function normalizeModelKeys(values: string[]): string[] { - const seen = new Set(); - const next: string[] = []; - for (const raw of values) { - const value = String(raw ?? "").trim(); - if (!value || seen.has(value)) { - continue; - } - seen.add(value); - next.push(value); - } - return next; -} - -function addModelSelectOption(params: { - entry: { - provider: string; - id: string; - name?: string; - contextWindow?: number; - reasoning?: boolean; - }; - options: WizardSelectOption[]; - seen: Set; - aliasIndex: ReturnType; - hasAuth: (provider: string) => boolean; -}) { - const key = modelKey(params.entry.provider, params.entry.id); - if (params.seen.has(key)) { - return; - } - // Skip internal router models that can't be directly called via API. - if (HIDDEN_ROUTER_MODELS.has(key)) { - return; - } - const hints: string[] = []; - if (params.entry.name && params.entry.name !== params.entry.id) { - hints.push(params.entry.name); - } - if (params.entry.contextWindow) { - hints.push(`ctx ${formatTokenK(params.entry.contextWindow)}`); - } - if (params.entry.reasoning) { - hints.push("reasoning"); - } - const aliases = params.aliasIndex.byKey.get(key); - if (aliases?.length) { - hints.push(`alias: ${aliases.join(", ")}`); - } - if (!params.hasAuth(params.entry.provider)) { - hints.push("auth missing"); - } - params.options.push({ - value: key, - label: key, - hint: hints.length > 0 ? hints.join(" · ") : undefined, - }); - params.seen.add(key); -} - -function matchesPreferredProvider(entryProvider: string, preferredProvider: string): boolean { - if (preferredProvider === "volcengine") { - return entryProvider === "volcengine" || entryProvider === "volcengine-plan"; - } - if (preferredProvider === "byteplus") { - return entryProvider === "byteplus" || entryProvider === "byteplus-plan"; - } - return entryProvider === preferredProvider; -} - -async function promptManualModel(params: { - prompter: WizardPrompter; - allowBlank: boolean; - initialValue?: string; -}): Promise { - const modelInput = await params.prompter.text({ - message: params.allowBlank ? "Default model (blank to keep)" : "Default model", - initialValue: params.initialValue, - placeholder: "provider/model", - validate: params.allowBlank ? undefined : (value) => (value?.trim() ? undefined : "Required"), - }); - const model = String(modelInput ?? "").trim(); - if (!model) { - return {}; - } - return { model }; -} - -export async function promptDefaultModel( - params: PromptDefaultModelParams, -): Promise { - const cfg = params.config; - const allowKeep = params.allowKeep ?? true; - const includeManual = params.includeManual ?? true; - const includeProviderPluginSetups = params.includeProviderPluginSetups ?? false; - const ignoreAllowlist = params.ignoreAllowlist ?? false; - const preferredProviderRaw = params.preferredProvider?.trim(); - const preferredProvider = preferredProviderRaw - ? normalizeProviderId(preferredProviderRaw) - : undefined; - const configuredRaw = resolveConfiguredModelRaw(cfg); - - const resolved = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const resolvedKey = modelKey(resolved.provider, resolved.model); - const configuredKey = configuredRaw ? resolvedKey : ""; - - const catalog = await loadModelCatalog({ config: cfg, useCache: false }); - if (catalog.length === 0) { - return promptManualModel({ - prompter: params.prompter, - allowBlank: allowKeep, - initialValue: configuredRaw || resolvedKey || undefined, - }); - } - - const aliasIndex = buildModelAliasIndex({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - let models = catalog; - if (!ignoreAllowlist) { - const { allowedCatalog } = buildAllowedModelSet({ - cfg, - catalog, - defaultProvider: DEFAULT_PROVIDER, - }); - models = allowedCatalog.length > 0 ? allowedCatalog : catalog; - } - - if (models.length === 0) { - return promptManualModel({ - prompter: params.prompter, - allowBlank: allowKeep, - initialValue: configuredRaw || resolvedKey || undefined, - }); - } - - const providerIds = Array.from(new Set(models.map((entry) => entry.provider))).toSorted((a, b) => - a.localeCompare(b), - ); - - const hasPreferredProvider = preferredProvider ? providerIds.includes(preferredProvider) : false; - const shouldPromptProvider = - !hasPreferredProvider && providerIds.length > 1 && models.length > PROVIDER_FILTER_THRESHOLD; - if (shouldPromptProvider) { - const selection = await params.prompter.select({ - message: "Filter models by provider", - options: [ - { value: "*", label: "All providers" }, - ...providerIds.map((provider) => { - const count = models.filter((entry) => entry.provider === provider).length; - return { - value: provider, - label: provider, - hint: `${count} model${count === 1 ? "" : "s"}`, - }; - }), - ], - }); - if (selection !== "*") { - models = models.filter((entry) => entry.provider === selection); - } - } - - if (hasPreferredProvider && preferredProvider) { - models = models.filter((entry) => matchesPreferredProvider(entry.provider, preferredProvider)); - } - - const agentDir = params.agentDir; - const hasAuth = createProviderAuthChecker({ cfg, agentDir }); - - const options: WizardSelectOption[] = []; - if (allowKeep) { - options.push({ - value: KEEP_VALUE, - label: configuredRaw - ? `Keep current (${configuredRaw})` - : `Keep current (default: ${resolvedKey})`, - hint: - configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined, - }); - } - if (includeManual) { - options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); - } - if (includeProviderPluginSetups && agentDir) { - const runtime = await loadResolvedModelPickerRuntime(); - const providerModelPickerOptions = - "resolveProviderModelPickerContributions" in runtime && - typeof runtime.resolveProviderModelPickerContributions === "function" - ? runtime - .resolveProviderModelPickerContributions({ - config: cfg, - workspaceDir: params.workspaceDir, - env: params.env, - }) - .map((contribution) => contribution.option) - : runtime.resolveProviderModelPickerEntries({ - config: cfg, - workspaceDir: params.workspaceDir, - env: params.env, - }); - options.push( - ...providerModelPickerOptions.map((entry) => ({ - value: entry.value, - label: entry.label, - ...(entry.hint ? { hint: entry.hint } : {}), - })), - ); - } - - const seen = new Set(); - - for (const entry of models) { - addModelSelectOption({ entry, options, seen, aliasIndex, hasAuth }); - } - - if (configuredKey && !seen.has(configuredKey)) { - options.push({ - value: configuredKey, - label: configuredKey, - hint: "current (not in catalog)", - }); - } - - let initialValue: string | undefined = allowKeep ? KEEP_VALUE : configuredKey || undefined; - if ( - allowKeep && - hasPreferredProvider && - preferredProvider && - resolved.provider !== preferredProvider - ) { - const firstModel = models[0]; - if (firstModel) { - initialValue = modelKey(firstModel.provider, firstModel.id); - } - } - - const selection = await params.prompter.select({ - message: params.message ?? "Default model", - options, - initialValue, - }); - - if (selection === KEEP_VALUE) { - return {}; - } - if (selection === MANUAL_VALUE) { - return promptManualModel({ - prompter: params.prompter, - allowBlank: false, - initialValue: configuredRaw || resolvedKey || undefined, - }); - } - - let pluginResolution: string | null = null; - let pluginProviders: ProviderPlugin[] = []; - if (selection.startsWith("provider-plugin:")) { - pluginResolution = selection; - } else if (!selection.includes("/")) { - const { resolvePluginProviders } = await loadResolvedModelPickerRuntime(); - pluginProviders = resolvePluginProviders({ - config: cfg, - workspaceDir: params.workspaceDir, - env: params.env, - }); - pluginResolution = pluginProviders.some( - (provider) => normalizeProviderId(provider.id) === normalizeProviderId(selection), - ) - ? selection - : null; - } - if (pluginResolution) { - if (!agentDir || !params.runtime) { - await params.prompter.note( - "Provider setup requires agent and runtime context.", - "Provider setup unavailable", - ); - return {}; - } - const { - resolvePluginProviders, - resolveProviderPluginChoice, - runProviderModelSelectedHook, - runProviderPluginAuthMethod, - } = await loadResolvedModelPickerRuntime(); - if (pluginProviders.length === 0) { - pluginProviders = resolvePluginProviders({ - config: cfg, - workspaceDir: params.workspaceDir, - env: params.env, - }); - } - const resolved = resolveProviderPluginChoice({ - providers: pluginProviders, - choice: pluginResolution, - }); - if (!resolved) { - return {}; - } - const applied = await runProviderPluginAuthMethod({ - config: cfg, - runtime: params.runtime, - prompter: params.prompter, - method: resolved.method, - agentDir, - workspaceDir: params.workspaceDir, - }); - if (applied.defaultModel) { - await runProviderModelSelectedHook({ - config: applied.config, - model: applied.defaultModel, - prompter: params.prompter, - agentDir, - workspaceDir: params.workspaceDir, - env: params.env, - }); - } - return { model: applied.defaultModel, config: applied.config }; - } - const model = String(selection); - const { runProviderModelSelectedHook } = await loadResolvedModelPickerRuntime(); - await runProviderModelSelectedHook({ - config: cfg, - model, - prompter: params.prompter, - agentDir, - workspaceDir: params.workspaceDir, - env: params.env, - }); - return { model }; -} - -export async function promptModelAllowlist(params: { - config: OpenClawConfig; - prompter: WizardPrompter; - message?: string; - agentDir?: string; - allowedKeys?: string[]; - initialSelections?: string[]; - preferredProvider?: string; -}): Promise { - const cfg = params.config; - const existingKeys = resolveConfiguredModelKeys(cfg); - const allowedKeys = normalizeModelKeys(params.allowedKeys ?? []); - const allowedKeySet = allowedKeys.length > 0 ? new Set(allowedKeys) : null; - const preferredProviderRaw = params.preferredProvider?.trim(); - const preferredProvider = preferredProviderRaw - ? normalizeProviderId(preferredProviderRaw) - : undefined; - const resolved = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const resolvedKey = modelKey(resolved.provider, resolved.model); - const initialSeeds = normalizeModelKeys([ - ...existingKeys, - resolvedKey, - ...(params.initialSelections ?? []), - ]); - const initialKeys = allowedKeySet - ? initialSeeds.filter((key) => allowedKeySet.has(key)) - : initialSeeds; - - const catalog = await loadModelCatalog({ config: cfg, useCache: false }); - if (catalog.length === 0 && allowedKeys.length === 0) { - const raw = await params.prompter.text({ - message: - params.message ?? - "Allowlist models (comma-separated provider/model; blank to keep current)", - initialValue: existingKeys.join(", "), - placeholder: "provider/model, other-provider/model", - }); - const parsed = String(raw ?? "") - .split(",") - .map((value) => value.trim()) - .filter((value) => value.length > 0); - if (parsed.length === 0) { - return {}; - } - return { models: normalizeModelKeys(parsed) }; - } - - const aliasIndex = buildModelAliasIndex({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir }); - - const options: WizardSelectOption[] = []; - const seen = new Set(); - - const allowedCatalog = allowedKeySet - ? catalog.filter((entry) => allowedKeySet.has(modelKey(entry.provider, entry.id))) - : catalog; - const filteredCatalog = - preferredProvider && - allowedCatalog.some((entry) => matchesPreferredProvider(entry.provider, preferredProvider)) - ? allowedCatalog.filter((entry) => - matchesPreferredProvider(entry.provider, preferredProvider), - ) - : allowedCatalog; - - for (const entry of filteredCatalog) { - addModelSelectOption({ entry, options, seen, aliasIndex, hasAuth }); - } - - const supplementalKeys = allowedKeySet ? allowedKeys : existingKeys; - for (const key of supplementalKeys) { - if (seen.has(key)) { - continue; - } - options.push({ - value: key, - label: key, - hint: allowedKeySet ? "allowed (not in catalog)" : "configured (not in catalog)", - }); - seen.add(key); - } - - if (options.length === 0) { - return {}; - } - - const selection = await params.prompter.multiselect({ - message: params.message ?? "Models in /model picker (multi-select)", - options, - initialValues: initialKeys.length > 0 ? initialKeys : undefined, - searchable: true, - }); - const selected = normalizeModelKeys(selection.map((value) => String(value))); - if (selected.length > 0) { - return { models: selected }; - } - if (existingKeys.length === 0) { - return { models: [] }; - } - const confirmClear = await params.prompter.confirm({ - message: "Clear the model allowlist? (shows all models)", - initialValue: false, - }); - if (!confirmClear) { - return {}; - } - return { models: [] }; -} - -export function applyModelAllowlist(cfg: OpenClawConfig, models: string[]): OpenClawConfig { - const defaults = cfg.agents?.defaults; - const normalized = normalizeModelKeys(models); - if (normalized.length === 0) { - if (!defaults?.models) { - return cfg; - } - const { models: _ignored, ...restDefaults } = defaults; - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: restDefaults, - }, - }; - } - - const existingModels = defaults?.models ?? {}; - const nextModels: Record = {}; - for (const key of normalized) { - nextModels[key] = existingModels[key] ?? {}; - } - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...defaults, - models: nextModels, - }, - }, - }; -} - -export function applyModelFallbacksFromSelection( - cfg: OpenClawConfig, - selection: string[], -): OpenClawConfig { - const normalized = normalizeModelKeys(selection); - if (normalized.length <= 1) { - return cfg; - } - - const resolved = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const resolvedKey = modelKey(resolved.provider, resolved.model); - if (!normalized.includes(resolvedKey)) { - return cfg; - } - - const defaults = cfg.agents?.defaults; - const existingModel = defaults?.model; - const existingPrimary = - typeof existingModel === "string" - ? existingModel - : existingModel && typeof existingModel === "object" - ? existingModel.primary - : undefined; - - const fallbacks = normalized.filter((key) => key !== resolvedKey); - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...defaults, - model: { - ...(typeof existingModel === "object" ? existingModel : undefined), - primary: existingPrimary ?? resolvedKey, - fallbacks, - }, - }, - }, - }; -} +export { + applyModelAllowlist, + applyModelFallbacksFromSelection, + applyPrimaryModel, + promptDefaultModel, + promptModelAllowlist, +} from "../flows/model-picker.js"; +export type { + PromptDefaultModelParams, + PromptDefaultModelResult, + PromptModelAllowlistResult, +} from "../flows/model-picker.js"; diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts new file mode 100644 index 00000000000..7ebb99206bc --- /dev/null +++ b/src/flows/model-picker.ts @@ -0,0 +1,690 @@ +import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; +import { loadModelCatalog } from "../agents/model-catalog.js"; +import { + buildAllowedModelSet, + buildModelAliasIndex, + modelKey, + normalizeProviderId, + resolveConfiguredModelRef, +} from "../agents/model-selection.js"; +import { formatTokenK } from "../commands/models/shared.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import { applyPrimaryModel } from "../plugins/provider-model-primary.js"; +import type { ProviderPlugin } from "../plugins/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; +import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; + +export { applyPrimaryModel } from "../plugins/provider-model-primary.js"; + +const KEEP_VALUE = "__keep__"; +const MANUAL_VALUE = "__manual__"; +const PROVIDER_FILTER_THRESHOLD = 30; + +// Internal router models are valid defaults during auth/setup but not manual API targets. +const HIDDEN_ROUTER_MODELS = new Set(["openrouter/auto"]); + +export type PromptDefaultModelParams = { + config: OpenClawConfig; + prompter: WizardPrompter; + allowKeep?: boolean; + includeManual?: boolean; + includeProviderPluginSetups?: boolean; + ignoreAllowlist?: boolean; + preferredProvider?: string; + agentDir?: string; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + runtime?: RuntimeEnv; + message?: string; +}; + +export type PromptDefaultModelResult = { model?: string; config?: OpenClawConfig }; +export type PromptModelAllowlistResult = { models?: string[] }; + +async function loadModelPickerRuntime() { + return import("../commands/model-picker.runtime.js"); +} + +const loadResolvedModelPickerRuntime = createLazyRuntimeSurface( + loadModelPickerRuntime, + ({ modelPickerRuntime }) => modelPickerRuntime, +); + +function hasAuthForProvider( + provider: string, + cfg: OpenClawConfig, + store: ReturnType, +) { + if (listProfilesForProvider(store, provider).length > 0) { + return true; + } + if (resolveEnvApiKey(provider)) { + return true; + } + if (hasUsableCustomProviderApiKey(cfg, provider)) { + return true; + } + return false; +} + +function createProviderAuthChecker(params: { + cfg: OpenClawConfig; + agentDir?: string; +}): (provider: string) => boolean { + const authStore = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const authCache = new Map(); + return (provider: string) => { + const cached = authCache.get(provider); + if (cached !== undefined) { + return cached; + } + const value = hasAuthForProvider(provider, params.cfg, authStore); + authCache.set(provider, value); + return value; + }; +} + +function resolveConfiguredModelRaw(cfg: OpenClawConfig): string { + return resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model) ?? ""; +} + +function resolveConfiguredModelKeys(cfg: OpenClawConfig): string[] { + const models = cfg.agents?.defaults?.models ?? {}; + return Object.keys(models) + .map((key) => String(key ?? "").trim()) + .filter((key) => key.length > 0); +} + +function normalizeModelKeys(values: string[]): string[] { + const seen = new Set(); + const next: string[] = []; + for (const raw of values) { + const value = String(raw ?? "").trim(); + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + next.push(value); + } + return next; +} + +function addModelSelectOption(params: { + entry: { + provider: string; + id: string; + name?: string; + contextWindow?: number; + reasoning?: boolean; + }; + options: WizardSelectOption[]; + seen: Set; + aliasIndex: ReturnType; + hasAuth: (provider: string) => boolean; +}) { + const key = modelKey(params.entry.provider, params.entry.id); + if (params.seen.has(key) || HIDDEN_ROUTER_MODELS.has(key)) { + return; + } + const hints: string[] = []; + if (params.entry.name && params.entry.name !== params.entry.id) { + hints.push(params.entry.name); + } + if (params.entry.contextWindow) { + hints.push(`ctx ${formatTokenK(params.entry.contextWindow)}`); + } + if (params.entry.reasoning) { + hints.push("reasoning"); + } + const aliases = params.aliasIndex.byKey.get(key); + if (aliases?.length) { + hints.push(`alias: ${aliases.join(", ")}`); + } + if (!params.hasAuth(params.entry.provider)) { + hints.push("auth missing"); + } + params.options.push({ + value: key, + label: key, + hint: hints.length > 0 ? hints.join(" · ") : undefined, + }); + params.seen.add(key); +} + +function matchesPreferredProvider(entryProvider: string, preferredProvider: string): boolean { + if (preferredProvider === "volcengine") { + return entryProvider === "volcengine" || entryProvider === "volcengine-plan"; + } + if (preferredProvider === "byteplus") { + return entryProvider === "byteplus" || entryProvider === "byteplus-plan"; + } + return entryProvider === preferredProvider; +} + +async function promptManualModel(params: { + prompter: WizardPrompter; + allowBlank: boolean; + initialValue?: string; +}): Promise { + const modelInput = await params.prompter.text({ + message: params.allowBlank ? "Default model (blank to keep)" : "Default model", + initialValue: params.initialValue, + placeholder: "provider/model", + validate: params.allowBlank ? undefined : (value) => (value?.trim() ? undefined : "Required"), + }); + const model = String(modelInput ?? "").trim(); + if (!model) { + return {}; + } + return { model }; +} + +function buildModelProviderFilterOptions( + models: Array<{ provider: string }>, +): Array<{ value: string; label: string; hint: string }> { + const providerIds = Array.from(new Set(models.map((entry) => entry.provider))).toSorted((a, b) => + a.localeCompare(b), + ); + return providerIds.map((provider) => { + const count = models.filter((entry) => entry.provider === provider).length; + return { + value: provider, + label: provider, + hint: `${count} model${count === 1 ? "" : "s"}`, + }; + }); +} + +async function maybeFilterModelsByProvider(params: { + models: Array<{ + provider: string; + id: string; + name?: string; + contextWindow?: number; + reasoning?: boolean; + }>; + preferredProvider?: string; + prompter: WizardPrompter; +}): Promise { + const providerIds = Array.from(new Set(params.models.map((entry) => entry.provider))).toSorted( + (a, b) => a.localeCompare(b), + ); + const hasPreferredProvider = params.preferredProvider + ? providerIds.includes(params.preferredProvider) + : false; + const shouldPromptProvider = + !hasPreferredProvider && + providerIds.length > 1 && + params.models.length > PROVIDER_FILTER_THRESHOLD; + let next = params.models; + if (shouldPromptProvider) { + const selection = await params.prompter.select({ + message: "Filter models by provider", + options: [{ value: "*", label: "All providers" }, ...buildModelProviderFilterOptions(next)], + }); + if (selection !== "*") { + next = next.filter((entry) => entry.provider === selection); + } + } + if (hasPreferredProvider && params.preferredProvider) { + next = next.filter((entry) => + matchesPreferredProvider(entry.provider, params.preferredProvider!), + ); + } + return next; +} + +async function resolveProviderPluginSetupOptions(params: { + cfg: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const runtime = await loadResolvedModelPickerRuntime(); + const providerModelPickerOptions = + "resolveProviderModelPickerContributions" in runtime && + typeof runtime.resolveProviderModelPickerContributions === "function" + ? runtime + .resolveProviderModelPickerContributions({ + config: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .map((contribution) => contribution.option) + : runtime.resolveProviderModelPickerEntries({ + config: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env, + }); + return providerModelPickerOptions.map((entry) => ({ + value: entry.value, + label: entry.label, + ...(entry.hint ? { hint: entry.hint } : {}), + })); +} + +async function maybeHandleProviderPluginSelection(params: { + selection: string; + cfg: OpenClawConfig; + prompter: WizardPrompter; + agentDir?: string; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + runtime?: RuntimeEnv; +}): Promise { + let pluginResolution: string | null = null; + let pluginProviders: ProviderPlugin[] = []; + if (params.selection.startsWith("provider-plugin:")) { + pluginResolution = params.selection; + } else if (!params.selection.includes("/")) { + const { resolvePluginProviders } = await loadResolvedModelPickerRuntime(); + pluginProviders = resolvePluginProviders({ + config: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env, + }); + pluginResolution = pluginProviders.some( + (provider) => normalizeProviderId(provider.id) === normalizeProviderId(params.selection), + ) + ? params.selection + : null; + } + if (!pluginResolution) { + return null; + } + if (!params.agentDir || !params.runtime) { + await params.prompter.note( + "Provider setup requires agent and runtime context.", + "Provider setup unavailable", + ); + return {}; + } + const { + resolvePluginProviders, + resolveProviderPluginChoice, + runProviderModelSelectedHook, + runProviderPluginAuthMethod, + } = await loadResolvedModelPickerRuntime(); + if (pluginProviders.length === 0) { + pluginProviders = resolvePluginProviders({ + config: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env, + }); + } + const resolved = resolveProviderPluginChoice({ + providers: pluginProviders, + choice: pluginResolution, + }); + if (!resolved) { + return {}; + } + const applied = await runProviderPluginAuthMethod({ + config: params.cfg, + runtime: params.runtime, + prompter: params.prompter, + method: resolved.method, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + }); + if (applied.defaultModel) { + await runProviderModelSelectedHook({ + config: applied.config, + model: applied.defaultModel, + prompter: params.prompter, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + env: params.env, + }); + } + return { model: applied.defaultModel, config: applied.config }; +} + +export async function promptDefaultModel( + params: PromptDefaultModelParams, +): Promise { + const cfg = params.config; + const allowKeep = params.allowKeep ?? true; + const includeManual = params.includeManual ?? true; + const includeProviderPluginSetups = params.includeProviderPluginSetups ?? false; + const ignoreAllowlist = params.ignoreAllowlist ?? false; + const preferredProvider = params.preferredProvider?.trim() + ? normalizeProviderId(params.preferredProvider) + : undefined; + const configuredRaw = resolveConfiguredModelRaw(cfg); + const resolved = resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const resolvedKey = modelKey(resolved.provider, resolved.model); + const configuredKey = configuredRaw ? resolvedKey : ""; + + const catalog = await loadModelCatalog({ config: cfg, useCache: false }); + if (catalog.length === 0) { + return promptManualModel({ + prompter: params.prompter, + allowBlank: allowKeep, + initialValue: configuredRaw || resolvedKey || undefined, + }); + } + + const aliasIndex = buildModelAliasIndex({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + }); + const models = ignoreAllowlist + ? catalog + : (() => { + const { allowedCatalog } = buildAllowedModelSet({ + cfg, + catalog, + defaultProvider: DEFAULT_PROVIDER, + }); + return allowedCatalog.length > 0 ? allowedCatalog : catalog; + })(); + if (models.length === 0) { + return promptManualModel({ + prompter: params.prompter, + allowBlank: allowKeep, + initialValue: configuredRaw || resolvedKey || undefined, + }); + } + + const filteredModels = await maybeFilterModelsByProvider({ + models, + preferredProvider, + prompter: params.prompter, + }); + const hasPreferredProvider = preferredProvider + ? filteredModels.some((entry) => matchesPreferredProvider(entry.provider, preferredProvider)) + : false; + const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir }); + + const options: WizardSelectOption[] = []; + if (allowKeep) { + options.push({ + value: KEEP_VALUE, + label: configuredRaw + ? `Keep current (${configuredRaw})` + : `Keep current (default: ${resolvedKey})`, + hint: + configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined, + }); + } + if (includeManual) { + options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); + } + if (includeProviderPluginSetups && params.agentDir) { + options.push( + ...(await resolveProviderPluginSetupOptions({ + cfg, + workspaceDir: params.workspaceDir, + env: params.env, + })), + ); + } + + const seen = new Set(); + for (const entry of filteredModels) { + addModelSelectOption({ entry, options, seen, aliasIndex, hasAuth }); + } + if (configuredKey && !seen.has(configuredKey)) { + options.push({ + value: configuredKey, + label: configuredKey, + hint: "current (not in catalog)", + }); + } + + let initialValue: string | undefined = allowKeep ? KEEP_VALUE : configuredKey || undefined; + if ( + allowKeep && + hasPreferredProvider && + preferredProvider && + resolved.provider !== preferredProvider + ) { + const firstModel = filteredModels[0]; + if (firstModel) { + initialValue = modelKey(firstModel.provider, firstModel.id); + } + } + + const selection = await params.prompter.select({ + message: params.message ?? "Default model", + options, + initialValue, + }); + if (selection === KEEP_VALUE) { + return {}; + } + if (selection === MANUAL_VALUE) { + return promptManualModel({ + prompter: params.prompter, + allowBlank: false, + initialValue: configuredRaw || resolvedKey || undefined, + }); + } + + const providerPluginResult = await maybeHandleProviderPluginSelection({ + selection: String(selection), + cfg, + prompter: params.prompter, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + env: params.env, + runtime: params.runtime, + }); + if (providerPluginResult) { + return providerPluginResult; + } + + const model = String(selection); + const { runProviderModelSelectedHook } = await loadResolvedModelPickerRuntime(); + await runProviderModelSelectedHook({ + config: cfg, + model, + prompter: params.prompter, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + env: params.env, + }); + return { model }; +} + +export async function promptModelAllowlist(params: { + config: OpenClawConfig; + prompter: WizardPrompter; + message?: string; + agentDir?: string; + allowedKeys?: string[]; + initialSelections?: string[]; + preferredProvider?: string; +}): Promise { + const cfg = params.config; + const existingKeys = resolveConfiguredModelKeys(cfg); + const allowedKeys = normalizeModelKeys(params.allowedKeys ?? []); + const allowedKeySet = allowedKeys.length > 0 ? new Set(allowedKeys) : null; + const preferredProvider = params.preferredProvider?.trim() + ? normalizeProviderId(params.preferredProvider) + : undefined; + const resolved = resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const resolvedKey = modelKey(resolved.provider, resolved.model); + const initialSeeds = normalizeModelKeys([ + ...existingKeys, + resolvedKey, + ...(params.initialSelections ?? []), + ]); + const initialKeys = allowedKeySet + ? initialSeeds.filter((key) => allowedKeySet.has(key)) + : initialSeeds; + + const catalog = await loadModelCatalog({ config: cfg, useCache: false }); + if (catalog.length === 0 && allowedKeys.length === 0) { + const raw = await params.prompter.text({ + message: + params.message ?? + "Allowlist models (comma-separated provider/model; blank to keep current)", + initialValue: existingKeys.join(", "), + placeholder: "provider/model, other-provider/model", + }); + const parsed = String(raw ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + if (parsed.length === 0) { + return {}; + } + return { models: normalizeModelKeys(parsed) }; + } + + const aliasIndex = buildModelAliasIndex({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + }); + const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir }); + + const options: WizardSelectOption[] = []; + const seen = new Set(); + const allowedCatalog = allowedKeySet + ? catalog.filter((entry) => allowedKeySet.has(modelKey(entry.provider, entry.id))) + : catalog; + const filteredCatalog = + preferredProvider && + allowedCatalog.some((entry) => matchesPreferredProvider(entry.provider, preferredProvider)) + ? allowedCatalog.filter((entry) => + matchesPreferredProvider(entry.provider, preferredProvider), + ) + : allowedCatalog; + + for (const entry of filteredCatalog) { + addModelSelectOption({ entry, options, seen, aliasIndex, hasAuth }); + } + + const supplementalKeys = allowedKeySet ? allowedKeys : existingKeys; + for (const key of supplementalKeys) { + if (seen.has(key)) { + continue; + } + options.push({ + value: key, + label: key, + hint: allowedKeySet ? "allowed (not in catalog)" : "configured (not in catalog)", + }); + seen.add(key); + } + if (options.length === 0) { + return {}; + } + + const selection = await params.prompter.multiselect({ + message: params.message ?? "Models in /model picker (multi-select)", + options, + initialValues: initialKeys.length > 0 ? initialKeys : undefined, + searchable: true, + }); + const selected = normalizeModelKeys(selection.map((value) => String(value))); + if (selected.length > 0) { + return { models: selected }; + } + if (existingKeys.length === 0) { + return { models: [] }; + } + const confirmClear = await params.prompter.confirm({ + message: "Clear the model allowlist? (shows all models)", + initialValue: false, + }); + if (!confirmClear) { + return {}; + } + return { models: [] }; +} + +export function applyModelAllowlist(cfg: OpenClawConfig, models: string[]): OpenClawConfig { + const defaults = cfg.agents?.defaults; + const normalized = normalizeModelKeys(models); + if (normalized.length === 0) { + if (!defaults?.models) { + return cfg; + } + const { models: _ignored, ...restDefaults } = defaults; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: restDefaults, + }, + }; + } + + const existingModels = defaults?.models ?? {}; + const nextModels: Record = {}; + for (const key of normalized) { + nextModels[key] = existingModels[key] ?? {}; + } + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...defaults, + models: nextModels, + }, + }, + }; +} + +export function applyModelFallbacksFromSelection( + cfg: OpenClawConfig, + selection: string[], +): OpenClawConfig { + const normalized = normalizeModelKeys(selection); + if (normalized.length <= 1) { + return cfg; + } + + const resolved = resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const resolvedKey = modelKey(resolved.provider, resolved.model); + if (!normalized.includes(resolvedKey)) { + return cfg; + } + + const defaults = cfg.agents?.defaults; + const existingModel = defaults?.model; + const existingPrimary = + typeof existingModel === "string" + ? existingModel + : existingModel && typeof existingModel === "object" + ? existingModel.primary + : undefined; + + const fallbacks = normalized.filter((key) => key !== resolvedKey); + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...defaults, + model: { + ...(typeof existingModel === "object" ? existingModel : undefined), + primary: existingPrimary ?? resolvedKey, + fallbacks, + }, + }, + }, + }; +}