mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(ai,coding-agent): unify image and classifier model infrastructure (#9948)
This commit is contained in:
parent
667fc3dd36
commit
a328aa89ad
106 changed files with 4032 additions and 2186 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
107
packages/ai/scripts/openrouter-catalog.ts
Normal file
107
packages/ai/scripts/openrouter-catalog.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue