mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-10 01:28:25 +00:00
1067 lines
39 KiB
JavaScript
1067 lines
39 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = path.resolve(__dirname, "..");
|
|
const outputPath = path.join(projectRoot, "models.json");
|
|
|
|
const sources = {
|
|
litellm: {
|
|
id: "litellm",
|
|
name: "LiteLLM model prices and context window",
|
|
url: "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
|
|
},
|
|
modelsDev: {
|
|
id: "models.dev",
|
|
name: "models.dev API",
|
|
url: "https://models.dev/api.json"
|
|
},
|
|
openrouter: {
|
|
id: "openrouter",
|
|
name: "OpenRouter models API",
|
|
url: "https://openrouter.ai/api/v1/models"
|
|
}
|
|
};
|
|
|
|
const sourceOrder = ["models.dev", "litellm", "openrouter"];
|
|
const support1MContextThreshold = 1_000_000;
|
|
const schemaVersion = 2;
|
|
|
|
const firstPartyProviderAliases = new Map(Object.entries({
|
|
ai21: "ai21",
|
|
alibaba: "alibaba",
|
|
anthropic: "anthropic",
|
|
amazon: "amazon",
|
|
bedrock: "amazon",
|
|
cohere: "cohere",
|
|
deepseek: "deepseek",
|
|
"deepseek-ai": "deepseek",
|
|
gemini: "google",
|
|
google: "google",
|
|
"google-vertex": "google",
|
|
llama: "meta-llama",
|
|
meta: "meta-llama",
|
|
"meta-llama": "meta-llama",
|
|
"meta-llama3": "meta-llama",
|
|
mistral: "mistral",
|
|
mistralai: "mistral",
|
|
minimax: "minimax",
|
|
minimaxai: "minimax",
|
|
moonshot: "moonshotai",
|
|
moonshotai: "moonshotai",
|
|
openai: "openai",
|
|
perplexity: "perplexity",
|
|
qwen: "alibaba",
|
|
xai: "x-ai",
|
|
"x-ai": "x-ai",
|
|
zai: "z-ai",
|
|
"z-ai": "z-ai",
|
|
"zai-org": "z-ai",
|
|
zhipuai: "z-ai"
|
|
}));
|
|
|
|
const providerHints = [
|
|
[/^(chatgpt|codex|dall-e|gpt-|gpt_|o[1345](?:-|$)|omni-|text-embedding-|tts-|whisper-)/i, "openai"],
|
|
[/^claude-/i, "anthropic"],
|
|
[/^(gemini-|imagen-|veo-)/i, "google"],
|
|
[/^grok-/i, "x-ai"],
|
|
[/^deepseek[-_]/i, "deepseek"],
|
|
[/^kimi[-_]/i, "moonshotai"],
|
|
[/^(glm-|charglm-|codegeex-|cogview-)/i, "z-ai"],
|
|
[/^(qwen|qwq|wanx|wan[-_])/i, "alibaba"],
|
|
[/^(llama-|codellama|meta-llama)/i, "meta-llama"],
|
|
[/^(mistral|mixtral|codestral|devstral|magistral|ministral|pixtral|voxtral)/i, "mistral"],
|
|
[/^(minimax|abab)/i, "minimax"],
|
|
[/^command[-_]/i, "cohere"],
|
|
[/^sonar(?:-|$)/i, "perplexity"],
|
|
[/^(nova-|titan-|amazon\\.)/i, "amazon"]
|
|
];
|
|
|
|
async function main() {
|
|
const [modelsDevPayload, liteLlmPayload, openRouterPayload] = await Promise.all([
|
|
fetchJson(sources.modelsDev.url),
|
|
fetchJson(sources.litellm.url),
|
|
fetchJson(sources.openrouter.url)
|
|
]);
|
|
|
|
const entries = new Map();
|
|
ingestModelsDev(entries, modelsDevPayload);
|
|
ingestLiteLlm(entries, liteLlmPayload);
|
|
ingestOpenRouter(entries, openRouterPayload);
|
|
|
|
const providerModelRecords = Array.from(entries.values())
|
|
.map(finalizeEntry)
|
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
const models = dedupeModels(providerModelRecords);
|
|
|
|
const payload = {
|
|
schemaVersion,
|
|
generatedAt: new Date().toISOString(),
|
|
generatedBy: "scripts/generate-models-json.mjs",
|
|
sources: Object.values(sources).map(({ id, name, url }) => ({ id, name, url })),
|
|
summary: buildSummary(models, providerModelRecords.length),
|
|
models
|
|
};
|
|
|
|
await writeFile(outputPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
console.log(`Wrote ${models.length} model records to ${path.relative(projectRoot, outputPath)}`);
|
|
console.log(`Merged ${providerModelRecords.length - models.length} duplicate provider/model records`);
|
|
console.log(`Providers: ${payload.summary.providerCount}`);
|
|
console.log(`Models with >=1M context: ${payload.summary.modelsWith1MContext}`);
|
|
console.log(`Models with image input: ${payload.summary.modelsWithImageInput}`);
|
|
console.log(`Models with image output/generation: ${payload.summary.modelsWithImageOutput}`);
|
|
}
|
|
|
|
async function fetchJson(url) {
|
|
const response = await fetch(url, { headers: { accept: "application/json" } });
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function ingestModelsDev(entries, payload) {
|
|
if (!isRecord(payload)) return;
|
|
|
|
for (const [providerId, provider] of Object.entries(payload)) {
|
|
if (!isRecord(provider) || !isRecord(provider.models)) continue;
|
|
|
|
for (const [modelKey, model] of Object.entries(provider.models)) {
|
|
if (!isRecord(model)) continue;
|
|
|
|
const modelId = readString(model.id) || modelKey;
|
|
const entry = ensureEntry(entries, providerId, modelId);
|
|
const modalities = normalizeModalities(model.modalities);
|
|
const limits = compactObject({
|
|
contextTokens: readNumber(model.limit?.context),
|
|
inputTokens: readNumber(model.limit?.input),
|
|
outputTokens: readNumber(model.limit?.output)
|
|
});
|
|
const capabilities = compactObject({
|
|
attachments: readBoolean(model.attachment),
|
|
audioInput: modalities.input.includes("audio"),
|
|
audioOutput: modalities.output.includes("audio"),
|
|
imageInput: modalities.input.includes("image"),
|
|
imageOutput: modalities.output.includes("image"),
|
|
interleaved: readBoolean(model.interleaved),
|
|
openWeights: readBoolean(model.open_weights),
|
|
pdfInput: modalities.input.includes("pdf"),
|
|
reasoning: readBoolean(model.reasoning),
|
|
structuredOutput: readBoolean(model.structured_output),
|
|
temperature: readBoolean(model.temperature),
|
|
toolCalling: readBoolean(model.tool_call),
|
|
videoInput: modalities.input.includes("video")
|
|
});
|
|
|
|
entry.sourceRecords.push(compactObject({
|
|
source: "models.dev",
|
|
sourceUrl: sources.modelsDev.url,
|
|
provider: providerId,
|
|
providerName: readString(provider.name),
|
|
providerApi: readString(provider.api),
|
|
providerDoc: readString(provider.doc),
|
|
model: modelId,
|
|
modelKey,
|
|
displayName: readString(model.name),
|
|
family: readString(model.family),
|
|
status: readString(model.status),
|
|
metadata: compactObject({
|
|
experimental: readBoolean(model.experimental),
|
|
knowledgeCutoff: readString(model.knowledge),
|
|
lastUpdated: readString(model.last_updated),
|
|
openWeights: readBoolean(model.open_weights),
|
|
releaseDate: readString(model.release_date),
|
|
reasoningOptions: model.reasoning_options
|
|
}),
|
|
limits,
|
|
modalities,
|
|
capabilities,
|
|
pricing: pricingFromModelsDev(model.cost)
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
function ingestLiteLlm(entries, payload) {
|
|
if (!isRecord(payload)) return;
|
|
|
|
for (const [modelId, model] of Object.entries(payload)) {
|
|
if (modelId === "sample_spec" || !isRecord(model)) continue;
|
|
|
|
const provider = readString(model.litellm_provider) || "unknown";
|
|
const entry = ensureEntry(entries, provider, modelId);
|
|
const mode = readString(model.mode);
|
|
const modalities = inferLiteLlmModalities(model, mode);
|
|
const limits = compactObject({
|
|
contextTokens: readNumber(model.max_input_tokens) ?? readNumber(model.max_tokens),
|
|
inputTokens: readNumber(model.max_input_tokens),
|
|
outputTokens: readNumber(model.max_output_tokens) ?? readNumber(model.max_tokens),
|
|
maxTokens: readNumber(model.max_tokens),
|
|
maxAudioLengthHours: readNumber(model.max_audio_length_hours),
|
|
maxAudioPerPrompt: readNumber(model.max_audio_per_prompt),
|
|
maxDocumentChunksPerQuery: readNumber(model.max_document_chunks_per_query),
|
|
maxImagesPerPrompt: readNumber(model.max_images_per_prompt),
|
|
maxPdfSizeMB: readNumber(model.max_pdf_size_mb),
|
|
maxQueryTokens: readNumber(model.max_query_tokens),
|
|
maxTokensPerDocumentChunk: readNumber(model.max_tokens_per_document_chunk),
|
|
maxVideoLength: readNumber(model.max_video_length),
|
|
maxVideosPerPrompt: readNumber(model.max_videos_per_prompt),
|
|
outputVectorSize: readNumber(model.output_vector_size)
|
|
});
|
|
|
|
entry.sourceRecords.push(compactObject({
|
|
source: "litellm",
|
|
sourceUrl: sources.litellm.url,
|
|
provider,
|
|
model: modelId,
|
|
mode,
|
|
metadata: compactObject({
|
|
comment: readString(model.comment),
|
|
deprecationDate: readString(model.deprecation_date),
|
|
metadata: isRecord(model.metadata) ? model.metadata : undefined,
|
|
providerSpecificEntry: model.provider_specific_entry,
|
|
source: readString(model.source),
|
|
supportedEndpoints: readStringArray(model.supported_endpoints),
|
|
supportedRegions: readStringArray(model.supported_regions)
|
|
}),
|
|
limits,
|
|
modalities,
|
|
capabilities: capabilitiesFromLiteLlm(model, mode, modalities),
|
|
pricing: pricingFromLiteLlm(model)
|
|
}));
|
|
}
|
|
}
|
|
|
|
function ingestOpenRouter(entries, payload) {
|
|
if (!isRecord(payload) || !Array.isArray(payload.data)) return;
|
|
|
|
for (const model of payload.data) {
|
|
if (!isRecord(model)) continue;
|
|
|
|
const modelId = readString(model.id) || readString(model.canonical_slug);
|
|
if (!modelId) continue;
|
|
|
|
const provider = "openrouter";
|
|
const entry = ensureEntry(entries, provider, modelId);
|
|
const modalities = normalizeModalities({
|
|
input: model.architecture?.input_modalities,
|
|
output: model.architecture?.output_modalities
|
|
});
|
|
const supportedParameters = readStringArray(model.supported_parameters);
|
|
const limits = compactObject({
|
|
contextTokens: readNumber(model.context_length) ?? readNumber(model.top_provider?.context_length),
|
|
outputTokens: readNumber(model.top_provider?.max_completion_tokens)
|
|
});
|
|
|
|
entry.sourceRecords.push(compactObject({
|
|
source: "openrouter",
|
|
sourceUrl: sources.openrouter.url,
|
|
provider,
|
|
model: modelId,
|
|
displayName: readString(model.name),
|
|
metadata: compactObject({
|
|
canonicalSlug: readString(model.canonical_slug),
|
|
createdAt: epochSecondsToIso(model.created),
|
|
expirationDate: readString(model.expiration_date),
|
|
huggingFaceId: readString(model.hugging_face_id),
|
|
instructType: readString(model.architecture?.instruct_type),
|
|
knowledgeCutoff: readString(model.knowledge_cutoff),
|
|
links: isRecord(model.links) ? model.links : undefined,
|
|
perRequestLimits: model.per_request_limits,
|
|
reasoning: isRecord(model.reasoning) ? model.reasoning : undefined,
|
|
supportedParameters,
|
|
supportedVoices: model.supported_voices,
|
|
tokenizer: readString(model.architecture?.tokenizer),
|
|
topProvider: isRecord(model.top_provider) ? model.top_provider : undefined
|
|
}),
|
|
limits,
|
|
modalities,
|
|
capabilities: compactObject({
|
|
audioInput: modalities.input.includes("audio"),
|
|
audioOutput: modalities.output.includes("audio"),
|
|
imageInput: modalities.input.includes("image"),
|
|
imageOutput: modalities.output.includes("image"),
|
|
parallelFunctionCalling: supportedParameters.includes("parallel_tool_calls"),
|
|
reasoning: supportedParameters.includes("reasoning") ||
|
|
supportedParameters.includes("include_reasoning") ||
|
|
isRecord(model.reasoning),
|
|
responseSchema: supportedParameters.includes("response_format"),
|
|
structuredOutput: supportedParameters.includes("structured_outputs"),
|
|
temperature: supportedParameters.includes("temperature"),
|
|
toolCalling: supportedParameters.includes("tools"),
|
|
toolChoice: supportedParameters.includes("tool_choice"),
|
|
webSearch: supportedParameters.includes("web_search_options"),
|
|
videoInput: modalities.input.includes("video")
|
|
}),
|
|
pricing: pricingFromOpenRouter(model.pricing)
|
|
}));
|
|
}
|
|
}
|
|
|
|
function ensureEntry(entries, provider, model) {
|
|
const entryId = composeEntryId(provider, model);
|
|
const key = normalizeEntryKey(entryId);
|
|
const existing = entries.get(key);
|
|
if (existing) return existing;
|
|
|
|
const entry = {
|
|
id: entryId,
|
|
provider,
|
|
model,
|
|
sourceRecords: []
|
|
};
|
|
entries.set(key, entry);
|
|
return entry;
|
|
}
|
|
|
|
function finalizeEntry(entry) {
|
|
const records = entry.sourceRecords.sort(compareSourceRecords);
|
|
const displayName = firstDefined(records.map((record) => record.displayName));
|
|
const family = firstDefined(records.map((record) => record.family));
|
|
const mode = firstDefined(records.map((record) => record.mode));
|
|
const limits = mergeLimits(records.map((record) => record.limits));
|
|
const modalities = mergeModalities(records.map((record) => record.modalities));
|
|
const capabilities = mergeCapabilities(records.map((record) => record.capabilities), modalities, limits, mode);
|
|
const pricingOffers = records
|
|
.filter((record) => isRecord(record.pricing) && Object.keys(record.pricing).length > 0)
|
|
.map((record) => compactObject({
|
|
source: record.source,
|
|
provider: record.provider,
|
|
model: record.model,
|
|
sourceUrl: record.sourceUrl,
|
|
...record.pricing
|
|
}));
|
|
|
|
return compactObject({
|
|
id: entry.id,
|
|
provider: entry.provider,
|
|
model: entry.model,
|
|
displayName,
|
|
family,
|
|
mode,
|
|
sources: uniqueStrings(records.map((record) => record.source)),
|
|
limits,
|
|
modalities,
|
|
capabilities,
|
|
pricing: pricingOffers.length > 0 ? {
|
|
currency: "USD",
|
|
normalizedUnit: "per1MTokens values are USD per 1,000,000 tokens; non-token values keep the unit named by their object key.",
|
|
offers: pricingOffers
|
|
} : undefined,
|
|
metadata: mergeMetadata(records),
|
|
sourceRecords: records.map((record) => omit(record, ["pricing", "limits", "modalities", "capabilities"]))
|
|
});
|
|
}
|
|
|
|
function dedupeModels(providerModelRecords) {
|
|
const groups = new Map();
|
|
for (const record of providerModelRecords) {
|
|
const identity = canonicalIdentityForRecord(record);
|
|
const existing = groups.get(identity.key);
|
|
if (existing) {
|
|
existing.records.push(record);
|
|
} else {
|
|
groups.set(identity.key, { identity, records: [record] });
|
|
}
|
|
}
|
|
|
|
return Array.from(groups.values())
|
|
.map(({ identity, records }) => mergeDedupedModel(identity, records))
|
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
}
|
|
|
|
function mergeDedupedModel(identity, records) {
|
|
const sortedRecords = records.slice().sort((a, b) => providerModelRecordScore(a, identity) - providerModelRecordScore(b, identity));
|
|
const representative = sortedRecords[0];
|
|
const sourceRecords = dedupeSourceRecords(sortedRecords.flatMap((record) => record.sourceRecords ?? []))
|
|
.sort(compareSourceRecords);
|
|
const pricingOffers = dedupePricingOffers(sortedRecords.flatMap((record) => record.pricing?.offers ?? []));
|
|
const limits = mergeLimits(sortedRecords.map((record) => record.limits));
|
|
const modalities = mergeModalities(sortedRecords.map((record) => record.modalities));
|
|
const mode = firstDefined(sortedRecords.map((record) => record.mode));
|
|
const capabilities = mergeCapabilities(sortedRecords.map((record) => record.capabilities), modalities, limits, mode);
|
|
const metadata = mergeMetadata(sourceRecords);
|
|
|
|
return compactObject({
|
|
id: identity.id,
|
|
provider: identity.provider,
|
|
model: identity.model,
|
|
displayName: firstDefined(sortedRecords.map((record) => record.displayName)) || representative.displayName,
|
|
family: firstDefined(sortedRecords.map((record) => record.family)),
|
|
mode,
|
|
sources: uniqueStrings(sortedRecords.flatMap((record) => record.sources ?? [])),
|
|
providers: uniqueStrings([
|
|
...sortedRecords.map((record) => record.provider),
|
|
...sourceRecords.map((record) => record.provider),
|
|
...pricingOffers.map((offer) => offer.provider)
|
|
]),
|
|
aliases: uniqueStrings([
|
|
...sortedRecords.map((record) => record.id),
|
|
...sortedRecords.map((record) => composeEntryId(record.provider, record.model)),
|
|
...sourceRecords.map((record) => composeEntryId(record.provider, record.model))
|
|
]),
|
|
mergedProviderModelRecords: sortedRecords.length,
|
|
limits,
|
|
modalities,
|
|
capabilities,
|
|
pricing: pricingOffers.length > 0 ? {
|
|
currency: "USD",
|
|
normalizedUnit: "per1MTokens values are USD per 1,000,000 tokens; non-token values keep the unit named by their object key.",
|
|
offers: pricingOffers
|
|
} : undefined,
|
|
metadata: compactObject({
|
|
...metadata,
|
|
providerModelRecordCount: sortedRecords.length
|
|
}),
|
|
sourceRecords
|
|
});
|
|
}
|
|
|
|
function canonicalIdentityForRecord(record) {
|
|
const segments = modelPathSegments(record.model || record.id);
|
|
const model = canonicalModelSlug(segments.at(-1) || record.model || record.id);
|
|
const providerFromPath = firstDefined(segments.map(canonicalKnownProvider));
|
|
const providerFromName = inferProviderFromModelName(model, record.displayName, record.family);
|
|
const providerFromRecord = canonicalProviderToken(record.provider);
|
|
const provider = providerFromPath || providerFromName || providerFromRecord || "unknown";
|
|
const id = `${provider}/${model}`;
|
|
return {
|
|
id,
|
|
key: normalizeEntryKey(id),
|
|
model,
|
|
provider
|
|
};
|
|
}
|
|
|
|
function providerModelRecordScore(record, identity) {
|
|
let score = 0;
|
|
if (record.provider !== identity.provider) score += 20;
|
|
if (canonicalModelSlug(modelPathSegments(record.model).at(-1) || record.model) !== identity.model) score += 10;
|
|
if (!record.pricing?.offers?.length) score += 5;
|
|
if (!record.sources?.includes("models.dev")) score += 2;
|
|
if (!record.sources?.includes("litellm")) score += 1;
|
|
return score;
|
|
}
|
|
|
|
function dedupeSourceRecords(records) {
|
|
const seen = new Set();
|
|
const output = [];
|
|
for (const record of records) {
|
|
const key = JSON.stringify([
|
|
record.source,
|
|
record.provider,
|
|
record.model,
|
|
record.modelKey,
|
|
record.displayName,
|
|
record.family,
|
|
record.mode,
|
|
record.status
|
|
]);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
output.push(record);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function dedupePricingOffers(offers) {
|
|
const seen = new Set();
|
|
const output = [];
|
|
for (const offer of offers) {
|
|
const key = JSON.stringify(offer);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
output.push(offer);
|
|
}
|
|
return output.sort((a, b) => {
|
|
const sourceDiff = sourceOrder.indexOf(a.source) - sourceOrder.indexOf(b.source);
|
|
if (sourceDiff !== 0) return sourceDiff;
|
|
return `${a.provider}/${a.model}`.localeCompare(`${b.provider}/${b.model}`);
|
|
});
|
|
}
|
|
|
|
function modelPathSegments(value) {
|
|
return String(value || "")
|
|
.split("/")
|
|
.map((segment) => segment.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function canonicalKnownProvider(value) {
|
|
const normalized = normalizeProviderToken(value);
|
|
return firstPartyProviderAliases.get(normalized);
|
|
}
|
|
|
|
function canonicalProviderToken(value) {
|
|
const normalized = normalizeProviderToken(value);
|
|
return firstPartyProviderAliases.get(normalized) || normalized || undefined;
|
|
}
|
|
|
|
function normalizeProviderToken(value) {
|
|
return String(value || "")
|
|
.trim()
|
|
.replace(/^hf:/i, "")
|
|
.replace(/^@/, "")
|
|
.replace(/_/g, "-")
|
|
.replace(/\s+/g, "-")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function canonicalModelSlug(value) {
|
|
return String(value || "unknown")
|
|
.trim()
|
|
.replace(/^hf:/i, "")
|
|
.replace(/_/g, "-")
|
|
.replace(/\s+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function inferProviderFromModelName(...values) {
|
|
const haystack = values
|
|
.filter((value) => typeof value === "string" && value.trim())
|
|
.join(" ")
|
|
.trim();
|
|
for (const [pattern, provider] of providerHints) {
|
|
if (pattern.test(haystack)) return provider;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function compareSourceRecords(a, b) {
|
|
const sourceDiff = sourceOrder.indexOf(a.source) - sourceOrder.indexOf(b.source);
|
|
if (sourceDiff !== 0) return sourceDiff;
|
|
return `${a.provider}/${a.model}`.localeCompare(`${b.provider}/${b.model}`);
|
|
}
|
|
|
|
function buildSummary(models, rawProviderModelCount) {
|
|
const providerCount = new Set(models.map((model) => model.provider)).size;
|
|
const availabilityProviderCount = new Set(models.flatMap((model) => model.providers ?? [model.provider])).size;
|
|
return {
|
|
modelCount: models.length,
|
|
rawProviderModelCount,
|
|
duplicateProviderModelRecordsMerged: rawProviderModelCount - models.length,
|
|
providerCount,
|
|
availabilityProviderCount,
|
|
sourceCounts: Object.fromEntries(
|
|
sourceOrder.map((source) => [
|
|
source,
|
|
models.reduce((count, model) => count + (model.sources.includes(source) ? 1 : 0), 0)
|
|
])
|
|
),
|
|
pricingOfferCount: models.reduce((count, model) => count + (model.pricing?.offers?.length ?? 0), 0),
|
|
modelsWithPricing: models.filter((model) => model.pricing?.offers?.length > 0).length,
|
|
modelsWithoutPricing: models.filter((model) => !model.pricing?.offers?.length).length,
|
|
modelsWith1MContext: models.filter((model) => model.limits?.supports1MContext).length,
|
|
modelsWithImageInput: models.filter((model) => model.capabilities?.imageInput).length,
|
|
modelsWithImageOutput: models.filter((model) => model.capabilities?.imageOutput || model.capabilities?.imageGeneration).length,
|
|
modelsWithAudioInput: models.filter((model) => model.capabilities?.audioInput).length,
|
|
modelsWithToolCalling: models.filter((model) => model.capabilities?.toolCalling || model.capabilities?.functionCalling).length,
|
|
modelsWithReasoning: models.filter((model) => model.capabilities?.reasoning).length
|
|
};
|
|
}
|
|
|
|
function pricingFromModelsDev(cost) {
|
|
if (!isRecord(cost)) return undefined;
|
|
|
|
const per1MTokens = compactObject({
|
|
cacheRead: readNumber(cost.cache_read),
|
|
cacheWrite: readNumber(cost.cache_write),
|
|
input: readNumber(cost.input),
|
|
inputAudio: readNumber(cost.input_audio),
|
|
output: readNumber(cost.output),
|
|
outputAudio: readNumber(cost.output_audio),
|
|
reasoningOutput: readNumber(cost.reasoning)
|
|
});
|
|
|
|
const known = new Set(["cache_read", "cache_write", "input", "input_audio", "output", "output_audio", "reasoning", "tiers", "context_over_200k"]);
|
|
const extra = numericObjectExcept(cost, known);
|
|
|
|
return compactObject({
|
|
sourceUnit: "usd_per_1m_tokens",
|
|
per1MTokens,
|
|
tiered: compactObject({
|
|
contextOver200K: normalizeNestedNumbers(cost.context_over_200k),
|
|
tiers: normalizeNestedNumbers(cost.tiers)
|
|
}),
|
|
extra
|
|
});
|
|
}
|
|
|
|
function pricingFromLiteLlm(model) {
|
|
const consumed = new Set();
|
|
const per1MTokens = compactObject({
|
|
cacheRead: per1MFromPerToken(firstNumber(model, consumed, [
|
|
"cache_read_input_token_cost",
|
|
"input_cache_read_cost_per_token",
|
|
"cache_read_cost_per_token"
|
|
])),
|
|
cacheWrite: per1MFromPerToken(firstNumber(model, consumed, [
|
|
"cache_creation_input_token_cost",
|
|
"input_cache_write_cost_per_token",
|
|
"cache_write_cost_per_token"
|
|
])),
|
|
input: per1MFromPerToken(firstNumber(model, consumed, [
|
|
"input_cost_per_token",
|
|
"prompt_cost_per_token"
|
|
])),
|
|
inputAudio: per1MFromPerToken(firstNumber(model, consumed, ["input_cost_per_audio_token"])),
|
|
inputImage: per1MFromPerToken(firstNumber(model, consumed, ["input_cost_per_image_token"])),
|
|
output: per1MFromPerToken(firstNumber(model, consumed, [
|
|
"output_cost_per_token",
|
|
"completion_cost_per_token"
|
|
])),
|
|
outputAudio: per1MFromPerToken(firstNumber(model, consumed, ["output_cost_per_audio_token"])),
|
|
outputImage: per1MFromPerToken(firstNumber(model, consumed, ["output_cost_per_image_token"])),
|
|
reasoningOutput: per1MFromPerToken(firstNumber(model, consumed, ["output_cost_per_reasoning_token"]))
|
|
});
|
|
|
|
const perImage = numericGroup(model, consumed, {
|
|
input: "input_cost_per_image",
|
|
output: "output_cost_per_image",
|
|
outputAbove512x512: "output_cost_per_image_above_512_and_512_pixels",
|
|
outputAbove512x512Premium: "output_cost_per_image_above_512_and_512_pixels_and_premium_image",
|
|
outputAbove1024x1024: "output_cost_per_image_above_1024_and_1024_pixels",
|
|
outputAbove1024x1024Premium: "output_cost_per_image_above_1024_and_1024_pixels_and_premium_image",
|
|
outputPremium: "output_cost_per_image_premium_image"
|
|
});
|
|
const perPixel = numericGroup(model, consumed, {
|
|
input: "input_cost_per_pixel",
|
|
output: "output_cost_per_pixel"
|
|
});
|
|
const perAudioSecond = numericGroup(model, consumed, {
|
|
input: "input_cost_per_audio_per_second",
|
|
inputAbove128KTokens: "input_cost_per_audio_per_second_above_128k_tokens",
|
|
output: "output_cost_per_second"
|
|
});
|
|
const perVideoSecond = numericGroup(model, consumed, {
|
|
input: "input_cost_per_video_per_second",
|
|
inputAbove128KTokens: "input_cost_per_video_per_second_above_128k_tokens",
|
|
inputAbove8SInterval: "input_cost_per_video_per_second_above_8s_interval",
|
|
inputAbove15SInterval: "input_cost_per_video_per_second_above_15s_interval",
|
|
output: "output_cost_per_video_per_second",
|
|
output1080p: "output_cost_per_second_1080p"
|
|
});
|
|
const perCharacter = numericGroup(model, consumed, {
|
|
input: "input_cost_per_character",
|
|
inputAbove128KTokens: "input_cost_per_character_above_128k_tokens",
|
|
output: "output_cost_per_character",
|
|
outputAbove128KTokens: "output_cost_per_character_above_128k_tokens"
|
|
});
|
|
const perRequest = numericGroup(model, consumed, {
|
|
input: "input_cost_per_request"
|
|
});
|
|
const perQuery = numericGroup(model, consumed, {
|
|
input: "input_cost_per_query"
|
|
});
|
|
const perPage = numericGroup(model, consumed, {
|
|
annotation: "annotation_cost_per_page",
|
|
ocr: "ocr_cost_per_page"
|
|
});
|
|
const perCredit = numericGroup(model, consumed, {
|
|
ocr: "ocr_cost_per_credit"
|
|
});
|
|
const perSession = numericGroup(model, consumed, {
|
|
codeInterpreter: "code_interpreter_cost_per_session"
|
|
});
|
|
const perGBPerDay = numericGroup(model, consumed, {
|
|
fileSearch: "file_search_cost_per_gb_per_day",
|
|
vectorStore: "vector_store_cost_per_gb_per_day"
|
|
});
|
|
const per1KCalls = numericGroup(model, consumed, {
|
|
fileSearch: "file_search_cost_per_1k_calls"
|
|
});
|
|
|
|
if (isRecord(model.search_context_cost_per_query)) consumed.add("search_context_cost_per_query");
|
|
|
|
return compactObject({
|
|
sourceUnit: "usd_per_token_for_token_fields",
|
|
per1MTokens,
|
|
perImage,
|
|
perPixel,
|
|
perAudioSecond,
|
|
perVideoSecond,
|
|
perCharacter,
|
|
perRequest,
|
|
perQuery,
|
|
perPage,
|
|
perCredit,
|
|
perSession,
|
|
perGBPerDay,
|
|
per1KCalls,
|
|
searchContextPerQuery: normalizeNestedNumbers(model.search_context_cost_per_query),
|
|
extra: numericPricingObjectExcept(model, consumed)
|
|
});
|
|
}
|
|
|
|
function pricingFromOpenRouter(pricing) {
|
|
if (!isRecord(pricing)) return undefined;
|
|
|
|
const consumed = new Set();
|
|
const per1MTokens = compactObject({
|
|
cacheRead: per1MFromPerToken(firstNumber(pricing, consumed, ["input_cache_read"])),
|
|
cacheWrite: per1MFromPerToken(firstNumber(pricing, consumed, ["input_cache_write"])),
|
|
input: per1MFromPerToken(firstNumber(pricing, consumed, ["prompt"])),
|
|
internalReasoning: per1MFromPerToken(firstNumber(pricing, consumed, ["internal_reasoning"])),
|
|
output: per1MFromPerToken(firstNumber(pricing, consumed, ["completion"]))
|
|
});
|
|
const other = numericGroup(pricing, consumed, {
|
|
audio: "audio",
|
|
image: "image",
|
|
webSearch: "web_search"
|
|
});
|
|
|
|
return compactObject({
|
|
sourceUnit: "usd_per_token_for_token_fields",
|
|
per1MTokens,
|
|
other,
|
|
extra: numericObjectExcept(pricing, consumed)
|
|
});
|
|
}
|
|
|
|
function capabilitiesFromLiteLlm(model, mode, modalities) {
|
|
return compactObject({
|
|
adaptiveThinking: readBoolean(model.supports_adaptive_thinking),
|
|
assistantPrefill: readBoolean(model.supports_assistant_prefill),
|
|
audioInput: readBoolean(model.supports_audio_input) || modalities.input.includes("audio"),
|
|
audioOutput: readBoolean(model.supports_audio_output) || modalities.output.includes("audio"),
|
|
codeExecution: readBoolean(model.supports_code_execution),
|
|
computerUse: readBoolean(model.supports_computer_use),
|
|
embedding: mode === "embedding",
|
|
embeddingImageInput: readBoolean(model.supports_embedding_image_input),
|
|
fileSearch: readBoolean(model.supports_file_search),
|
|
functionCalling: readBoolean(model.supports_function_calling),
|
|
imageEditing: readBoolean(model.supports_nova_canvas_image_edit),
|
|
imageGeneration: mode === "image_generation",
|
|
imageInput: readBoolean(model.supports_vision) ||
|
|
readBoolean(model.supports_image_input) ||
|
|
readBoolean(model.supports_embedding_image_input) ||
|
|
modalities.input.includes("image"),
|
|
imageOutput: mode === "image_generation" || modalities.output.includes("image"),
|
|
lowReasoningEffort: readBoolean(model.supports_low_reasoning_effort),
|
|
maxReasoningEffort: readBoolean(model.supports_max_reasoning_effort),
|
|
minimalReasoningEffort: readBoolean(model.supports_minimal_reasoning_effort),
|
|
moderation: mode === "moderation",
|
|
multimodal: readBoolean(model.supports_multimodal),
|
|
nativeStreaming: readBoolean(model.supports_native_streaming),
|
|
nativeStructuredOutput: readBoolean(model.supports_native_structured_output),
|
|
noneReasoningEffort: readBoolean(model.supports_none_reasoning_effort),
|
|
parallelFunctionCalling: readBoolean(model.supports_parallel_function_calling),
|
|
pdfInput: readBoolean(model.supports_pdf_input) || modalities.input.includes("pdf"),
|
|
promptCaching: readBoolean(model.supports_prompt_caching),
|
|
reasoning: readBoolean(model.supports_reasoning),
|
|
rerank: mode === "rerank",
|
|
responseSchema: readBoolean(model.supports_response_schema),
|
|
samplingParams: readBoolean(model.supports_sampling_params),
|
|
serviceTier: readBoolean(model.supports_service_tier),
|
|
speech: mode === "audio_speech",
|
|
systemMessages: readBoolean(model.supports_system_messages),
|
|
toolChoice: readBoolean(model.supports_tool_choice),
|
|
transcription: mode === "audio_transcription",
|
|
urlContext: readBoolean(model.supports_url_context),
|
|
videoInput: readBoolean(model.supports_video_input) || modalities.input.includes("video"),
|
|
vision: readBoolean(model.supports_vision),
|
|
webSearch: readBoolean(model.supports_web_search),
|
|
xhighReasoningEffort: readBoolean(model.supports_xhigh_reasoning_effort)
|
|
});
|
|
}
|
|
|
|
function inferLiteLlmModalities(model, mode) {
|
|
const input = new Set();
|
|
const output = new Set();
|
|
|
|
if (mode === "audio_transcription") {
|
|
input.add("audio");
|
|
output.add("text");
|
|
} else if (mode === "audio_speech") {
|
|
input.add("text");
|
|
output.add("audio");
|
|
} else if (mode === "image_generation") {
|
|
input.add("text");
|
|
output.add("image");
|
|
} else if (mode === "embedding") {
|
|
input.add("text");
|
|
output.add("embedding");
|
|
} else if (mode === "rerank") {
|
|
input.add("text");
|
|
output.add("score");
|
|
} else {
|
|
input.add("text");
|
|
output.add("text");
|
|
}
|
|
|
|
if (readBoolean(model.supports_vision) ||
|
|
readBoolean(model.supports_image_input) ||
|
|
readBoolean(model.supports_embedding_image_input) ||
|
|
readNumber(model.input_cost_per_image) !== undefined ||
|
|
readNumber(model.input_cost_per_image_token) !== undefined) {
|
|
input.add("image");
|
|
}
|
|
if (readBoolean(model.supports_audio_input) || readNumber(model.input_cost_per_audio_token) !== undefined) {
|
|
input.add("audio");
|
|
}
|
|
if (readBoolean(model.supports_audio_output) || readNumber(model.output_cost_per_audio_token) !== undefined) {
|
|
output.add("audio");
|
|
}
|
|
if (readBoolean(model.supports_video_input) || readNumber(model.input_cost_per_video_per_second) !== undefined) {
|
|
input.add("video");
|
|
}
|
|
if (readBoolean(model.supports_pdf_input)) {
|
|
input.add("pdf");
|
|
}
|
|
|
|
return { input: Array.from(input).sort(), output: Array.from(output).sort() };
|
|
}
|
|
|
|
function mergeLimits(limitsList) {
|
|
const output = {};
|
|
const numericKeys = [
|
|
"contextTokens",
|
|
"inputTokens",
|
|
"maxAudioLengthHours",
|
|
"maxAudioPerPrompt",
|
|
"maxDocumentChunksPerQuery",
|
|
"maxImagesPerPrompt",
|
|
"maxPdfSizeMB",
|
|
"maxQueryTokens",
|
|
"maxTokens",
|
|
"maxTokensPerDocumentChunk",
|
|
"maxVideoLength",
|
|
"maxVideosPerPrompt",
|
|
"outputTokens",
|
|
"outputVectorSize"
|
|
];
|
|
|
|
for (const key of numericKeys) {
|
|
const values = limitsList.map((limits) => readNumber(limits?.[key])).filter((value) => value !== undefined);
|
|
if (values.length > 0) output[key] = Math.max(...values);
|
|
}
|
|
|
|
const contextCandidates = [output.contextTokens, output.inputTokens, output.maxTokens]
|
|
.filter((value) => Number.isFinite(value));
|
|
output.supports1MContext = contextCandidates.some((value) => value >= support1MContextThreshold);
|
|
|
|
return compactObject(output);
|
|
}
|
|
|
|
function mergeModalities(modalityList) {
|
|
const input = new Set();
|
|
const output = new Set();
|
|
for (const modalities of modalityList) {
|
|
for (const value of modalities?.input ?? []) input.add(value);
|
|
for (const value of modalities?.output ?? []) output.add(value);
|
|
}
|
|
return {
|
|
input: Array.from(input).sort(),
|
|
output: Array.from(output).sort()
|
|
};
|
|
}
|
|
|
|
function mergeCapabilities(capabilitiesList, modalities, limits, mode) {
|
|
const merged = {};
|
|
for (const capabilities of capabilitiesList) {
|
|
if (!isRecord(capabilities)) continue;
|
|
for (const [key, value] of Object.entries(capabilities)) {
|
|
if (value === true) merged[key] = true;
|
|
else if (value === false && merged[key] !== true) merged[key] = false;
|
|
}
|
|
}
|
|
|
|
merged.audioInput = merged.audioInput || modalities.input.includes("audio");
|
|
merged.audioOutput = merged.audioOutput || modalities.output.includes("audio");
|
|
merged.imageInput = merged.imageInput || modalities.input.includes("image");
|
|
merged.imageOutput = merged.imageOutput || modalities.output.includes("image");
|
|
merged.pdfInput = merged.pdfInput || modalities.input.includes("pdf");
|
|
merged.videoInput = merged.videoInput || modalities.input.includes("video");
|
|
merged.supports1MContext = Boolean(limits.supports1MContext);
|
|
if (mode === "image_generation") merged.imageGeneration = true;
|
|
|
|
return compactObject(merged);
|
|
}
|
|
|
|
function mergeMetadata(records) {
|
|
const output = compactObject({
|
|
displayNames: uniqueStrings(records.map((record) => record.displayName)),
|
|
families: uniqueStrings(records.map((record) => record.family)),
|
|
modes: uniqueStrings(records.map((record) => record.mode)),
|
|
statuses: uniqueStrings(records.map((record) => record.status)),
|
|
knowledgeCutoff: firstDefined(records.map((record) => record.metadata?.knowledgeCutoff)),
|
|
releaseDate: firstDefined(records.map((record) => record.metadata?.releaseDate)),
|
|
lastUpdated: firstDefined(records.map((record) => record.metadata?.lastUpdated)),
|
|
deprecationDate: firstDefined(records.map((record) => record.metadata?.deprecationDate)),
|
|
supportedParameters: uniqueStrings(records.flatMap((record) => record.metadata?.supportedParameters ?? [])),
|
|
supportedEndpoints: uniqueStrings(records.flatMap((record) => record.metadata?.supportedEndpoints ?? [])),
|
|
supportedRegions: uniqueStrings(records.flatMap((record) => record.metadata?.supportedRegions ?? []))
|
|
});
|
|
|
|
return output;
|
|
}
|
|
|
|
function normalizeModalities(value) {
|
|
if (!isRecord(value)) return { input: [], output: [] };
|
|
return {
|
|
input: readStringArray(value.input).sort(),
|
|
output: readStringArray(value.output).sort()
|
|
};
|
|
}
|
|
|
|
function composeEntryId(provider, model) {
|
|
const normalizedProvider = String(provider || "unknown").trim() || "unknown";
|
|
const normalizedModel = String(model || "unknown").trim() || "unknown";
|
|
const lowerProviderPrefix = `${normalizedProvider.toLowerCase()}/`;
|
|
if (normalizedModel.toLowerCase().startsWith(lowerProviderPrefix)) {
|
|
return normalizedModel;
|
|
}
|
|
return `${normalizedProvider}/${normalizedModel}`;
|
|
}
|
|
|
|
function normalizeEntryKey(value) {
|
|
return String(value).trim().replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/").toLowerCase();
|
|
}
|
|
|
|
function firstNumber(record, consumed, keys) {
|
|
for (const key of keys) {
|
|
const value = readNumber(record?.[key]);
|
|
if (value !== undefined) {
|
|
consumed.add(key);
|
|
return value;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function numericGroup(record, consumed, mapping) {
|
|
const output = {};
|
|
for (const [targetKey, sourceKey] of Object.entries(mapping)) {
|
|
const value = readNumber(record?.[sourceKey]);
|
|
if (value !== undefined) {
|
|
output[targetKey] = value;
|
|
consumed.add(sourceKey);
|
|
}
|
|
}
|
|
return compactObject(output);
|
|
}
|
|
|
|
function numericObjectExcept(record, excludedKeys) {
|
|
if (!isRecord(record)) return undefined;
|
|
const output = {};
|
|
for (const [key, value] of Object.entries(record)) {
|
|
if (excludedKeys.has(key)) continue;
|
|
const normalized = normalizeNestedNumbers(value);
|
|
if (normalized !== undefined) output[key] = normalized;
|
|
}
|
|
return compactObject(output);
|
|
}
|
|
|
|
function numericPricingObjectExcept(record, excludedKeys) {
|
|
if (!isRecord(record)) return undefined;
|
|
const output = {};
|
|
for (const [key, value] of Object.entries(record)) {
|
|
if (excludedKeys.has(key) || !looksLikePricingKey(key)) continue;
|
|
const normalized = normalizeNestedNumbers(value);
|
|
if (normalized !== undefined) output[key] = normalized;
|
|
}
|
|
return compactObject(output);
|
|
}
|
|
|
|
function looksLikePricingKey(key) {
|
|
return key.includes("cost") ||
|
|
key.includes("price") ||
|
|
key.includes("_per_") ||
|
|
key.includes("dbu") ||
|
|
key.includes("uplift_multiplier");
|
|
}
|
|
|
|
function normalizeNestedNumbers(value) {
|
|
const number = readNumber(value);
|
|
if (number !== undefined) return number;
|
|
if (Array.isArray(value)) {
|
|
const items = value
|
|
.map((item) => normalizeNestedNumbers(item))
|
|
.filter((item) => item !== undefined);
|
|
return items.length > 0 ? items : undefined;
|
|
}
|
|
if (isRecord(value)) {
|
|
const output = {};
|
|
for (const [key, nested] of Object.entries(value)) {
|
|
const normalized = normalizeNestedNumbers(nested);
|
|
if (normalized !== undefined) output[key] = normalized;
|
|
}
|
|
return Object.keys(output).length > 0 ? output : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function per1MFromPerToken(value) {
|
|
return value === undefined ? undefined : roundNumber(value * 1_000_000);
|
|
}
|
|
|
|
function readNumber(value) {
|
|
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
}
|
|
|
|
function readString(value) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
}
|
|
|
|
function readBoolean(value) {
|
|
return typeof value === "boolean" ? value : undefined;
|
|
}
|
|
|
|
function readStringArray(value) {
|
|
return Array.isArray(value)
|
|
? value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim())
|
|
: [];
|
|
}
|
|
|
|
function firstDefined(values) {
|
|
return values.find((value) => value !== undefined && value !== null && value !== "");
|
|
}
|
|
|
|
function uniqueStrings(values) {
|
|
return Array.from(new Set(values.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim()))).sort();
|
|
}
|
|
|
|
function compactObject(object) {
|
|
if (!isRecord(object)) return undefined;
|
|
const output = {};
|
|
for (const [key, value] of Object.entries(object)) {
|
|
if (value === undefined || value === null) continue;
|
|
if (Array.isArray(value) && value.length === 0) continue;
|
|
if (isRecord(value) && Object.keys(value).length === 0) continue;
|
|
output[key] = value;
|
|
}
|
|
return Object.keys(output).length > 0 ? output : undefined;
|
|
}
|
|
|
|
function omit(object, keys) {
|
|
const keySet = new Set(keys);
|
|
const output = {};
|
|
for (const [key, value] of Object.entries(object)) {
|
|
if (!keySet.has(key)) output[key] = value;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function roundNumber(value) {
|
|
return Number(value.toPrecision(12));
|
|
}
|
|
|
|
function epochSecondsToIso(value) {
|
|
const seconds = readNumber(value);
|
|
if (seconds === undefined) return undefined;
|
|
return new Date(seconds * 1000).toISOString();
|
|
}
|
|
|
|
function isRecord(value) {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|