mirror of
https://github.com/Nezumi-2711/9router-config-generate.git
synced 2026-09-22 13:38:32 +00:00
204 lines
6.5 KiB
TypeScript
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)
|
|
})
|