pi-mono/packages/ai/scripts/model-generation/engine.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

219 lines
7.4 KiB
TypeScript

import type { Api, Model } from "../../src/types.ts";
import {
applyModelPatch,
type DefinedRule,
type GeneratedModel,
matchesModel,
type ProviderDefinition,
type RuleContext,
type RulePack,
type SourceContext,
} from "./dsl.ts";
import type { ModelsDevCatalog, ModelsDevModel, UpstreamCatalogs } from "./sources.ts";
export interface CatalogBuildOptions {
strict: boolean;
upstream: UpstreamCatalogs;
providers: readonly ProviderDefinition[];
rulePacks: readonly RulePack[];
}
export interface RuleHit {
stage: string;
rule: DefinedRule;
}
export interface CatalogBuildResult {
/** Generated models grouped by provider, providers in declaration order, models in generation order. */
providers: ReadonlyMap<string, readonly GeneratedModel[]>;
/** Rules that matched no model, excluding rules declared `mayMatchNothing`. */
unmatchedRules: readonly { stage: string; rule: DefinedRule }[];
/** Rules that touched each model, keyed by `provider:id`. */
explain(provider: string, id: string): readonly RuleHit[];
}
interface ModelOrigin {
sourceId: string;
entry: ModelsDevModel;
}
function modelKey(provider: string, id: string): string {
return `${provider}:${id}`;
}
export async function buildModelCatalog(options: CatalogBuildOptions): Promise<CatalogBuildResult> {
const generated = new Map<string, GeneratedModel[]>();
const origins = new WeakMap<GeneratedModel, ModelOrigin>();
const hits = new Map<string, RuleHit[]>();
const matchCounts = new Map<DefinedRule, number>();
// Every rule context can read the models.dev catalog, so load it before any provider runs.
const modelsDev: ModelsDevCatalog = await options.upstream.modelsDev();
const sourceContext: SourceContext = {
strict: options.strict,
upstream: options.upstream,
generated: (provider) => generated.get(provider) ?? [],
attachSource: (model, sourceId, entry) => {
origins.set(model, { sourceId, entry });
},
};
function lookup(provider: string, id: string): GeneratedModel | undefined {
return generated.get(provider)?.find((model) => model.id === id);
}
function contextFor(model: GeneratedModel): RuleContext {
const origin = origins.get(model);
return {
strict: options.strict,
modelsDev,
sourceId: origin?.sourceId,
source: origin?.entry,
reasoningOptions: origin?.entry.reasoning_options,
lookup,
};
}
function recordHit(model: GeneratedModel, stage: string, rule: DefinedRule): void {
matchCounts.set(rule, (matchCounts.get(rule) ?? 0) + 1);
const key = modelKey(model.provider, model.id);
const list = hits.get(key);
if (list) list.push({ stage, rule });
else hits.set(key, [{ stage, rule }]);
}
/** Applies rules in order to each model; returns the surviving models. */
function applyRules(models: GeneratedModel[], rules: readonly DefinedRule[], stage: string): GeneratedModel[] {
const survivors: GeneratedModel[] = [];
for (const model of models) {
let dropped = false;
for (const rule of rules) {
if (!matchesModel(model, rule.match, contextFor(model))) continue;
recordHit(model, stage, rule);
if (rule.drop) {
dropped = true;
break;
}
if (rule.set) applyModelPatch(model, rule.set, "merge");
if (rule.replace) applyModelPatch(model, rule.replace, "replace");
if (rule.apply) rule.apply(model, contextFor(model));
}
if (!dropped) survivors.push(model);
}
return survivors;
}
for (const rule of [
...options.providers.flatMap((provider) => provider.rules ?? []),
...options.rulePacks.flatMap((pack) => pack.rules),
]) {
matchCounts.set(rule, 0);
}
for (const provider of options.providers) {
if (generated.has(provider.id)) throw new Error(`Provider ${provider.id} is declared more than once`);
const sources = provider.source ? (Array.isArray(provider.source) ? provider.source : [provider.source]) : [];
const models: GeneratedModel[] = [];
const seen = new Set<string>();
const add = (model: GeneratedModel): void => {
if (model.provider !== provider.id) {
throw new Error(`Provider ${provider.id} produced model ${model.id} for provider ${model.provider}`);
}
// The first source to produce an id wins; static models fill remaining gaps.
if (seen.has(model.id)) return;
seen.add(model.id);
models.push(model);
};
for (const source of sources) {
for (const model of await source(sourceContext)) add(model);
}
for (const model of provider.models ?? []) add(structuredClone(model));
// Rules see the provider's models, so lookups within the provider work.
generated.set(provider.id, models);
generated.set(provider.id, applyRules(models, provider.rules ?? [], `provider:${provider.id}`));
}
for (const pack of options.rulePacks) {
for (const [providerId, models] of generated) {
generated.set(providerId, applyRules(models, pack.rules, pack.name));
}
}
const providers = new Map<string, readonly GeneratedModel[]>();
for (const [providerId, models] of generated) {
providers.set(providerId, models.map(normalizeModel));
}
const unmatchedRules: { stage: string; rule: DefinedRule }[] = [];
for (const provider of options.providers) {
for (const rule of provider.rules ?? []) {
if (!rule.mayMatchNothing && matchCounts.get(rule) === 0)
unmatchedRules.push({ stage: `provider:${provider.id}`, rule });
}
}
for (const pack of options.rulePacks) {
for (const rule of pack.rules) {
if (!rule.mayMatchNothing && matchCounts.get(rule) === 0) unmatchedRules.push({ stage: pack.name, rule });
}
}
return {
providers,
unmatchedRules,
explain: (provider, id) => hits.get(modelKey(provider, id)) ?? [],
};
}
const MODEL_KEY_ORDER = [
"id",
"name",
"api",
"provider",
"baseUrl",
"reasoning",
"thinkingLevelMap",
"input",
"cost",
"promptCache",
"contextWindow",
"maxTokens",
"samplingParams",
"headers",
"compat",
] as const satisfies readonly (keyof Model<Api>)[];
const THINKING_LEVEL_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
const COST_KEY_ORDER = ["input", "output", "cacheRead", "cacheWrite", "tiers"];
function orderKeys(value: Record<string, unknown>, order: readonly string[]): Record<string, unknown> {
const rank = new Map(order.map((key, index) => [key, index]));
const keys = Object.keys(value).sort((a, b) => {
const rankA = rank.get(a) ?? Number.POSITIVE_INFINITY;
const rankB = rank.get(b) ?? Number.POSITIVE_INFINITY;
return rankA - rankB || (a < b ? -1 : a > b ? 1 : 0);
});
return Object.fromEntries(keys.map((key) => [key, value[key]]));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Gives every generated model the same key order (fixed order for known fields, alphabetical
* for compat flags, level order for thinking maps) and removes empty compat objects, so
* generated data diffs only when values change.
*/
export function normalizeModel(model: GeneratedModel): GeneratedModel {
const record = { ...(model as unknown as Record<string, unknown>) };
for (const key of Object.keys(record)) {
if (record[key] === undefined) delete record[key];
}
if (isRecord(record.compat)) {
if (Object.keys(record.compat).length === 0) delete record.compat;
else record.compat = orderKeys(record.compat, []);
}
if (isRecord(record.thinkingLevelMap))
record.thinkingLevelMap = orderKeys(record.thinkingLevelMap, THINKING_LEVEL_ORDER);
if (isRecord(record.cost)) record.cost = orderKeys(record.cost, COST_KEY_ORDER);
return orderKeys(record, MODEL_KEY_ORDER) as unknown as GeneratedModel;
}