// models.dev catalog — model metadata (context limits, pricing, capabilities). The cache is seeded // at load from a baked, curated subset (catalog.json — popular providers, generated by // `npm run catalog:refresh`) so pricing/limits work offline; a live prefetch() then overlays the // full models.dev catalog. Reads are synchronous from the in-memory cache. import { readFileSync } from "node:fs"; interface Info { context?: number; output?: number; inputCost?: number; // $ per 1M input tokens outputCost?: number; // $ per 1M output tokens reasoning?: boolean; } interface CatalogModel { name: string; context: number | null; output: number | null; in: number | null; out: number | null; reasoning?: boolean; cacheRead?: number; toolCall?: boolean; } interface Catalog { providers: Record }>; } const cache = new Map(); let fetchedAt = 0; // The baked offline catalog (curated popular providers). Seeds the cache; live prefetch overlays it. const CATALOG: Catalog = (() => { try { return JSON.parse(readFileSync(new URL("./catalog.json", import.meta.url), "utf8")) as Catalog; } catch { return { providers: {} }; } })(); for (const prov of Object.values(CATALOG.providers)) { for (const [id, m] of Object.entries(prov.models)) { cache.set(id, { context: m.context ?? undefined, output: m.output ?? undefined, inputCost: m.in ?? undefined, outputCost: m.out ?? undefined, reasoning: m.reasoning }); } } /** Fetch and cache the models.dev catalog (no-op if fetched within the last hour). */ export async function prefetch(): Promise { if (cache.size && Date.now() - fetchedAt < 3_600_000) return; try { const res = await fetch("https://models.dev/api.json", { signal: AbortSignal.timeout(10_000) }); if (!res.ok) return; const data = (await res.json()) as Record }>; cache.clear(); for (const prov of Object.values(data)) { for (const [id, m] of Object.entries(prov.models ?? {})) { cache.set(id, { context: m.limit?.context, output: m.limit?.output, inputCost: m.cost?.input, outputCost: m.cost?.output, reasoning: m.reasoning }); } } fetchedAt = Date.now(); } catch { /* offline — keep whatever's cached */ } } function lookup(modelId: string): Info | null { return cache.get(modelId) ?? cache.get(modelId.split("/").pop() ?? "") ?? cache.get(modelId.split(":")[0] ?? "") ?? null; } /** [inputCostPer1M, outputCostPer1M] from models.dev, or null. */ export function priceOf(modelId: string): [number, number] | null { const i = lookup(modelId); return i && i.inputCost != null && i.outputCost != null ? [i.inputCost, i.outputCost] : null; } /** Context-window limit (tokens) from models.dev, or null. */ export function contextOf(modelId: string): number | null { return lookup(modelId)?.context ?? null; } export function catalogSize(): number { return cache.size; } /** Human-readable listing of the baked offline catalog. No filter → provider summary; a filter * (provider id/name substring) → that provider's models with context + price. */ export function catalogText(filter?: string): string { const f = filter?.toLowerCase(); const out: string[] = []; for (const [pid, prov] of Object.entries(CATALOG.providers)) { const models = Object.entries(prov.models); if (!f) { out.push(`${pid.padEnd(24)} ${String(models.length).padStart(3)} models \x1b[2m${prov.name}\x1b[0m`); continue; } if (!pid.toLowerCase().includes(f) && !prov.name.toLowerCase().includes(f)) continue; out.push(`\n\x1b[1m${prov.name}\x1b[0m \x1b[2m(${pid})\x1b[0m`); for (const [id, m] of models) { const price = m.in != null && m.out != null ? `$${m.in}/$${m.out}` : "—"; const ctx = m.context ? `${Math.round(m.context / 1000)}k` : "—"; out.push(` ${id.padEnd(40)} ${ctx.padStart(6)} ctx · ${price}/1M${m.reasoning ? " · reasoning" : ""}`); } } if (!out.length) return `no providers match "${filter}". Try /catalog with no argument for the list.`; return f ? out.join("\n") : `${out.join("\n")}\n\x1b[2m/catalog for models · npm run catalog:refresh to update\x1b[0m`; }