Files
9router-config-generate/scripts/sync-models.ts
T

204 lines
6.5 KiB
TypeScript

#!/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<string, string[]> = {
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<string, RawProviderModel>
}
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<string, RawModel>
const rawApi = (await apiRes.json()) as Record<string, RawProvider>
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<string, GeneratedEntry> = {}
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<string, CatalogEntry> = {')
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)
})