blob: 43b6e4636f3f8f02c4eb53b873e19d35bb527fdd [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';
const DEFAULT_PRICING_OUTPUT = 'packages/runtime/src/telemetry/model-pricing.generated.ts';
// models.dev cost fields describe the catalog provider's public API. They are
// not automatically the rate a user pays on an OAuth, free, subscription, or
// plan access path. Such paths stay unpriced unless builtin-pricing.ts carries
// an explicit rate for that exact path.
export const PRICING_EXCLUDED_PROVIDER_TYPES = new Set([
'alibaba-coding-plan-cn',
'alibaba-coding-plan',
'alibaba-token-plan-cn',
'alibaba-token-plan',
'github-copilot',
'gemini-cli',
'kimi-coding-plan',
'minimax-coding-plan',
'MiniMax-cn',
'opencode-free',
'opencode-go',
'stepfun-ai-step-plan',
'stepfun-step-plan',
'tencent-coding-plan',
'tencent-token-plan',
'volcengine-agent-plan',
'volcengine-coding-plan',
'xiaomi-token-plan-ams',
'xiaomi-token-plan-cn',
'xiaomi-token-plan-sgp',
'zai-coding-plan',
]);
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 pricingOutputPath =
option('--pricing-output', argv) ??
(outputPath === DEFAULT_OUTPUT ? DEFAULT_PRICING_OUTPUT : undefined);
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 generatedPricing = [];
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)]),
);
if (!PRICING_EXCLUDED_PROVIDER_TYPES.has(providerType)) {
generatedPricing.push(
...Object.entries(provider.models)
.sort(([left], [right]) => left.localeCompare(right))
.map(([id, model]) => toPricing(providerType, id, model))
.filter((pricing) => pricing !== undefined),
);
}
}
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'));
if (pricingOutputPath) {
await writeFile(pricingOutputPath, buildPricingModule(generatedPricing));
}
}
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)) ||
(model.modalities !== undefined && !Array.isArray(model.modalities?.output)) ||
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`);
}
if (
(model.description !== undefined && typeof model.description !== 'string') ||
(model.knowledge !== undefined && typeof model.knowledge !== 'string') ||
(model.limit?.input !== undefined &&
(typeof model.limit.input !== 'number' || !Number.isFinite(model.limit.input))) ||
(model.structured_output !== undefined && typeof model.structured_output !== 'boolean') ||
(model.last_updated !== undefined && typeof model.last_updated !== 'string')
) {
throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`);
}
const lifecycle = lifecycleForStatus(providerId, modelId, model.status);
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,
...(model.description !== undefined ? { description: model.description } : {}),
lifecycle,
docsUrl: provider.doc,
contextWindow: model.limit?.context,
...(model.limit?.input !== undefined ? { inputLimit: model.limit.input } : {}),
maxOutputTokens: model.limit?.output,
...(model.knowledge !== undefined ? { knowledgeCutoff: model.knowledge } : {}),
...(model.structured_output !== undefined ? { structuredOutput: model.structured_output } : {}),
...(model.last_updated !== undefined ? { lastUpdated: model.last_updated } : {}),
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' || value === 'pdf',
),
output: (Array.isArray(model.modalities.output) ? model.modalities.output : []).filter(
(value) => value === 'text' || value === 'image' || value === 'audio',
),
},
}
: {}),
};
}
export function toPricing(providerType, modelId, model) {
const cost = model?.cost;
if (cost === undefined) return undefined;
if (!cost || typeof cost !== 'object' || Array.isArray(cost)) {
throw new Error(`models.dev model ${providerType}/${modelId} has an unsupported cost shape`);
}
// PricingConfig and computeCost() currently represent one flat rate. Do
// not publish a base rate for a model whose actual price depends on input
// volume or an explicit tier table; that would systematically undercharge
// long-context requests until the runtime can select a tier by usage.
if (
Object.prototype.hasOwnProperty.call(cost, 'context_over_200k') ||
Object.prototype.hasOwnProperty.call(cost, 'tiers')
) {
return undefined;
}
const inputUsdPer1M = priceNumber(providerType, modelId, cost.input, 'input');
const outputUsdPer1M = priceNumber(providerType, modelId, cost.output, 'output');
const cacheReadUsdPer1M = optionalPriceNumber(
providerType,
modelId,
cost.cache_read,
'cache_read',
);
const cacheWriteUsdPer1M = optionalPriceNumber(
providerType,
modelId,
cost.cache_write,
'cache_write',
);
return {
modelKey: `${providerType}:${modelId}`,
inputUsdPer1M,
outputUsdPer1M,
...(cacheReadUsdPer1M !== undefined ? { cacheReadUsdPer1M } : {}),
...(cacheWriteUsdPer1M !== undefined ? { cacheWriteUsdPer1M } : {}),
};
}
function lifecycleForStatus(providerId, modelId, status) {
if (status === undefined) return 'active';
if (status === 'active' || status === 'beta' || status === 'alpha' || status === 'deprecated') {
return status;
}
throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported status`);
}
function priceNumber(providerType, modelId, value, field) {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw new Error(`models.dev model ${providerType}/${modelId} has an unsupported cost.${field}`);
}
return value;
}
function optionalPriceNumber(providerType, modelId, value, field) {
return value === undefined ? undefined : priceNumber(providerType, modelId, value, field);
}
function buildPricingModule(pricing) {
const lines = [
'// Generated by scripts/sync-model-metadata.mjs from https://models.dev/api.json.',
'// Do not edit by hand; special access-path pricing belongs in builtin-pricing.ts.',
"import type { PricingConfig } from '@maka/core';",
'',
'export const GENERATED_MODEL_PRICING: readonly PricingConfig[] = [',
];
for (const entry of pricing) lines.push(` ${JSON.stringify(entry)},`);
lines.push('];', '');
return lines.join('\n');
}
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;
}