diff --git a/src/agents/model-scan.test.ts b/src/agents/model-scan.test.ts index fc2a34a1f..d69445324 100644 --- a/src/agents/model-scan.test.ts +++ b/src/agents/model-scan.test.ts @@ -53,7 +53,11 @@ describe("scanOpenRouterModels", () => { "acme/free-by-suffix:free", ]); - const byPricing = results[0]!; + const [byPricing] = results; + expect(byPricing).toBeTruthy(); + if (!byPricing) { + throw new Error("Expected pricing-based model result."); + } expect(byPricing.supportsToolsMeta).toBe(true); expect(byPricing.supportedParametersCount).toBe(3); expect(byPricing.isFree).toBe(true); diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index 32727997a..a36016ee8 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -79,6 +79,11 @@ export type OpenRouterScanOptions = { maxAgeDays?: number; providerFilter?: string; probe?: boolean; + onProgress?: (update: { + phase: "catalog" | "probe"; + completed: number; + total: number; + }) => void; }; type OpenAIModel = Model<"openai-completions">; @@ -333,10 +338,12 @@ async function mapWithConcurrency( items: T[], concurrency: number, fn: (item: T, index: number) => Promise, + opts?: { onProgress?: (completed: number, total: number) => void }, ): Promise { const limit = Math.max(1, Math.floor(concurrency)); const results = Array.from({ length: items.length }) as R[]; let nextIndex = 0; + let completed = 0; const worker = async () => { while (true) { @@ -344,9 +351,16 @@ async function mapWithConcurrency( nextIndex += 1; if (current >= items.length) return; results[current] = await fn(items[current] as T, current); + completed += 1; + opts?.onProgress?.(completed, items.length); } }; + if (items.length === 0) { + opts?.onProgress?.(0, 0); + return results; + } + await Promise.all( Array.from({ length: Math.min(limit, items.length) }, () => worker()), ); @@ -400,7 +414,16 @@ export async function scanOpenRouterModels( const baseModel = getModel("openrouter", "openrouter/auto") as OpenAIModel; - return mapWithConcurrency(filtered, concurrency, async (entry) => { + options.onProgress?.({ + phase: "probe", + completed: 0, + total: filtered.length, + }); + + return mapWithConcurrency( + filtered, + concurrency, + async (entry) => { const isFree = isFreeOpenRouterModel(entry); if (!probe) { return { @@ -454,7 +477,16 @@ export async function scanOpenRouterModels( tool: toolResult, image: imageResult, } satisfies ModelScanResult; - }); + }, + { + onProgress: (completed, total) => + options.onProgress?.({ + phase: "probe", + completed, + total, + }), + }, + ); } export { OPENROUTER_MODELS_URL }; diff --git a/src/commands/models/scan.ts b/src/commands/models/scan.ts index cd77ad340..14993f40b 100644 --- a/src/commands/models/scan.ts +++ b/src/commands/models/scan.ts @@ -4,6 +4,7 @@ import { type ModelScanResult, scanOpenRouterModels, } from "../../agents/model-scan.js"; +import { withProgress } from "../../cli/progress.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; import { formatMs, formatTokenK, updateConfig } from "./shared.js"; @@ -188,15 +189,30 @@ export async function modelsScanCommand( storedKey = undefined; } } - const results = await scanOpenRouterModels({ - apiKey: storedKey ?? undefined, - minParamB: minParams, - maxAgeDays, - providerFilter: opts.provider, - timeoutMs: timeout, - concurrency, - probe, - }); + const results = await withProgress( + { + label: "Scanning OpenRouter models...", + indeterminate: false, + enabled: opts.json !== true, + }, + async (progress) => + await scanOpenRouterModels({ + apiKey: storedKey ?? undefined, + minParamB: minParams, + maxAgeDays, + providerFilter: opts.provider, + timeoutMs: timeout, + concurrency, + probe, + onProgress: ({ phase, completed, total }) => { + if (phase !== "probe") return; + if (total <= 0) return; + const labelBase = probe ? "Probing models" : "Scanning models"; + progress.setLabel(`${labelBase} (${completed}/${total})`); + progress.setPercent((completed / total) * 100); + }, + }), + ); if (!probe) { if (!opts.json) {