feat(ai,coding-agent): unify image and classifier model infrastructure (#9948)

This commit is contained in:
Armin Ronacher 2026-09-23 17:41:19 +02:00 • committed by GitHub
parent 667fc3dd36
commit a328aa89ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 4032 additions and 2186 deletions

View file

@ -1,162 +0,0 @@
#!/usr/bin/env node
import { writeFileSync } from "fs";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import type { ImagesModel } from "../src/types.ts";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageRoot = join(__dirname, "..");
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
function readStrictOption(args: string[]): boolean {
for (const arg of args) {
if (arg !== "--strict") throw new Error(`Unknown argument: ${arg}`);
}
return args.includes("--strict");
}
interface OpenRouterModelRecord {
id: string;
name: string;
context_length?: number;
architecture?: {
input_modalities?: string[];
output_modalities?: string[];
};
pricing?: {
prompt?: string;
completion?: string;
input_cache_read?: string;
input_cache_write?: string;
};
}
export function parseOpenRouterImageModels(
payload: unknown,
strict: boolean,
): ImagesModel<"openrouter-images">[] {
const data =
typeof payload === "object" && payload !== null
? (payload as { data?: OpenRouterModelRecord[] }).data
: undefined;
if (!Array.isArray(data) || data.length === 0) {
if (strict) throw new Error("OpenRouter API returned a missing or empty image model list");
return [];
}
const models: ImagesModel<"openrouter-images">[] = [];
for (const model of data) {
const input = Array.from(
new Set(
(model.architecture?.input_modalities ?? []).filter(
(modality): modality is "text" | "image" => modality === "text" || modality === "image",
),
),
);
const output = Array.from(
new Set(
(model.architecture?.output_modalities ?? []).filter(
(modality): modality is "text" | "image" => modality === "text" || modality === "image",
),
),
);
if (!output.includes("image")) continue;
if (input.length === 0) input.push("text");
models.push({
id: model.id,
name: model.name,
api: "openrouter-images",
provider: "openrouter",
baseUrl: OPENROUTER_BASE_URL,
input,
output,
cost: {
input: parseFloat(model.pricing?.prompt || "0") * 1_000_000,
output: parseFloat(model.pricing?.completion || "0") * 1_000_000,
cacheRead: parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000,
cacheWrite: parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000,
},
});
}
if (strict && models.length === 0) {
throw new Error("OpenRouter API returned no usable image models");
}
return models;
}
async function fetchOpenRouterImageModels(strict: boolean): Promise<ImagesModel<"openrouter-images">[]> {
try {
console.log("Fetching image models from OpenRouter API...");
const response = await fetch(`${OPENROUTER_BASE_URL}/models?output_modalities=image`);
if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`);
const models = parseOpenRouterImageModels(await response.json(), strict);
console.log(`Fetched ${models.length} image models from OpenRouter`);
return models;
} catch (error) {
console.error("Failed to fetch OpenRouter image models:", error);
if (strict) throw error;
return [];
}
}
function generateImageModelsFile(models: ImagesModel<"openrouter-images">[]): string {
const imageModelsByProvider = {
openrouter: Object.fromEntries(
models
.sort((a, b) => a.id.localeCompare(b.id))
.map((model) => [
model.id,
`{
id: ${JSON.stringify(model.id)},
name: ${JSON.stringify(model.name)},
api: ${JSON.stringify(model.api)},
provider: ${JSON.stringify(model.provider)},
baseUrl: ${JSON.stringify(model.baseUrl)},
input: ${JSON.stringify(model.input)},
output: ${JSON.stringify(model.output)},
cost: ${JSON.stringify(model.cost, null, 2).replace(/^/gm, "\t")}
} satisfies ImagesModel<${JSON.stringify(model.api)}>`,
]),
),
};
const providerEntries = Object.entries(imageModelsByProvider)
.map(([provider, providerModels]) => {
const modelEntries = Object.entries(providerModels)
.map(([id, serialized]) => `\t\t${JSON.stringify(id)}: ${serialized},`)
.join("\n");
return `\t${JSON.stringify(provider)}: {\n${modelEntries}\n\t},`;
})
.join("\n");
return `// This file is auto-generated by scripts/generate-image-models.ts
// Do not edit manually - run 'npm run generate-image-models' to update
import type { ImagesApi, ImagesModel } from "./types.ts";
export const IMAGE_MODELS = {
${providerEntries}
} as const satisfies Record<string, Record<string, ImagesModel<ImagesApi>>>;
`;
}
async function main(): Promise<void> {
const strict = readStrictOption(process.argv.slice(2));
const models = await fetchOpenRouterImageModels(strict);
const output = generateImageModelsFile(models);
const outputPath = join(packageRoot, "src", "image-models.generated.ts");
writeFileSync(outputPath, output, "utf-8");
console.log(`Generated ${outputPath}`);
}
if (process.argv[1] && resolve(process.argv[1]) === __filename) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}

View file

@ -4,10 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSy
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { getEffortThinkingLevelMap, type ModelsDevReasoningOption } from "./models-dev-reasoning-options.ts";
import {
getOpenRouterThinkingLevelMap,
type OpenRouterReasoningMetadata,
} from "./openrouter-reasoning-options.ts";
import { buildOpenRouterCatalog, type OpenRouterCatalog, type OpenRouterModelListItem } from "./openrouter-catalog.ts";
import {
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
@ -16,7 +13,12 @@ import {
} from "../src/api/cloudflare.ts";
import type {
AnthropicMessagesCompat,
AnyModel,
Api,
ClassifierApi,
ClassifierModel,
ImageApi,
ImageModel,
KnownProvider,
Model,
ModelCost,
@ -131,31 +133,20 @@ interface ModelsDevProvider {
models?: Record<string, ModelsDevModel>;
}
interface ModelsDevMetadata {
id: string;
type?: string;
name: string;
limit?: { context?: number };
modalities?: { input?: string[] };
}
type ModelsDevCatalog = Record<string, ModelsDevProvider>;
interface NvidiaNimModelListItem {
id: string;
}
interface OpenRouterModelListItem {
id: string;
name: string;
supported_parameters?: string[];
architecture?: { modality?: string };
pricing?: {
prompt?: string;
completion?: string;
input_cache_read?: string;
input_cache_write?: string;
};
top_provider?: {
context_length?: number;
max_completion_tokens?: number;
};
context_length?: number;
reasoning?: OpenRouterReasoningMetadata;
}
interface AiGatewayModel {
id: string;
name?: string;
@ -976,14 +967,14 @@ function applyPromptCacheMetadata(model: Model<Api>): void {
// behavior; a documented TTL alone does not establish full cache loss.
}
function applyImageInputMetadata(model: Model<Api>): void {
function applyImageInputMetadata(model: AnyModel): void {
if (!model.input.includes("image")) return;
const providerLimits: Model<Api>["inputLimits"] =
const providerLimits: AnyModel["inputLimits"] =
model.provider === "anthropic"
? {
maxRequestBytes: 32 * 1024 * 1024,
images: { maxPerRequest: model.contextWindow === 200000 ? 100 : 600 },
images: { maxPerRequest: model.type !== "image" && model.contextWindow === 200000 ? 100 : 600 },
}
: model.provider === "amazon-bedrock"
? { images: { maxPerMessage: 20 } }
@ -1283,68 +1274,30 @@ async function fetchNvidiaNimModelIds(): Promise<Map<string, string>> {
}
}
async function fetchOpenRouterModels(): Promise<Model<any>[]> {
async function fetchOpenRouterList(query: string): Promise<OpenRouterModelListItem[]> {
const response = await fetch(`https://openrouter.ai/api/v1/models${query}`);
if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`);
const data = (await response.json()) as { data?: OpenRouterModelListItem[] };
return data.data ?? [];
}
async function fetchOpenRouterModels(): Promise<OpenRouterCatalog> {
try {
console.log("Fetching models from OpenRouter API...");
const response = await fetch("https://openrouter.ai/api/v1/models");
if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`);
const data = (await response.json()) as { data?: OpenRouterModelListItem[] };
const models: Model<any>[] = [];
for (const model of data.data ?? []) {
// Only include models that support tools
if (!model.supported_parameters?.includes("tools")) continue;
// Parse provider from model ID
let provider: KnownProvider = "openrouter";
let modelKey = model.id;
modelKey = model.id; // Keep full ID for OpenRouter
// Parse input modalities
const input: ("text" | "image")[] = ["text"];
if (model.architecture?.modality?.includes("image")) {
input.push("image");
}
// Convert pricing from $/token to $/million tokens
const inputCost = roundCost(parseFloat(model.pricing?.prompt || "0") * 1_000_000);
const outputCost = roundCost(parseFloat(model.pricing?.completion || "0") * 1_000_000);
const cacheReadCost = roundCost(parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000);
const cacheWriteCost = roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000);
const contextWindow = model.top_provider?.context_length || model.context_length || 4096;
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);
const useAnthropicMessages = /^anthropic\//.test(modelKey) && !modelKey.endsWith(":batch");
const normalizedModel: Model<any> = {
id: modelKey,
name: model.name,
api: useAnthropicMessages ? "anthropic-messages" : "openai-completions",
baseUrl: useAnthropicMessages ? "https://openrouter.ai/api" : "https://openrouter.ai/api/v1",
provider,
reasoning: model.supported_parameters?.includes("reasoning") || false,
...(thinkingLevelMap && { thinkingLevelMap }),
input,
cost: {
input: inputCost,
output: outputCost,
cacheRead: cacheReadCost,
cacheWrite: cacheWriteCost,
},
contextWindow,
maxTokens: model.top_provider?.max_completion_tokens || 4096,
};
models.push(normalizedModel);
const [listed, imageListed] = await Promise.all([
fetchOpenRouterList(""),
fetchOpenRouterList("?output_modalities=image"),
]);
const catalog = buildOpenRouterCatalog(listed, imageListed);
console.log(`Fetched ${catalog.chat.length} tool-capable and ${catalog.images.length} image models from OpenRouter`);
if (generatorOptions.strict && catalog.images.length === 0) {
throw new Error("OpenRouter API returned no usable image models");
}
console.log(`Fetched ${models.length} tool-capable models from OpenRouter`);
return models;
return catalog;
} catch (error) {
console.error("Failed to fetch OpenRouter models:", error);
if (generatorOptions.strict) throw error;
return [];
return { chat: [], images: [] };
}
}
@ -2662,6 +2615,37 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
}
async function loadModelsDevClassifierModels(): Promise<ClassifierModel<"typesafe-system-one">[]> {
try {
console.log("Fetching classifier models from models.dev API...");
const response = await fetch("https://models.dev/models.json?type=decision");
if (!response.ok) throw new Error(`models.dev classifier API returned ${response.status}`);
const data = (await response.json()) as Record<string, ModelsDevMetadata>;
const metadata = data["typesafe/jev-latest"];
if (!metadata || metadata.type !== "decision") {
throw new Error("models.dev did not return decision model typesafe/jev-latest");
}
return [
{
type: "classifier",
id: "jev-latest",
name: metadata.name,
api: "typesafe-system-one",
provider: "typesafe",
baseUrl: "https://api.typesafe.ai/v1/",
input: metadata.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
// The canonical models.dev entry has no direct-provider pricing and System One reports no token usage.
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: metadata.limit?.context || 64000,
},
];
} catch (error) {
console.error("Failed to load models.dev classifier data:", error);
if (generatorOptions.strict) throw error;
return [];
}
}
async function generateModels() {
// Fetch models from all upstream catalogs.
// models.dev: Anthropic, Google, OpenAI, Groq, Cerebras, and others
@ -2669,12 +2653,13 @@ async function generateModels() {
// AI Gateway: OpenAI-compatible catalog with tool-capable models
// Radius: its unauthenticated public catalog; authenticated clients overlay it at runtime
const modelsDevModels = await loadModelsDevData();
const openRouterModels = await fetchOpenRouterModels();
const classifierModels = await loadModelsDevClassifierModels();
const openRouterCatalog = await fetchOpenRouterModels();
const aiGatewayModels = await fetchAiGatewayModels();
const radiusModels = await fetchRadiusModels();
// Combine models (models.dev has priority where sources overlap).
const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels, ...radiusModels].filter(
// Combine chat models (models.dev has priority where sources overlap).
const allModels = [...modelsDevModels, ...openRouterCatalog.chat, ...aiGatewayModels, ...radiusModels].filter(
(model) =>
!(model.provider === "xai" && XAI_BUILTIN_EXCLUDED_MODEL_IDS.has(model.id)) &&
!((model.provider === "opencode" || model.provider === "opencode-go") && model.id === "gpt-5.3-codex-spark"),
@ -3290,26 +3275,49 @@ async function generateModels() {
}
applyAnthropicAllowedFallbackModelMetadata(allModels.filter(isAnthropicFallbackMetadataModel));
// Group by provider and deduplicate by model ID
const providers: Record<string, Record<string, Model<any>>> = {};
// Keep chat and image catalogs separate so one upstream ID can expose both
// operations with different API implementations.
type ProviderCatalog = {
chat: Record<string, Model<Api>>;
image: Record<string, ImageModel<ImageApi>>;
classifier: Record<string, ClassifierModel<ClassifierApi>>;
};
const providers: Record<string, ProviderCatalog> = {};
for (const model of allModels) {
if (!providers[model.provider]) {
providers[model.provider] = {};
}
// Use model ID as key to automatically deduplicate
// Only add if not already present (models.dev takes priority over OpenRouter)
if (!providers[model.provider][model.id]) {
providers[model.provider][model.id] = model;
}
providers[model.provider] ??= { chat: {}, image: {}, classifier: {} };
// Only add if not already present (models.dev takes priority over OpenRouter).
providers[model.provider].chat[model.id] ??= { ...model, type: "chat" };
}
for (const model of openRouterCatalog.images) {
applyImageInputMetadata(model);
providers[model.provider] ??= { chat: {}, image: {}, classifier: {} };
providers[model.provider].image[model.id] ??= model;
}
for (const model of classifierModels) {
providers[model.provider] ??= { chat: {}, image: {}, classifier: {} };
providers[model.provider].classifier[model.id] ??= model;
}
const sortedProviderIds = Object.keys(providers).sort();
const jsonProviders: Record<string, Record<string, Model<any>>> = {};
const jsonChatProviders: Record<string, Record<string, Model<Api>>> = {};
const jsonImageProviders: Record<string, Record<string, ImageModel<ImageApi>>> = {};
const jsonClassifierProviders: Record<string, Record<string, ClassifierModel<ClassifierApi>>> = {};
const jsonAllProviders: Record<string, AnyModel[]> = {};
for (const providerId of sortedProviderIds) {
jsonProviders[providerId] = {};
for (const modelId of Object.keys(providers[providerId]).sort()) {
jsonProviders[providerId][modelId] = providers[providerId][modelId];
}
jsonChatProviders[providerId] = Object.fromEntries(
Object.entries(providers[providerId].chat).sort(([left], [right]) => left.localeCompare(right)),
);
jsonImageProviders[providerId] = Object.fromEntries(
Object.entries(providers[providerId].image).sort(([left], [right]) => left.localeCompare(right)),
);
jsonClassifierProviders[providerId] = Object.fromEntries(
Object.entries(providers[providerId].classifier).sort(([left], [right]) => left.localeCompare(right)),
);
jsonAllProviders[providerId] = [
...Object.values(jsonChatProviders[providerId]),
...Object.values(jsonImageProviders[providerId]),
...Object.values(jsonClassifierProviders[providerId]),
];
}
const serializeJson = (value: unknown) => `${JSON.stringify(value, null, generatorOptions.pretty ? 2 : undefined)}\n`;
@ -3317,25 +3325,29 @@ async function generateModels() {
const generatedDataProviderIds = generatorOptions.dataOnly
? readModelDataProviderIds(packageRoot)
: sortedProviderIds;
const missingProviderIds = generatedDataProviderIds.filter((providerId) => !jsonProviders[providerId]);
const missingProviderIds = generatedDataProviderIds.filter((providerId) => !jsonAllProviders[providerId]);
if (missingProviderIds.length > 0) {
throw new Error(`Cannot hydrate missing providers: ${missingProviderIds.join(", ")}`);
}
// Only the ignored internal data is grouped by API for type derivation. Public JSON catalog output stays flat.
const generatedDataProviders: Record<string, Record<string, Record<string, Model<Api>>>> = {};
// Only the ignored internal data is grouped by API for type derivation.
const generatedDataProviders: Record<string, Record<string, Record<string, AnyModel>>> = {};
const modelDataStructure: ModelDataStructure = {};
for (const providerId of generatedDataProviderIds) {
const models = jsonProviders[providerId];
const models = jsonAllProviders[providerId];
generatedDataProviders[providerId] = {};
modelDataStructure[providerId] = {};
const apiIds = Array.from(new Set(Object.values(models).map((model) => model.api))).sort();
const apiIds = Array.from(new Set(models.map((model) => model.api))).sort();
for (const api of apiIds) {
generatedDataProviders[providerId][api] = {};
for (const [modelId, model] of Object.entries(models)) {
for (const model of models) {
if (model.api !== api) continue;
generatedDataProviders[providerId][api][modelId] = model;
modelDataStructure[providerId][modelId] = api;
const identity = `${model.type}:${model.id}`;
if (generatedDataProviders[providerId][api][identity]) {
throw new Error(`${providerId}/${identity} has duplicate ${api} catalog entries`);
}
generatedDataProviders[providerId][api][identity] = model;
modelDataStructure[providerId][identity] = api;
}
}
}
@ -3389,13 +3401,21 @@ async function generateModels() {
`;
const catalogConstName = (providerId: string) =>
`${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_MODELS`;
const imageCatalogConstName = (providerId: string) =>
`${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_IMAGE_MODELS`;
const classifierCatalogConstName = (providerId: string) =>
`${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_CLASSIFIER_MODELS`;
const generatedShardFiles = new Set<string>();
for (const providerId of sortedProviderIds) {
let output = generatedHeader;
output += `import values from "./data/${providerId}.json" with { type: "json" };\n`;
output += `import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";\n\n`;
output += `export const ${catalogConstName(providerId)}: ModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
output += `\tflattenModelCatalog(${JSON.stringify(providerId)}, values);\n`;
output += `import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";\n\n`;
output += `export const ${catalogConstName(providerId)}: ChatModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
output += `\tflattenChatModelCatalog(${JSON.stringify(providerId)}, values);\n\n`;
output += `export const ${imageCatalogConstName(providerId)}: ImageModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
output += `\tflattenImageModelCatalog(${JSON.stringify(providerId)}, values);\n\n`;
output += `export const ${classifierCatalogConstName(providerId)}: ClassifierModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
output += `\tflattenClassifierModelCatalog(${JSON.stringify(providerId)}, values);\n`;
const filename = `${providerId}.models.ts`;
generatedShardFiles.add(filename);
writeFileSync(join(providersDir, filename), output);
@ -3406,7 +3426,7 @@ async function generateModels() {
let output = generatedHeader;
for (const providerId of sortedProviderIds) {
output += `import { ${catalogConstName(providerId)} } from "./providers/${providerId}.models.ts";\n`;
output += `import { ${classifierCatalogConstName(providerId)}, ${imageCatalogConstName(providerId)}, ${catalogConstName(providerId)} } from "./providers/${providerId}.models.ts";\n`;
}
output += `\nexport const MODELS: {\n`;
for (const providerId of sortedProviderIds) {
@ -3416,6 +3436,22 @@ async function generateModels() {
for (const providerId of sortedProviderIds) {
output += `\t${JSON.stringify(providerId)}: ${catalogConstName(providerId)},\n`;
}
output += `};\n\nexport const IMAGE_MODELS: {\n`;
for (const providerId of sortedProviderIds) {
output += `\treadonly ${JSON.stringify(providerId)}: typeof ${imageCatalogConstName(providerId)};\n`;
}
output += `} = {\n`;
for (const providerId of sortedProviderIds) {
output += `\t${JSON.stringify(providerId)}: ${imageCatalogConstName(providerId)},\n`;
}
output += `};\n\nexport const CLASSIFIER_MODELS: {\n`;
for (const providerId of sortedProviderIds) {
output += `\treadonly ${JSON.stringify(providerId)}: typeof ${classifierCatalogConstName(providerId)};\n`;
}
output += `} = {\n`;
for (const providerId of sortedProviderIds) {
output += `\t${JSON.stringify(providerId)}: ${classifierCatalogConstName(providerId)},\n`;
}
output += `};\n`;
writeFileSync(aggregatorPath, output);
console.log("Generated provider catalogs and src/models.generated.ts");
@ -3446,27 +3482,33 @@ async function generateModels() {
}
if (generatorOptions.jsonOutputDir) {
// `models.json` and `providers/{id}.json` retain the legacy keyed chat catalog.
// The `.all` variants are arrays so the same upstream id can appear once per type.
const providerOutputDir = join(generatorOptions.jsonOutputDir, "providers");
rmSync(generatorOptions.jsonOutputDir, { recursive: true, force: true });
mkdirSync(providerOutputDir, { recursive: true });
writeJson(join(generatorOptions.jsonOutputDir, "models.json"), jsonProviders);
writeJson(join(generatorOptions.jsonOutputDir, "models.json"), jsonChatProviders);
writeJson(join(generatorOptions.jsonOutputDir, "models.all.json"), jsonAllProviders);
writeJson(join(generatorOptions.jsonOutputDir, "providers.json"), sortedProviderIds);
for (const providerId of sortedProviderIds) {
writeJson(join(providerOutputDir, `${providerId}.json`), jsonProviders[providerId]);
writeJson(join(providerOutputDir, `${providerId}.json`), jsonChatProviders[providerId]);
writeJson(join(providerOutputDir, `${providerId}.all.json`), jsonAllProviders[providerId]);
}
console.log(`Generated JSON model catalog under ${generatorOptions.jsonOutputDir}`);
}
// Print statistics
const totalModels = allModels.length;
const reasoningModels = allModels.filter(m => m.reasoning).length;
const reasoningModels = allModels.filter((model) => model.reasoning).length;
console.log(`\nModel Statistics:`);
console.log(` Total tool-capable models: ${totalModels}`);
console.log(` Reasoning-capable models: ${reasoningModels}`);
for (const [provider, models] of Object.entries(providers)) {
console.log(` ${provider}: ${Object.keys(models).length} models`);
console.log(
` ${provider}: ${Object.keys(models.chat).length} chat models, ${Object.keys(models.image).length} image models, ${Object.keys(models.classifier).length} classifier models`,
);
}
}

View file

@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
export const MODEL_DATA_SCHEMA_VERSION = 3;
export const MODEL_DATA_SCHEMA_VERSION = 6;
export const MODEL_DATA_MANIFEST_FILE = ".manifest.json";
export type ModelDataStructure = Record<string, Record<string, string>>;
@ -15,7 +15,7 @@ export interface ModelDataManifest {
}
const MODEL_DATA_IMPORT_PATTERN =
/^import \{ [A-Z][A-Z0-9_]*_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm;
/^import \{ [A-Z][A-Z0-9_]*_CLASSIFIER_MODELS, [A-Z][A-Z0-9_]*_IMAGE_MODELS, [A-Z][A-Z0-9_]*_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm;
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
@ -50,6 +50,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isModalityList(value: unknown): value is ("text" | "image")[] {
return Array.isArray(value) && value.length > 0 && value.every((entry) => entry === "text" || entry === "image");
}
function readJsonObject(path: string, description: string, errors: string[]): Record<string, unknown> | undefined {
let parsed: unknown;
try {
@ -73,9 +77,9 @@ function readProviderStructure(path: string, providerId: string): Record<string,
const models = new Map<string, string>();
for (const [api, value] of Object.entries(groups)) {
if (!isRecord(value)) throw new Error(`${path} API group ${JSON.stringify(api)} must be an object`);
for (const modelId of Object.keys(value)) {
if (models.has(modelId)) throw new Error(`${path} contains model ${modelId} in more than one API group`);
models.set(modelId, api);
for (const modelKey of Object.keys(value)) {
if (models.has(modelKey)) throw new Error(`${path} contains ${modelKey} in more than one API group`);
models.set(modelKey, api);
}
}
if (models.size === 0) throw new Error(`${path} contains no generated model data`);
@ -158,19 +162,36 @@ function validateModelValue(
}
if (typeof value.name !== "string" || value.name.length === 0) errors.push(`${label} has no model name`);
if (typeof value.baseUrl !== "string") errors.push(`${label} has no baseUrl string`);
if (typeof value.reasoning !== "boolean") errors.push(`${label} has no reasoning boolean`);
if (
!Array.isArray(value.input) ||
value.input.length === 0 ||
value.input.some((entry) => entry !== "text" && entry !== "image")
) {
errors.push(`${label} has invalid input modalities`);
if (!isModalityList(value.input)) errors.push(`${label} has invalid input modalities`);
if (value.type === "image") {
if (!isModalityList(value.output) || !value.output.includes("image")) {
errors.push(`${label} has invalid output modalities`);
}
} else if (value.output !== undefined) {
errors.push(`${label} has unsupported output modalities`);
}
if (typeof value.contextWindow !== "number" || !Number.isFinite(value.contextWindow) || value.contextWindow <= 0) {
errors.push(`${label} has invalid contextWindow`);
}
if (typeof value.maxTokens !== "number" || !Number.isFinite(value.maxTokens) || value.maxTokens <= 0) {
errors.push(`${label} has invalid maxTokens`);
if (value.type === "chat") {
if (typeof value.reasoning !== "boolean") errors.push(`${label} has no reasoning boolean`);
if (
typeof value.contextWindow !== "number" ||
!Number.isFinite(value.contextWindow) ||
value.contextWindow <= 0
) {
errors.push(`${label} has invalid contextWindow`);
}
if (typeof value.maxTokens !== "number" || !Number.isFinite(value.maxTokens) || value.maxTokens <= 0) {
errors.push(`${label} has invalid maxTokens`);
}
} else if (value.type === "classifier") {
if (
typeof value.contextWindow !== "number" ||
!Number.isFinite(value.contextWindow) ||
value.contextWindow <= 0
) {
errors.push(`${label} has invalid contextWindow`);
}
} else if (value.type !== "image") {
errors.push(`${label} has type ${JSON.stringify(value.type)}, expected "chat", "image", or "classifier"`);
}
if (!isRecord(value.cost)) {
errors.push(`${label} has invalid cost metadata`);
@ -246,13 +267,18 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi
errors.push(`${filename} API group ${JSON.stringify(api)} must be an object`);
continue;
}
for (const [modelId, model] of Object.entries(value)) {
if (actualModels.has(modelId)) {
errors.push(`${providerId}/${modelId} appears in more than one API group`);
for (const [modelKey, model] of Object.entries(value)) {
if (actualModels.has(modelKey)) {
errors.push(`${providerId}/${modelKey} appears in more than one API group`);
continue;
}
actualModels.set(modelId, api);
actualModels.set(modelKey, api);
const separator = modelKey.indexOf(":");
const modelId = separator >= 0 ? modelKey.slice(separator + 1) : modelKey;
validateModelValue(model, providerId, modelId, api, errors);
if (isRecord(model) && modelKey !== `${String(model.type)}:${String(model.id)}`) {
errors.push(`${providerId}/${modelKey} has mismatched type/id identity`);
}
}
}

View file

@ -0,0 +1,107 @@
import type { ImageModel, Model, ModelCost } from "../src/types.ts";
import { getOpenRouterThinkingLevelMap, type OpenRouterReasoningMetadata } from "./openrouter-reasoning-options.ts";
export interface OpenRouterModelListItem {
id: string;
name: string;
supported_parameters?: string[];
architecture?: { modality?: string; input_modalities?: string[]; output_modalities?: string[] };
pricing?: {
prompt?: string;
completion?: string;
input_cache_read?: string;
input_cache_write?: string;
};
top_provider?: {
context_length?: number;
max_completion_tokens?: number;
};
context_length?: number;
reasoning?: OpenRouterReasoningMetadata;
}
export interface OpenRouterCatalog {
chat: Model<"anthropic-messages" | "openai-completions">[];
images: ImageModel<"openrouter-images">[];
}
function roundCost(value: number): number {
return Number(value.toFixed(6));
}
function modalities(values: string[] | undefined): ("text" | "image")[] {
return Array.from(
new Set((values ?? []).filter((value): value is "text" | "image" => value === "text" || value === "image")),
);
}
function cost(model: OpenRouterModelListItem): ModelCost {
// Convert pricing from $/token to $/million tokens
return {
input: roundCost(parseFloat(model.pricing?.prompt || "0") * 1_000_000),
output: roundCost(parseFloat(model.pricing?.completion || "0") * 1_000_000),
cacheRead: roundCost(parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000),
cacheWrite: roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000),
};
}
/**
* Build the OpenRouter catalog from the default listing and the
* `output_modalities=image` listing. The default listing omits image-only
* models, so image models come from the second one. An upstream model may
* appear in both results; it then gets separate chat and image entries.
*/
export function buildOpenRouterCatalog(
listed: readonly OpenRouterModelListItem[],
imageListed: readonly OpenRouterModelListItem[],
): OpenRouterCatalog {
const chat: OpenRouterCatalog["chat"] = [];
for (const model of listed) {
// Only include models that support tools
if (!model.supported_parameters?.includes("tools")) continue;
// Parse input modalities
const input: ("text" | "image")[] = ["text"];
if (model.architecture?.modality?.includes("image")) {
input.push("image");
}
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);
const useAnthropicMessages = /^anthropic\//.test(model.id) && !model.id.endsWith(":batch");
chat.push({
type: "chat",
id: model.id,
name: model.name,
api: useAnthropicMessages ? "anthropic-messages" : "openai-completions",
baseUrl: useAnthropicMessages ? "https://openrouter.ai/api" : "https://openrouter.ai/api/v1",
provider: "openrouter",
reasoning: model.supported_parameters?.includes("reasoning") || false,
...(thinkingLevelMap && { thinkingLevelMap }),
input,
cost: cost(model),
contextWindow: model.top_provider?.context_length || model.context_length || 4096,
maxTokens: model.top_provider?.max_completion_tokens || 4096,
});
}
const images: OpenRouterCatalog["images"] = [];
for (const model of imageListed) {
if (images.some((entry) => entry.id === model.id)) continue;
const output = modalities(model.architecture?.output_modalities);
if (!output.includes("image")) continue;
const input = modalities(model.architecture?.input_modalities);
images.push({
type: "image",
id: model.id,
name: model.name,
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: input.length > 0 ? input : ["text"],
output,
cost: cost(model),
});
}
return { chat, images };
}