import { cancel, multiselect as clackMultiselect, isCancel } from "@clack/prompts"; import type { RuntimeEnv } from "../../runtime.js"; import { resolveApiKeyForProvider } from "../../agents/model-auth.js"; import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-scan.js"; import { withProgressTotals } from "../../cli/progress.js"; import { loadConfig } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; import { stylePromptHint, stylePromptMessage, stylePromptTitle, } from "../../terminal/prompt-style.js"; import { formatMs, formatTokenK, updateConfig } from "./shared.js"; const MODEL_PAD = 42; const CTX_PAD = 8; const multiselect = (params: Parameters>[0]) => clackMultiselect({ ...params, message: stylePromptMessage(params.message), options: params.options.map((opt) => opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) }, ), }); const pad = (value: string, size: number) => value.padEnd(size); const truncate = (value: string, max: number) => { if (value.length <= max) { return value; } if (max <= 3) { return value.slice(0, max); } return `${value.slice(0, max - 3)}...`; }; function sortScanResults(results: ModelScanResult[]): ModelScanResult[] { return results.slice().toSorted((a, b) => { const aImage = a.image.ok ? 1 : 0; const bImage = b.image.ok ? 1 : 0; if (aImage !== bImage) { return bImage - aImage; } const aToolLatency = a.tool.latencyMs ?? Number.POSITIVE_INFINITY; const bToolLatency = b.tool.latencyMs ?? Number.POSITIVE_INFINITY; if (aToolLatency !== bToolLatency) { return aToolLatency - bToolLatency; } const aCtx = a.contextLength ?? 0; const bCtx = b.contextLength ?? 0; if (aCtx !== bCtx) { return bCtx - aCtx; } const aParams = a.inferredParamB ?? 0; const bParams = b.inferredParamB ?? 0; if (aParams !== bParams) { return bParams - aParams; } return a.modelRef.localeCompare(b.modelRef); }); } function sortImageResults(results: ModelScanResult[]): ModelScanResult[] { return results.slice().toSorted((a, b) => { const aLatency = a.image.latencyMs ?? Number.POSITIVE_INFINITY; const bLatency = b.image.latencyMs ?? Number.POSITIVE_INFINITY; if (aLatency !== bLatency) { return aLatency - bLatency; } const aCtx = a.contextLength ?? 0; const bCtx = b.contextLength ?? 0; if (aCtx !== bCtx) { return bCtx - aCtx; } const aParams = a.inferredParamB ?? 0; const bParams = b.inferredParamB ?? 0; if (aParams !== bParams) { return bParams - aParams; } return a.modelRef.localeCompare(b.modelRef); }); } function buildScanHint(result: ModelScanResult): string { const toolLabel = result.tool.ok ? `tool ${formatMs(result.tool.latencyMs)}` : "tool fail"; const imageLabel = result.image.skipped ? "img skip" : result.image.ok ? `img ${formatMs(result.image.latencyMs)}` : "img fail"; const ctxLabel = result.contextLength ? `ctx ${formatTokenK(result.contextLength)}` : "ctx ?"; const paramLabel = result.inferredParamB ? `${result.inferredParamB}b` : null; return [toolLabel, imageLabel, ctxLabel, paramLabel].filter(Boolean).join(" | "); } function printScanSummary(results: ModelScanResult[], runtime: RuntimeEnv) { const toolOk = results.filter((r) => r.tool.ok); const imageOk = results.filter((r) => r.image.ok); const toolImageOk = results.filter((r) => r.tool.ok && r.image.ok); const imageOnly = imageOk.filter((r) => !r.tool.ok); runtime.log( `Scan results: tested ${results.length}, tool ok ${toolOk.length}, image ok ${imageOk.length}, tool+image ok ${toolImageOk.length}, image only ${imageOnly.length}`, ); } function printScanTable(results: ModelScanResult[], runtime: RuntimeEnv) { const header = [ pad("Model", MODEL_PAD), pad("Tool", 10), pad("Image", 10), pad("Ctx", CTX_PAD), pad("Params", 8), "Notes", ].join(" "); runtime.log(header); for (const entry of results) { const modelLabel = pad(truncate(entry.modelRef, MODEL_PAD), MODEL_PAD); const toolLabel = pad(entry.tool.ok ? formatMs(entry.tool.latencyMs) : "fail", 10); const imageLabel = pad( entry.image.ok ? formatMs(entry.image.latencyMs) : entry.image.skipped ? "skip" : "fail", 10, ); const ctxLabel = pad(formatTokenK(entry.contextLength), CTX_PAD); const paramsLabel = pad(entry.inferredParamB ? `${entry.inferredParamB}b` : "-", 8); const notes = entry.modality ? `modality:${entry.modality}` : ""; runtime.log([modelLabel, toolLabel, imageLabel, ctxLabel, paramsLabel, notes].join(" ")); } } export async function modelsScanCommand( opts: { minParams?: string; maxAgeDays?: string; provider?: string; maxCandidates?: string; timeout?: string; concurrency?: string; yes?: boolean; input?: boolean; setDefault?: boolean; setImage?: boolean; json?: boolean; probe?: boolean; }, runtime: RuntimeEnv, ) { const minParams = opts.minParams ? Number(opts.minParams) : undefined; if (minParams !== undefined && (!Number.isFinite(minParams) || minParams < 0)) { throw new Error("--min-params must be >= 0"); } const maxAgeDays = opts.maxAgeDays ? Number(opts.maxAgeDays) : undefined; if (maxAgeDays !== undefined && (!Number.isFinite(maxAgeDays) || maxAgeDays < 0)) { throw new Error("--max-age-days must be >= 0"); } const maxCandidates = opts.maxCandidates ? Number(opts.maxCandidates) : 6; if (!Number.isFinite(maxCandidates) || maxCandidates <= 0) { throw new Error("--max-candidates must be > 0"); } const timeout = opts.timeout ? Number(opts.timeout) : undefined; if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) { throw new Error("--timeout must be > 0"); } const concurrency = opts.concurrency ? Number(opts.concurrency) : undefined; if (concurrency !== undefined && (!Number.isFinite(concurrency) || concurrency <= 0)) { throw new Error("--concurrency must be > 0"); } const cfg = loadConfig(); const probe = opts.probe ?? true; let storedKey: string | undefined; if (probe) { try { const resolved = await resolveApiKeyForProvider({ provider: "openrouter", cfg, }); storedKey = resolved.apiKey; } catch { storedKey = undefined; } } const results = await withProgressTotals( { label: "Scanning OpenRouter models...", indeterminate: false, enabled: opts.json !== true, }, async (update) => await scanOpenRouterModels({ apiKey: storedKey ?? undefined, minParamB: minParams, maxAgeDays, providerFilter: opts.provider, timeoutMs: timeout, concurrency, probe, onProgress: ({ phase, completed, total }) => { if (phase !== "probe") { return; } const labelBase = probe ? "Probing models" : "Scanning models"; update({ completed, total, label: `${labelBase} (${completed}/${total})`, }); }, }), ); if (!probe) { if (!opts.json) { runtime.log( `Found ${results.length} OpenRouter free models (metadata only; pass --probe to test tools/images).`, ); printScanTable(sortScanResults(results), runtime); } else { runtime.log(JSON.stringify(results, null, 2)); } return; } const toolOk = results.filter((entry) => entry.tool.ok); if (toolOk.length === 0) { throw new Error("No tool-capable OpenRouter free models found."); } const sorted = sortScanResults(results); const toolSorted = sortScanResults(toolOk); const imageOk = results.filter((entry) => entry.image.ok); const imageSorted = sortImageResults(imageOk); const imagePreferred = toolSorted.filter((entry) => entry.image.ok); const preselectPool = imagePreferred.length > 0 ? imagePreferred : toolSorted; const preselected = preselectPool .slice(0, Math.floor(maxCandidates)) .map((entry) => entry.modelRef); const imagePreselected = imageSorted .slice(0, Math.floor(maxCandidates)) .map((entry) => entry.modelRef); if (!opts.json) { printScanSummary(results, runtime); printScanTable(sorted, runtime); } const noInput = opts.input === false; const canPrompt = process.stdin.isTTY && !opts.yes && !noInput && !opts.json; let selected: string[] = preselected; let selectedImages: string[] = imagePreselected; if (canPrompt) { const selection = await multiselect({ message: "Select fallback models (ordered)", options: toolSorted.map((entry) => ({ value: entry.modelRef, label: entry.modelRef, hint: buildScanHint(entry), })), initialValues: preselected, }); if (isCancel(selection)) { cancel(stylePromptTitle("Model scan cancelled.") ?? "Model scan cancelled."); runtime.exit(0); } selected = selection; if (imageSorted.length > 0) { const imageSelection = await multiselect({ message: "Select image fallback models (ordered)", options: imageSorted.map((entry) => ({ value: entry.modelRef, label: entry.modelRef, hint: buildScanHint(entry), })), initialValues: imagePreselected, }); if (isCancel(imageSelection)) { cancel(stylePromptTitle("Model scan cancelled.") ?? "Model scan cancelled."); runtime.exit(0); } selectedImages = imageSelection; } } else if (!process.stdin.isTTY && !opts.yes && !noInput && !opts.json) { throw new Error("Non-interactive scan: pass --yes to apply defaults."); } if (selected.length === 0) { throw new Error("No models selected for fallbacks."); } if (opts.setImage && selectedImages.length === 0) { throw new Error("No image-capable models selected for image model."); } const _updated = await updateConfig((cfg) => { const nextModels = { ...cfg.agents?.defaults?.models }; for (const entry of selected) { if (!nextModels[entry]) { nextModels[entry] = {}; } } for (const entry of selectedImages) { if (!nextModels[entry]) { nextModels[entry] = {}; } } const existingImageModel = cfg.agents?.defaults?.imageModel as | { primary?: string; fallbacks?: string[] } | undefined; const nextImageModel = selectedImages.length > 0 ? { ...(existingImageModel?.primary ? { primary: existingImageModel.primary } : undefined), fallbacks: selectedImages, ...(opts.setImage ? { primary: selectedImages[0] } : {}), } : cfg.agents?.defaults?.imageModel; const existingModel = cfg.agents?.defaults?.model as | { primary?: string; fallbacks?: string[] } | undefined; const defaults = { ...cfg.agents?.defaults, model: { ...(existingModel?.primary ? { primary: existingModel.primary } : undefined), fallbacks: selected, ...(opts.setDefault ? { primary: selected[0] } : {}), }, ...(nextImageModel ? { imageModel: nextImageModel } : {}), models: nextModels, } satisfies NonNullable["defaults"]>; return { ...cfg, agents: { ...cfg.agents, defaults, }, }; }); if (opts.json) { runtime.log( JSON.stringify( { selected, selectedImages, setDefault: Boolean(opts.setDefault), setImage: Boolean(opts.setImage), results, warnings: [], }, null, 2, ), ); return; } logConfigUpdated(runtime); runtime.log(`Fallbacks: ${selected.join(", ")}`); if (selectedImages.length > 0) { runtime.log(`Image fallbacks: ${selectedImages.join(", ")}`); } if (opts.setDefault) { runtime.log(`Default model: ${selected[0]}`); } if (opts.setImage && selectedImages.length > 0) { runtime.log(`Image model: ${selectedImages[0]}`); } }