chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
67
third_party/pi/packages/ai/scripts/ai-gateway-pricing.ts
vendored
Normal file
67
third_party/pi/packages/ai/scripts/ai-gateway-pricing.ts
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ModelCost, ModelCostRates, ModelCostTier } from "../src/types.ts";
|
||||
|
||||
/** A prompt-length bracket. `min` is inclusive and `max` exclusive, both in prompt tokens. */
|
||||
export interface AiGatewayPriceTier {
|
||||
cost?: string | number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/** Vercel AI Gateway pricing in $/token. */
|
||||
export interface AiGatewayPricing {
|
||||
input?: string | number;
|
||||
output?: string | number;
|
||||
input_cache_read?: string | number;
|
||||
input_cache_write?: string | number;
|
||||
input_tiers?: AiGatewayPriceTier[];
|
||||
output_tiers?: AiGatewayPriceTier[];
|
||||
input_cache_read_tiers?: AiGatewayPriceTier[];
|
||||
input_cache_write_tiers?: AiGatewayPriceTier[];
|
||||
}
|
||||
|
||||
const RATE_FIELDS = [
|
||||
{ rate: "input", tiers: "input_tiers" },
|
||||
{ rate: "output", tiers: "output_tiers" },
|
||||
{ rate: "cacheRead", tiers: "input_cache_read_tiers" },
|
||||
{ rate: "cacheWrite", tiers: "input_cache_write_tiers" },
|
||||
] as const;
|
||||
|
||||
function perMillion(value: string | number | undefined): number {
|
||||
const parsed = typeof value === "number" ? value : parseFloat(value ?? "0");
|
||||
return Number.isFinite(parsed) ? Number((parsed * 1_000_000).toFixed(6)) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert AI Gateway pricing to $/million tokens. Each rate lists its own prompt-length brackets;
|
||||
* every bracket start above zero becomes a request-wide tier with the rates in effect there.
|
||||
*/
|
||||
export function getAiGatewayCost(pricing: AiGatewayPricing | undefined): ModelCost {
|
||||
const base: ModelCostRates = {
|
||||
input: perMillion(pricing?.input),
|
||||
output: perMillion(pricing?.output),
|
||||
cacheRead: perMillion(pricing?.input_cache_read),
|
||||
cacheWrite: perMillion(pricing?.input_cache_write),
|
||||
};
|
||||
|
||||
const starts = new Set<number>();
|
||||
for (const field of RATE_FIELDS) {
|
||||
for (const bracket of pricing?.[field.tiers] ?? []) {
|
||||
if (bracket.min !== undefined && bracket.min > 0) starts.add(bracket.min);
|
||||
}
|
||||
}
|
||||
|
||||
const tiers = [...starts]
|
||||
.sort((a, b) => a - b)
|
||||
.map((start): ModelCostTier => {
|
||||
const tier: ModelCostTier = { inputTokensAbove: start - 1, ...base };
|
||||
for (const field of RATE_FIELDS) {
|
||||
const bracket = pricing?.[field.tiers]?.find(
|
||||
(candidate) => (candidate.min ?? 0) <= start && (candidate.max === undefined || start < candidate.max),
|
||||
);
|
||||
if (bracket?.cost !== undefined) tier[field.rate] = perMillion(bracket.cost);
|
||||
}
|
||||
return tier;
|
||||
});
|
||||
|
||||
return tiers.length > 0 ? { ...base, tiers } : base;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
1617
third_party/pi/packages/ai/scripts/generate-models.ts
vendored
1617
third_party/pi/packages/ai/scripts/generate-models.ts
vendored
File diff suppressed because it is too large
Load Diff
86
third_party/pi/packages/ai/scripts/hydrate-model-catalog.ts
vendored
Normal file
86
third_party/pi/packages/ai/scripts/hydrate-model-catalog.ts
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import {
|
||||
createModelDataManifest,
|
||||
groupProviderModelData,
|
||||
MODEL_DATA_MANIFEST_FILE,
|
||||
type ModelCatalogEntry,
|
||||
type ModelDataStructure,
|
||||
readModelDataProviderIds,
|
||||
validateModelDataDirectory,
|
||||
} from "./model-data.ts";
|
||||
|
||||
type CatalogEntry = ModelCatalogEntry & Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCatalogEntry(value: unknown): value is CatalogEntry {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.type === "string" &&
|
||||
typeof value.id === "string" &&
|
||||
typeof value.api === "string" &&
|
||||
value.api.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the checkout's provider data files from a published typed catalog
|
||||
* (`models.all.json`) without network access. With `validateOnly`, stage and
|
||||
* validate the data without replacing the checkout's current data.
|
||||
*/
|
||||
export function hydrateModelCatalog(
|
||||
packageRoot: string,
|
||||
catalogPath: string,
|
||||
options: { validateOnly?: boolean } = {},
|
||||
): void {
|
||||
const catalog: unknown = JSON.parse(readFileSync(catalogPath, "utf8"));
|
||||
if (!isRecord(catalog)) throw new Error("Model catalog must be an object");
|
||||
|
||||
const files: Record<string, string> = {};
|
||||
const structure: ModelDataStructure = {};
|
||||
for (const provider of readModelDataProviderIds(packageRoot)) {
|
||||
const models = catalog[provider];
|
||||
if (models === undefined) throw new Error(`Model catalog is missing provider: ${provider}`);
|
||||
if (!Array.isArray(models) || models.length === 0) {
|
||||
throw new Error(`Model catalog has no typed model list for provider: ${provider}`);
|
||||
}
|
||||
const entries: CatalogEntry[] = [];
|
||||
for (const model of models) {
|
||||
if (!isCatalogEntry(model)) throw new Error(`Model catalog has an invalid entry for provider ${provider}`);
|
||||
entries.push(model);
|
||||
}
|
||||
const grouped = groupProviderModelData(provider, entries);
|
||||
structure[provider] = grouped.structure;
|
||||
files[`${provider}.json`] = `${JSON.stringify(grouped.groups)}\n`;
|
||||
}
|
||||
|
||||
// Public catalogs have no generation timestamp. Use a fixed stamp so hydration
|
||||
// produces identical bytes for identical input, regardless of build time.
|
||||
const manifest = createModelDataManifest(structure, files, "1970-01-01T00:00:00.000Z");
|
||||
const providersDir = join(packageRoot, "src", "providers");
|
||||
const stagingRoot = mkdtempSync(join(providersDir, ".model-hydration-"));
|
||||
const stagedData = join(stagingRoot, "data");
|
||||
try {
|
||||
mkdirSync(stagedData);
|
||||
for (const [name, content] of Object.entries(files)) writeFileSync(join(stagedData, name), content);
|
||||
writeFileSync(join(stagedData, MODEL_DATA_MANIFEST_FILE), `${JSON.stringify(manifest)}\n`);
|
||||
validateModelDataDirectory(structure, stagedData);
|
||||
if (options.validateOnly) return;
|
||||
const dataDir = join(providersDir, "data");
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
renameSync(stagedData, dataDir);
|
||||
} finally {
|
||||
rmSync(stagingRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
if (process.argv.length !== 3) throw new Error("Usage: node hydrate-model-catalog.ts <models.all.json>");
|
||||
hydrateModelCatalog(join(dirname(fileURLToPath(import.meta.url)), ".."), resolve(process.argv[2]));
|
||||
}
|
||||
98
third_party/pi/packages/ai/scripts/model-data.ts
vendored
98
third_party/pi/packages/ai/scripts/model-data.ts
vendored
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,3 +304,33 @@ export function validateGeneratedModelData(packageRoot: string): void {
|
||||
const structure = readModelDataStructure(packageRoot);
|
||||
validateModelDataDirectory(structure, join(packageRoot, "src", "providers", "data"));
|
||||
}
|
||||
|
||||
export interface ModelCatalogEntry {
|
||||
type: string;
|
||||
id: string;
|
||||
api: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group one provider's typed catalog entries by API and key them by `type:id`,
|
||||
* the layout of `src/providers/data/<provider>.json`.
|
||||
*/
|
||||
export function groupProviderModelData<T extends ModelCatalogEntry>(
|
||||
providerId: string,
|
||||
models: readonly T[],
|
||||
): { groups: Record<string, Record<string, T>>; structure: Record<string, string> } {
|
||||
const groups: Record<string, Record<string, T>> = {};
|
||||
const structure: Record<string, string> = {};
|
||||
for (const api of Array.from(new Set(models.map((model) => model.api))).sort()) {
|
||||
const group: Record<string, T> = {};
|
||||
for (const model of models) {
|
||||
if (model.api !== api) continue;
|
||||
const identity = `${model.type}:${model.id}`;
|
||||
if (group[identity]) throw new Error(`${providerId}/${identity} has duplicate ${api} catalog entries`);
|
||||
group[identity] = model;
|
||||
structure[identity] = api;
|
||||
}
|
||||
groups[api] = group;
|
||||
}
|
||||
return { groups, structure };
|
||||
}
|
||||
|
||||
105
third_party/pi/packages/ai/scripts/openrouter-catalog.ts
vendored
Normal file
105
third_party/pi/packages/ai/scripts/openrouter-catalog.ts
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
import { type OpenRouterModel, openRouterCost } from "../src/providers/openrouter-api.ts";
|
||||
import type { ClassifierModel, ImageModel, Model } from "../src/types.ts";
|
||||
import { getOpenRouterThinkingLevelMap, type OpenRouterReasoningMetadata } from "./openrouter-reasoning-options.ts";
|
||||
|
||||
export interface OpenRouterModelListItem extends OpenRouterModel {
|
||||
name: string;
|
||||
supported_parameters?: string[];
|
||||
architecture?: { modality?: string; input_modalities?: string[]; output_modalities?: string[] };
|
||||
reasoning?: OpenRouterReasoningMetadata;
|
||||
}
|
||||
|
||||
export interface OpenRouterCatalog {
|
||||
chat: Model<"anthropic-messages" | "openai-completions">[];
|
||||
images: ImageModel<"openrouter-images">[];
|
||||
classifiers: ClassifierModel<"typesafe-system-one">[];
|
||||
}
|
||||
|
||||
function modalities(values: string[] | undefined): ("text" | "image")[] {
|
||||
return Array.from(
|
||||
new Set((values ?? []).filter((value): value is "text" | "image" => value === "text" || value === "image")),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the OpenRouter catalog from the default listing and the
|
||||
* `output_modalities=image` and `output_modalities=decisions` listings. The
|
||||
* default listing omits image-only and decision models, so those come from
|
||||
* the other listings. An upstream model may appear in several results; it then
|
||||
* gets separate entries per operation.
|
||||
*/
|
||||
export function buildOpenRouterCatalog(
|
||||
listed: readonly OpenRouterModelListItem[],
|
||||
imageListed: readonly OpenRouterModelListItem[],
|
||||
decisionListed: 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: openRouterCost(model.pricing),
|
||||
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: openRouterCost(model.pricing),
|
||||
});
|
||||
}
|
||||
|
||||
// Decision models such as TypeSafe's Jev are served through OpenRouter's
|
||||
// TypeSafe-compatible System One endpoint.
|
||||
const classifiers: OpenRouterCatalog["classifiers"] = [];
|
||||
for (const model of decisionListed) {
|
||||
if (classifiers.some((entry) => entry.id === model.id)) continue;
|
||||
if (!model.architecture?.output_modalities?.includes("decisions")) continue;
|
||||
const input = modalities(model.architecture.input_modalities);
|
||||
classifiers.push({
|
||||
type: "classifier",
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
api: "typesafe-system-one",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: input.length > 0 ? input : ["text"],
|
||||
cost: openRouterCost(model.pricing),
|
||||
contextWindow: model.top_provider?.context_length || model.context_length || 4096,
|
||||
});
|
||||
}
|
||||
|
||||
return { chat, images, classifiers };
|
||||
}
|
||||
Reference in New Issue
Block a user