| import { readFile, writeFile } from 'node:fs/promises'; |
| |
| const SOURCE_URL = 'https://models.dev/api.json'; |
| const DEFAULT_OUTPUT = 'packages/core/src/model-metadata.generated.ts'; |
| const PROVIDERS = { |
| anthropic: 'anthropic', |
| alibaba: 'alibaba', |
| 'alibaba-coding-plan-cn': 'alibaba-coding-plan-cn', |
| 'alibaba-coding-plan': 'alibaba-coding-plan', |
| 'alibaba-token-plan-cn': 'alibaba-token-plan-cn', |
| 'alibaba-token-plan': 'alibaba-token-plan', |
| cerebras: 'cerebras', |
| cohere: 'cohere', |
| 'cloudflare-workers-ai': 'cloudflare-workers-ai', |
| deepinfra: 'deepinfra', |
| deepseek: 'deepseek', |
| 'fireworks-ai': 'fireworks-ai', |
| 'github-copilot': 'github-copilot', |
| google: 'google', |
| 'gemini-cli': 'google', |
| groq: 'groq', |
| huggingface: 'huggingface', |
| MiniMax: 'minimax', |
| 'MiniMax-cn': 'minimax-cn', |
| mistral: 'mistral', |
| moonshot: 'moonshotai-cn', |
| nvidia: 'nvidia', |
| 'ollama-cloud': 'ollama-cloud', |
| openai: 'openai', |
| opencode: 'opencode', |
| 'opencode-go': 'opencode-go', |
| openrouter: 'openrouter', |
| siliconflow: 'siliconflow', |
| stepfun: 'stepfun', |
| 'stepfun-ai': 'stepfun-ai', |
| 'stepfun-ai-step-plan': 'stepfun-ai-step-plan', |
| togetherai: 'togetherai', |
| 'tencent-coding-plan': 'tencent-coding-plan', |
| 'tencent-token-plan': 'tencent-token-plan', |
| 'tencent-tokenhub': 'tencent-tokenhub', |
| vercel: 'vercel', |
| xai: 'xai', |
| xiaomi: 'xiaomi', |
| 'xiaomi-token-plan-cn': 'xiaomi-token-plan-cn', |
| 'xiaomi-token-plan-sgp': 'xiaomi-token-plan-sgp', |
| 'xiaomi-token-plan-ams': 'xiaomi-token-plan-ams', |
| zai: 'zai', |
| 'zai-coding-plan': 'zai-coding-plan', |
| zenmux: 'zenmux', |
| }; |
| |
| const inputPath = option('--input'); |
| const outputPath = option('--output') ?? DEFAULT_OUTPUT; |
| const catalog = JSON.parse( |
| inputPath |
| ? await readFile(inputPath, 'utf8') |
| : await fetch(SOURCE_URL, { signal: AbortSignal.timeout(10_000) }).then((response) => { |
| if (!response.ok) throw new Error(`models.dev returned HTTP ${response.status}`); |
| return response.text(); |
| }), |
| ); |
| |
| const generated = {}; |
| const generatedProviders = {}; |
| const generatedModelProviderOverrides = {}; |
| for (const [providerType, sourceId] of Object.entries(PROVIDERS)) { |
| const provider = catalog[sourceId]; |
| if (!provider) { |
| throw new Error(`models.dev provider ${sourceId} is missing`); |
| } |
| if (!provider.models || typeof provider.models !== 'object') { |
| throw new Error(`models.dev provider ${sourceId} has no models object`); |
| } |
| if ( |
| typeof provider.id !== 'string' || |
| typeof provider.name !== 'string' || |
| typeof provider.doc !== 'string' |
| ) { |
| throw new Error(`models.dev provider ${sourceId} has an unsupported shape`); |
| } |
| generatedProviders[providerType] = { |
| id: provider.id, |
| name: provider.name, |
| ...(typeof provider.api === 'string' ? { api: provider.api } : {}), |
| doc: provider.doc, |
| }; |
| generated[providerType] = Object.fromEntries( |
| Object.entries(provider.models) |
| .sort(([left], [right]) => left.localeCompare(right)) |
| .map(([id, model]) => [id, toMetadata(sourceId, id, provider, model)]), |
| ); |
| generatedModelProviderOverrides[providerType] = Object.fromEntries( |
| Object.entries(provider.models) |
| .sort(([left], [right]) => left.localeCompare(right)) |
| .filter(([, model]) => model.provider !== undefined) |
| .map(([id, model]) => [id, toModelProviderOverride(sourceId, id, model.provider)]), |
| ); |
| } |
| |
| function toModelProviderOverride(providerId, modelId, override) { |
| if ( |
| !override || |
| typeof override !== 'object' || |
| typeof override.npm !== 'string' || |
| (override.api !== undefined && typeof override.api !== 'string') |
| ) { |
| throw new Error( |
| `models.dev model ${providerId}/${modelId} has an unsupported provider override`, |
| ); |
| } |
| return { |
| npm: override.npm, |
| ...(override.api ? { api: override.api } : {}), |
| }; |
| } |
| |
| function toMetadata(providerId, modelId, provider, model) { |
| if ( |
| typeof provider.doc !== 'string' || |
| typeof model?.name !== 'string' || |
| (model.modalities !== undefined && !Array.isArray(model.modalities?.input)) || |
| typeof model.limit?.context !== 'number' || |
| typeof model.limit?.output !== 'number' || |
| typeof model.reasoning !== 'boolean' || |
| typeof model.tool_call !== 'boolean' |
| ) { |
| throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`); |
| } |
| return { |
| displayName: model.name, |
| lifecycle: model.status === 'deprecated' ? 'deprecated' : 'active', |
| docsUrl: provider.doc, |
| contextWindow: model.limit?.context, |
| maxOutputTokens: model.limit?.output, |
| capabilities: { |
| ...(model.modalities ? { vision: model.modalities.input.includes('image') } : {}), |
| reasoning: model.reasoning === true, |
| functionCalling: model.tool_call === true, |
| }, |
| ...(model.modalities |
| ? { |
| modalities: { |
| input: model.modalities.input.filter( |
| (value) => value === 'text' || value === 'image' || value === 'audio', |
| ), |
| output: (Array.isArray(model.modalities.output) ? model.modalities.output : []).filter( |
| (value) => value === 'text' || value === 'image' || value === 'audio', |
| ), |
| }, |
| } |
| : {}), |
| }; |
| } |
| |
| const providerTypeUnion = Object.keys(PROVIDERS).map(JSON.stringify).join(' | '); |
| const lines = [ |
| '// Generated by scripts/sync-model-metadata.mjs from https://models.dev/api.json.', |
| '// Do not edit by hand; put access-path-specific facts in model-metadata.ts.', |
| "import type { ModelMetadata } from './model-metadata.js';", |
| '', |
| `export const GENERATED_MODELS_DEV_METADATA: Record<${providerTypeUnion}, Record<string, ModelMetadata>> = {`, |
| ]; |
| for (const [provider, models] of Object.entries(generated)) { |
| lines.push(` ${JSON.stringify(provider)}: {`); |
| for (const [id, metadata] of Object.entries(models)) { |
| lines.push(` ${JSON.stringify(id)}: ${JSON.stringify(metadata)},`); |
| } |
| lines.push(' },'); |
| } |
| lines.push('};', ''); |
| lines.push( |
| `export const GENERATED_MODELS_DEV_MODEL_PROVIDER_OVERRIDES: Record<${providerTypeUnion}, Record<string, { npm: string; api?: string }>> = {`, |
| ); |
| for (const [provider, overrides] of Object.entries(generatedModelProviderOverrides)) { |
| lines.push(` ${JSON.stringify(provider)}: ${JSON.stringify(overrides)},`); |
| } |
| lines.push('};', ''); |
| lines.push( |
| `export const GENERATED_MODELS_DEV_PROVIDER_FACTS: Record<${providerTypeUnion}, { id: string; name: string; api?: string; doc: string }> = {`, |
| ); |
| for (const [provider, facts] of Object.entries(generatedProviders)) { |
| lines.push(` ${JSON.stringify(provider)}: ${JSON.stringify(facts)},`); |
| } |
| lines.push('};', ''); |
| await writeFile(outputPath, lines.join('\n')); |
| |
| function option(name) { |
| const index = process.argv.indexOf(name); |
| if (index === -1) return undefined; |
| const value = process.argv[index + 1]; |
| if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`); |
| return value; |
| } |