#!/usr/bin/env bun /** * Sync script for models.dev catalog snapshot. * Fetches models.json and api.json from https://models.dev, prunes, joins reasoning efforts, * and generates src/data/modelCatalog.generated.ts. */ import { resolve } from 'node:path' const MODELS_DEV_URL = 'https://models.dev/models.json' const API_DEV_URL = 'https://models.dev/api.json' const PROVIDER_ALIASES: Record = { google: ['google', 'google-vertex'], anthropic: ['anthropic', 'google-vertex-anthropic', 'amazon-bedrock'], zhipuai: ['zhipuai', 'zai'], openai: ['openai', 'azure'], } // Allowlist for reasoning effort options, filtering out 'none', 'default', null, etc. // Per plan recommendation: PR 1 ships with ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'] const EFFORT_ALLOWLIST = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const interface RawModel { name?: string limit?: { context?: number output?: number input?: number } } interface RawReasoningOption { type: string values?: (string | null)[] } interface RawProviderModel { reasoning_options?: RawReasoningOption[] } interface RawProvider { models?: Record } async function main() { const isCheckMode = process.argv.includes('--check') console.log('Fetching models.dev catalog...') const [modelsRes, apiRes] = await Promise.all([ fetch(MODELS_DEV_URL), fetch(API_DEV_URL), ]) if (!modelsRes.ok) { throw new Error(`Failed to fetch ${MODELS_DEV_URL}: HTTP ${modelsRes.status}`) } if (!apiRes.ok) { throw new Error(`Failed to fetch ${API_DEV_URL}: HTTP ${apiRes.status}`) } const rawModels = (await modelsRes.json()) as Record const rawApi = (await apiRes.json()) as Record const rawKeys = Object.keys(rawModels) if (rawKeys.length < 100) { throw new Error(`Sanity check failed: models.dev returned only ${rawKeys.length} models (< 100). Aborting.`) } // Build pruned catalog // Keys sorted deterministically const sortedKeys = rawKeys.sort() let usableCount = 0 let effortsCount = 0 let warningCount = 0 interface GeneratedEntry { n: string c: number o?: number e?: string[] } const prunedCatalog: Record = {} for (const fullKey of sortedKeys) { const raw = rawModels[fullKey] const context = raw?.limit?.context // 1. Prune entry with context <= 0 or missing (image/realtime) if (!context || context <= 0) { continue } usableCount++ const entry: GeneratedEntry = { n: raw.name || fullKey, c: context, } // 2. Emit 'o' only when limit.output > 0 if (raw.limit?.output && raw.limit.output > 0) { entry.o = raw.limit.output } // Mitigation check from plan: check if raw.limit.input < context - (entry.o ?? 0) if (raw.limit?.input && entry.o && raw.limit.input < (context - entry.o)) { console.warn(`[warning] ${fullKey}: limit.input (${raw.limit.input}) < context - out (${context - entry.o})`) warningCount++ } // 3. Extract and sanitize reasoning efforts from api.json const [lab, id] = fullKey.split('/', 2) const providersToCheck = PROVIDER_ALIASES[lab] ?? [lab] let foundEfforts: string[] | null = null for (const p of providersToCheck) { const providerData = rawApi[p] if (!providerData?.models) continue const candidate = providerData.models[fullKey] || providerData.models[id] if (candidate?.reasoning_options) { const effortOpt = candidate.reasoning_options.find((opt) => opt.type === 'effort') if (effortOpt?.values && Array.isArray(effortOpt.values)) { // Filter against allowlist preserving order const sanitized = effortOpt.values .filter((v): v is string => typeof v === 'string' && (EFFORT_ALLOWLIST as readonly string[]).includes(v)) if (sanitized.length > 0) { foundEfforts = sanitized break } } } } if (foundEfforts && foundEfforts.length > 0) { entry.e = foundEfforts effortsCount++ } prunedCatalog[fullKey] = entry } // Format generated code deterministically const today = new Date().toISOString().slice(0, 10) const lines: string[] = [] lines.push('// @generated by scripts/sync-models.ts — DO NOT EDIT') lines.push('// Source: https://models.dev/models.json + api.json (MIT)') lines.push("import type { CatalogEntry } from './modelCatalog.ts'") lines.push('') lines.push(`export const CATALOG_GENERATED_AT = '${today}'`) lines.push(`export const CATALOG_SOURCE_COUNT = ${usableCount}`) lines.push('') lines.push('export const MODEL_CATALOG: Record = {') for (const [key, entry] of Object.entries(prunedCatalog)) { // deterministic field order: n, c, o, e const parts: string[] = [] parts.push(`"n":${JSON.stringify(entry.n)}`) parts.push(`"c":${entry.c}`) if (entry.o !== undefined) { parts.push(`"o":${entry.o}`) } if (entry.e && entry.e.length > 0) { parts.push(`"e":${JSON.stringify(entry.e)}`) } lines.push(` ${JSON.stringify(key)}: {${parts.join(',')}},`) } lines.push('}') lines.push('') const generatedContent = lines.join('\n') const targetPath = resolve(import.meta.dir, '../src/data/modelCatalog.generated.ts') if (isCheckMode) { const existingContent = await Bun.file(targetPath).text().catch(() => '') // Compare ignoring lines with CATALOG_GENERATED_AT or Generated const normalize = (s: string) => s .split('\n') .filter((l) => !l.includes('CATALOG_GENERATED_AT') && !l.includes('@generated')) .join('\n') if (normalize(existingContent) !== normalize(generatedContent)) { console.error('Check failed: src/data/modelCatalog.generated.ts is out of sync with models.dev.') process.exit(1) } console.log('Check passed: src/data/modelCatalog.generated.ts is up to date.') return } await Bun.write(targetPath, generatedContent) const byteSize = new TextEncoder().encode(generatedContent).length console.log( `Synced ${usableCount} models, ${effortsCount} with efforts, ${warningCount} warnings, ${byteSize} bytes written to ${targetPath}` ) } main().catch((err) => { console.error(err) process.exit(1) })