pi-mono/packages/ai/scripts/model-generation/sources.ts
Claude 8d95fd138b
feat(ai): declare model catalog generation as providers and rules
Replace the single 3300-line generate-models.ts with declarative provider
definitions and ordered rule packs under scripts/model-generation/.

Each provider is a defineProvider() call: an upstream source (usually the
generic modelsDev() mapper), static models upstream lacks, and rules scoped
to that provider. Cross-provider facts live in rule packs applied in a fixed
order. Every rule() carries a why, matches models with plain data, and
patches them with typed set/replace/apply semantics.

Generated model data is unchanged: 1443 models across 41 providers compare
equal against a snapshot of the same upstream responses, apart from a now
stable key order. Committed models.generated.ts and provider shards are
byte-identical.

Also:
- Bring packages/ai/scripts under tsgo and biome; the old generator had 20+
  type errors that nothing checked.
- Add --upstream-snapshot <dir> to record and replay upstream catalogs and
  --explain <provider>:<model> to list the rules applied to a model.
- Report rules that matched no model so stale corrections get removed.
- Export parseRadiusGatewayConfig so the generator can replay Radius too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ExCU9zaZnN8vB5fKpTAjP
2026-09-20 16:03:22 +00:00

236 lines
6.5 KiB
TypeScript

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
DEFAULT_RADIUS_GATEWAY,
parseRadiusGatewayConfig,
type RadiusGatewayConfig,
} from "../../src/providers/radius-config.ts";
import type { ModelsDevReasoningOption } from "../models-dev-reasoning-options.ts";
import type { OpenRouterReasoningMetadata } from "../openrouter-reasoning-options.ts";
/** One model entry from https://models.dev/api.json. */
export interface ModelsDevModel {
id: string;
name: string;
tool_call?: boolean;
structured_output?: boolean;
reasoning?: boolean;
reasoning_options?: ModelsDevReasoningOption[];
status?: string;
limit?: {
context?: number;
output?: number;
};
cost?: {
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
tiers?: {
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
tier?: {
type?: string;
size?: number;
};
}[];
};
modalities?: {
input?: string[];
output?: string[];
};
provider?: {
npm?: string;
};
}
export interface ModelsDevProvider {
models?: Record<string, ModelsDevModel>;
}
export type ModelsDevCatalog = Record<string, ModelsDevProvider>;
export interface NvidiaNimModelListItem {
id: string;
}
export 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;
}
export interface AiGatewayModel {
id: string;
name?: string;
context_window?: number;
max_tokens?: number;
tags?: string[];
pricing?: {
input?: string | number;
output?: string | number;
input_cache_read?: string | number;
input_cache_write?: string | number;
};
}
export const MODELS_DEV_URL = "https://models.dev/api.json";
export const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
export const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1/models";
export const NVIDIA_NIM_MODELS_URL = "https://integrate.api.nvidia.com/v1/models";
export const RADIUS_CONFIG_URL = new URL("/v1/config", DEFAULT_RADIUS_GATEWAY).href;
export interface UpstreamOptions {
/** Fail instead of continuing with an empty catalog when an upstream source cannot be loaded. */
strict: boolean;
/**
* Directory of recorded upstream responses. Responses present in the directory are
* read instead of fetched; fetched responses are written there for later replays.
*/
snapshotDir?: string;
}
interface UpstreamSource<T> {
name: string;
label: string;
url: string;
parse: (payload: unknown) => T;
empty: () => T;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseListPayload<T>(payload: unknown): T[] {
return isRecord(payload) && Array.isArray(payload.data) ? (payload.data as T[]) : [];
}
const MODELS_DEV_SOURCE: UpstreamSource<ModelsDevCatalog> = {
name: "models-dev",
label: "models.dev",
url: MODELS_DEV_URL,
parse: (payload) => (isRecord(payload) ? (payload as ModelsDevCatalog) : {}),
empty: () => ({}),
};
const OPENROUTER_SOURCE: UpstreamSource<OpenRouterModelListItem[]> = {
name: "openrouter",
label: "OpenRouter",
url: OPENROUTER_MODELS_URL,
parse: parseListPayload<OpenRouterModelListItem>,
empty: () => [],
};
const AI_GATEWAY_SOURCE: UpstreamSource<AiGatewayModel[]> = {
name: "vercel-ai-gateway",
label: "Vercel AI Gateway",
url: AI_GATEWAY_MODELS_URL,
parse: parseListPayload<AiGatewayModel>,
empty: () => [],
};
const NVIDIA_NIM_SOURCE: UpstreamSource<NvidiaNimModelListItem[]> = {
name: "nvidia-nim",
label: "NVIDIA NIM",
url: NVIDIA_NIM_MODELS_URL,
parse: parseListPayload<NvidiaNimModelListItem>,
empty: () => [],
};
const RADIUS_SOURCE: UpstreamSource<RadiusGatewayConfig | undefined> = {
name: "radius",
label: "Radius",
url: RADIUS_CONFIG_URL,
parse: (payload) => {
const config = parseRadiusGatewayConfig(payload, DEFAULT_RADIUS_GATEWAY);
if (config.models.length === 0) throw new Error("Radius API returned no models");
return config;
},
empty: () => undefined,
};
/**
* Lazily loaded, memoized upstream catalogs. Each source is fetched at most once per
* generator run and only when a provider declaration asks for it.
*/
export class UpstreamCatalogs {
readonly options: UpstreamOptions;
private readonly loaded = new Map<string, Promise<unknown>>();
constructor(options: UpstreamOptions) {
this.options = options;
}
modelsDev(): Promise<ModelsDevCatalog> {
return this.load(MODELS_DEV_SOURCE);
}
openRouter(): Promise<OpenRouterModelListItem[]> {
return this.load(OPENROUTER_SOURCE);
}
vercelAiGateway(): Promise<AiGatewayModel[]> {
return this.load(AI_GATEWAY_SOURCE);
}
nvidiaNim(): Promise<NvidiaNimModelListItem[]> {
return this.load(NVIDIA_NIM_SOURCE);
}
radius(): Promise<RadiusGatewayConfig | undefined> {
return this.load(RADIUS_SOURCE);
}
private load<T>(source: UpstreamSource<T>): Promise<T> {
let pending = this.loaded.get(source.name) as Promise<T> | undefined;
if (!pending) {
pending = this.loadUncached(source);
this.loaded.set(source.name, pending);
}
return pending;
}
private async loadUncached<T>(source: UpstreamSource<T>): Promise<T> {
try {
return source.parse(await this.readPayload(source));
} catch (error) {
console.error(`Failed to fetch ${source.label} models:`, error);
if (this.options.strict) throw error;
return source.empty();
}
}
private async readPayload<T>(source: UpstreamSource<T>): Promise<unknown> {
const snapshotPath = this.options.snapshotDir ? join(this.options.snapshotDir, `${source.name}.json`) : undefined;
if (snapshotPath && existsSync(snapshotPath)) {
console.log(`Reading ${source.label} models from ${snapshotPath}...`);
return JSON.parse(readFileSync(snapshotPath, "utf8"));
}
console.log(`Fetching models from ${source.label} API...`);
const response = await fetch(source.url, { headers: { accept: "application/json" } });
if (!response.ok) throw new Error(`${source.label} API returned ${response.status}`);
const text = await response.text();
const payload: unknown = JSON.parse(text);
if (snapshotPath) {
mkdirSync(this.options.snapshotDir as string, { recursive: true });
writeFileSync(snapshotPath, text);
}
return payload;
}
}