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:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View 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;
}

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);
});
}

File diff suppressed because it is too large Load Diff

View 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]));
}

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`);
}
}
}
@@ -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 };
}

View 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 };
}