diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 0aa1786a8..22e9131eb 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -83,6 +83,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` ( - **MiniMax API (platform.minimax.io)**: config is auto‑written for the Anthropic-compatible `/anthropic` endpoint. - **MiniMax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint. - **Skip**: no auth configured yet. + - Pick a default model from detected options (or enter provider/model manually). - Wizard runs a model check and warns if the configured model is unknown or missing auth. - OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; auth profiles live in `~/.clawdbot/agents//agent/auth-profiles.json` (API keys + OAuth). - More detail: [/concepts/oauth](/concepts/oauth) diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 1d09e220c..ca15e5b7d 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -729,3 +729,32 @@ export async function applyAuthChoice(params: { return { config: nextConfig, agentModelOverride }; } + +export function resolvePreferredProviderForAuthChoice( + choice: AuthChoice, +): string | undefined { + switch (choice) { + case "oauth": + case "setup-token": + case "claude-cli": + case "token": + case "apiKey": + return "anthropic"; + case "openai-codex": + case "codex-cli": + return "openai-codex"; + case "openai-api-key": + return "openai"; + case "gemini-api-key": + return "google"; + case "antigravity": + return "google-antigravity"; + case "minimax-cloud": + case "minimax-api": + return "minimax"; + case "minimax": + return "lmstudio"; + default: + return undefined; + } +} diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 55fa1c6a2..b81abcd7e 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -37,7 +37,10 @@ import { WizardCancelledError, type WizardPrompter, } from "../wizard/prompts.js"; -import { applyAuthChoice } from "./auth-choice.js"; +import { + applyAuthChoice, + resolvePreferredProviderForAuthChoice, +} from "./auth-choice.js"; import { buildAuthChoiceOptions } from "./auth-choice-options.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, @@ -46,6 +49,7 @@ import { } from "./daemon-runtime.js"; import { healthCommand } from "./health.js"; import { formatHealthCheckFailure } from "./health-format.js"; +import { applyPrimaryModel, promptDefaultModel } from "./model-picker.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, @@ -310,56 +314,15 @@ async function promptAuthConfig( next = applied.config; } - const currentModel = - typeof next.agents?.defaults?.model === "string" - ? next.agents?.defaults?.model - : (next.agents?.defaults?.model?.primary ?? ""); - const preferAnthropic = - authChoice === "claude-cli" || - authChoice === "setup-token" || - authChoice === "token" || - authChoice === "oauth" || - authChoice === "apiKey"; - const modelInitialValue = - preferAnthropic && !currentModel.startsWith("anthropic/") - ? "anthropic/claude-opus-4-5" - : currentModel; - - const modelInput = guardCancel( - await text({ - message: "Default model (blank to keep)", - initialValue: modelInitialValue, - }), - runtime, - ); - const model = String(modelInput ?? "").trim(); - if (model) { - const existingDefaults = next.agents?.defaults; - const existingModel = existingDefaults?.model; - const existingModels = existingDefaults?.models; - next = { - ...next, - agents: { - ...next.agents, - defaults: { - ...existingDefaults, - model: { - ...(existingModel && - "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }) - .fallbacks, - } - : undefined), - primary: model, - }, - models: { - ...existingModels, - [model]: existingModels?.[model] ?? {}, - }, - }, - }, - }; + const modelSelection = await promptDefaultModel({ + config: next, + prompter, + allowKeep: true, + ignoreAllowlist: true, + preferredProvider: resolvePreferredProviderForAuthChoice(authChoice), + }); + if (modelSelection.model) { + next = applyPrimaryModel(next, modelSelection.model); } return next; diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts new file mode 100644 index 000000000..5a83094a8 --- /dev/null +++ b/src/commands/model-picker.ts @@ -0,0 +1,282 @@ +import { + ensureAuthProfileStore, + listProfilesForProvider, +} from "../agents/auth-profiles.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { + getCustomProviderApiKey, + resolveEnvApiKey, +} from "../agents/model-auth.js"; +import { loadModelCatalog } from "../agents/model-catalog.js"; +import { + buildAllowedModelSet, + buildModelAliasIndex, + modelKey, + normalizeProviderId, + resolveConfiguredModelRef, +} from "../agents/model-selection.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; +import { formatTokenK } from "./models/shared.js"; + +const KEEP_VALUE = "__keep__"; +const MANUAL_VALUE = "__manual__"; +const PROVIDER_FILTER_THRESHOLD = 30; + +type PromptDefaultModelParams = { + config: ClawdbotConfig; + prompter: WizardPrompter; + allowKeep?: boolean; + includeManual?: boolean; + ignoreAllowlist?: boolean; + preferredProvider?: string; + agentDir?: string; + message?: string; +}; + +type PromptDefaultModelResult = { model?: string }; + +function hasAuthForProvider( + provider: string, + cfg: ClawdbotConfig, + store: ReturnType, +) { + if (listProfilesForProvider(store, provider).length > 0) return true; + if (resolveEnvApiKey(provider)) return true; + if (getCustomProviderApiKey(cfg, provider)) return true; + return false; +} + +function resolveConfiguredModelRaw(cfg: ClawdbotConfig): string { + const raw = cfg.agents?.defaults?.model as + | { primary?: string } + | string + | undefined; + if (typeof raw === "string") return raw.trim(); + return raw?.primary?.trim() ?? ""; +} + +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 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 }); + 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 providers = Array.from( + new Set(models.map((entry) => entry.provider)), + ).sort((a, b) => a.localeCompare(b)); + + const hasPreferredProvider = preferredProvider + ? providers.includes(preferredProvider) + : false; + const shouldPromptProvider = + !hasPreferredProvider && + providers.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" }, + ...providers.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) => entry.provider === preferredProvider); + } + + const authStore = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const authCache = new Map(); + const hasAuth = (provider: string) => { + const cached = authCache.get(provider); + if (cached !== undefined) return cached; + const value = hasAuthForProvider(provider, cfg, authStore); + authCache.set(provider, value); + return value; + }; + + 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" }); + } + + const seen = new Set(); + const addModelOption = (entry: { + provider: string; + id: string; + name?: string; + contextWindow?: number; + reasoning?: boolean; + }) => { + const key = modelKey(entry.provider, entry.id); + if (seen.has(key)) return; + const hints: string[] = []; + if (entry.name && entry.name !== entry.id) hints.push(entry.name); + if (entry.contextWindow) + hints.push(`ctx ${formatTokenK(entry.contextWindow)}`); + if (entry.reasoning) hints.push("reasoning"); + const aliases = aliasIndex.byKey.get(key); + if (aliases?.length) hints.push(`alias: ${aliases.join(", ")}`); + if (!hasAuth(entry.provider)) hints.push("auth missing"); + options.push({ + value: key, + label: key, + hint: hints.length > 0 ? hints.join(" · ") : undefined, + }); + seen.add(key); + }; + + for (const entry of models) addModelOption(entry); + + if (configuredKey && !seen.has(configuredKey)) { + options.push({ + value: configuredKey, + label: configuredKey, + hint: "current (not in catalog)", + }); + } + + const initialValue = allowKeep ? KEEP_VALUE : configuredKey || undefined; + + 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, + }); + } + return { model: String(selection) }; +} + +export function applyPrimaryModel( + cfg: ClawdbotConfig, + model: string, +): ClawdbotConfig { + const defaults = cfg.agents?.defaults; + const existingModel = defaults?.model; + const existingModels = defaults?.models; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...defaults, + model: { + ...(existingModel && + "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }) + .fallbacks, + } + : undefined), + primary: model, + }, + models: { + ...existingModels, + [model]: existingModels?.[model] ?? {}, + }, + }, + }, + }; +} diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 302c9ec7e..b22e4d839 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -4,6 +4,7 @@ import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js"; import { applyAuthChoice, + resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff, } from "../commands/auth-choice.js"; import { buildAuthChoiceOptions } from "../commands/auth-choice-options.js"; @@ -14,6 +15,10 @@ import { } from "../commands/daemon-runtime.js"; import { healthCommand } from "../commands/health.js"; import { formatHealthCheckFailure } from "../commands/health-format.js"; +import { + applyPrimaryModel, + promptDefaultModel, +} from "../commands/model-picker.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, @@ -343,6 +348,17 @@ export async function runOnboardingWizard( }); nextConfig = authResult.config; + const modelSelection = await promptDefaultModel({ + config: nextConfig, + prompter, + allowKeep: true, + ignoreAllowlist: true, + preferredProvider: resolvePreferredProviderForAuthChoice(authChoice), + }); + if (modelSelection.model) { + nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); + } + await warnIfModelConfigLooksOff(nextConfig, prompter); const port =