blob: 25d0b20f15db0368893134472415948e3a309b41 [file]
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
const SOURCE_URL = 'https://models.dev/api.json';
const DEFAULT_OUTPUT = 'packages/core/src/model-metadata.generated.ts';
export 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',
'kimi-coding-plan': 'kimi-for-coding',
MiniMax: 'minimax',
'MiniMax-cn': 'minimax-cn',
'minimax-coding-plan': 'minimax-coding-plan',
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',
'stepfun-step-plan': 'stepfun-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',
};
export async function main(argv = process.argv) {
const inputPath = option('--input', argv);
const outputPath = option('--output', argv) ?? 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 = {};
const directory = {};
for (const [sourceId, provider] of Object.entries(catalog)) {
if (typeof provider.id !== 'string') {
throw new Error(`models.dev provider ${sourceId} has an unsupported shape`);
}
directory[provider.id] = {
...(typeof provider.api === 'string' ? { api: provider.api } : {}),
};
}
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)]),
);
}
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('};', '');
lines.push('export const GENERATED_MODELS_DEV_DIRECTORY: Record<string, { api?: string }> = {');
for (const [id, facts] of Object.entries(directory).sort(([left], [right]) =>
left.localeCompare(right),
)) {
lines.push(` ${JSON.stringify(id)}: ${JSON.stringify(facts)},`);
}
lines.push('};', '');
await writeFile(outputPath, lines.join('\n'));
}
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 } : {}),
};
}
export 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`);
}
const reasoningOptions = model.reasoning_options ?? [];
if (!Array.isArray(reasoningOptions)) {
throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`);
}
let efforts;
let toggle = false;
for (const entry of reasoningOptions) {
if (entry?.type === 'effort') {
if (!Array.isArray(entry.values) || entry.values.some((value) => typeof value !== 'string')) {
throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`);
}
efforts = entry.values;
} else if (entry?.type === 'toggle') {
toggle = true;
} else if (entry?.type !== 'budget_tokens') {
// budget_tokens is a known models.dev option type with no wire consumer
// yet; any other unknown type fails loudly so a models.dev schema change
// is a conscious decision, not silent drift.
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,
},
...(efforts?.length || toggle
? {
thinkingOptions: {
...(efforts?.length ? { efforts } : {}),
...(toggle ? { toggle: 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',
),
},
}
: {}),
};
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}
function option(name, argv) {
const index = argv.indexOf(name);
if (index === -1) return undefined;
const value = argv[index + 1];
if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`);
return value;
}