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
This commit is contained in:
Claude 2026-09-20 16:03:22 +00:00
parent 3390bd9363
commit 8d95fd138b
No known key found for this signature in database
51 changed files with 3879 additions and 3264 deletions

View file

@ -31,10 +31,12 @@ Create a provider file exporting:
- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers; do not statically import provider implementation modules there.
- Add credential detection in `packages/ai/src/env-api-keys.ts`.
## 4. Model Generation (`packages/ai/scripts/generate-models.ts`)
## 4. Model Generation (`packages/ai/scripts/model-generation/`)
- Add logic to fetch/parse models from the provider source.
- Map to the standardized `Model` interface.
- Add `providers/<provider>.ts` with a `defineProvider()` declaration: a `modelsDev()` source (or a custom source function), static `models` for entries upstream lacks, and `rules` for provider-specific corrections. Register it in `providers/index.ts`.
- Cross-provider facts (thinking levels, tool capabilities) go into the matching pack under `rules/`.
- Every `rule()` needs a `why`. Read `packages/ai/scripts/model-generation/README.md` for matcher, patch, and ordering semantics.
- Verify with `node scripts/generate-models.ts --strict --json-only --json-output /tmp/catalog --explain <provider>:<model>` and `node scripts/diff-model-catalog.mjs <provider>` from the repo root.
## 5. Tests (`packages/ai/test/`)

View file

@ -27,6 +27,7 @@
"includes": [
"packages/*/src/**/*.ts",
"packages/*/test/**/*.ts",
"packages/ai/scripts/**/*.ts",
"packages/session-backends/*/src/**/*.ts",
"packages/session-backends/*/test/**/*.ts",
"packages/coding-agent/examples/**/*.ts",

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Changed
- Reorganized model catalog generation into declarative provider definitions and ordered rule packs under `scripts/model-generation/`, type-checked by `npm run check`. Generated model data is unchanged apart from a stable key order. `generate-models.ts` gained `--upstream-snapshot <dir>` to record and replay upstream catalogs and `--explain <provider>:<model>` to list the rules applied to a model.
## [0.86.1] - 2026-09-20
### Added

View file

@ -1686,10 +1686,10 @@ Create a new API implementation file (for example `bedrock-converse-stream.ts`)
Add a lazy wrapper `src/api/<api-id>.lazy.ts` (`<name>Api()` via `lazyApi()`) so providers can reference the implementation without importing its SDK. Add any root-level `export type` re-exports in `src/index.ts` that should remain available from `@earendil-works/pi-ai`.
#### 3. Model Generation (`scripts/generate-models.ts`, `scripts/generate-image-models.ts`)
#### 3. Model Generation (`scripts/model-generation/`, `scripts/generate-image-models.ts`)
- Add logic to fetch and parse models from the provider's source (e.g., models.dev API)
- Map chat/tool-capable provider model data to the standardized `Model` interface via `scripts/generate-models.ts`; hydration groups the ignored `src/providers/data/<id>.json` values by API, while stable `src/providers/<id>.models.ts` wrappers derive exact model/API types directly from those JSON keys
- Declare the provider in `scripts/model-generation/providers/<id>.ts` with `defineProvider()`: a source (usually `modelsDev()`), static models upstream lacks, and `rules` for provider-specific corrections; register it in `providers/index.ts`. See `scripts/model-generation/README.md`
- Hydration groups the ignored `src/providers/data/<id>.json` values by API, while stable `src/providers/<id>.models.ts` wrappers derive exact model/API types directly from those JSON keys
- Map image-generation provider model data to the standardized `ImagesModel` interface via `scripts/generate-image-models.ts`
- Handle provider-specific quirks (pricing format, capability flags, model ID transformations)

View file

@ -33,10 +33,7 @@ interface OpenRouterModelRecord {
};
}
export function parseOpenRouterImageModels(
payload: unknown,
strict: boolean,
): ImagesModel<"openrouter-images">[] {
export function parseOpenRouterImageModels(payload: unknown, strict: boolean): ImagesModel<"openrouter-images">[] {
const data =
typeof payload === "object" && payload !== null
? (payload as { data?: OpenRouterModelRecord[] }).data

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
import { createCanvas } from "canvas";
import { mkdirSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
@ -30,4 +30,4 @@ const outputPath = join(__dirname, "..", "test", "data", "red-circle.png");
mkdirSync(join(__dirname, "..", "test", "data"), { recursive: true });
writeFileSync(outputPath, buffer);
console.log(`Generated test image at: ${outputPath}`);
console.log(`Generated test image at: ${outputPath}`);

View file

@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
export const MODEL_DATA_SCHEMA_VERSION = 3;
@ -14,8 +14,7 @@ export interface ModelDataManifest {
files: Record<string, string>;
}
const MODEL_DATA_IMPORT_PATTERN =
/^import \{ [A-Z][A-Z0-9_]*_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm;
const MODEL_DATA_IMPORT_PATTERN = /^import \{ [A-Z][A-Z0-9_]*_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm;
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
@ -34,7 +33,10 @@ function describeSetDifference(expected: readonly string[], actual: readonly str
const actualSet = new Set(actual);
const missing = expected.filter((value) => !actualSet.has(value));
const extra = actual.filter((value) => !expectedSet.has(value));
return [missing.length > 0 ? `missing: ${missing.join(", ")}` : "", extra.length > 0 ? `extra: ${extra.join(", ")}` : ""]
return [
missing.length > 0 ? `missing: ${missing.join(", ")}` : "",
extra.length > 0 ? `extra: ${extra.join(", ")}` : "",
]
.filter(Boolean)
.join("; ");
}
@ -149,7 +151,8 @@ function validateModelValue(
errors.push(`${label} must be an object`);
return;
}
if (value.id !== modelId) errors.push(`${label} has id ${JSON.stringify(value.id)}, expected ${JSON.stringify(modelId)}`);
if (value.id !== modelId)
errors.push(`${label} has id ${JSON.stringify(value.id)}, expected ${JSON.stringify(modelId)}`);
if (value.provider !== providerId) {
errors.push(`${label} has provider ${JSON.stringify(value.provider)}, expected ${JSON.stringify(providerId)}`);
}
@ -203,7 +206,9 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi
.filter((entry) => entry.endsWith(".json") && entry !== MODEL_DATA_MANIFEST_FILE)
.sort();
if (!sameStrings(expectedFiles, actualFiles)) {
errors.push(`provider data files do not match the generated catalog (${describeSetDifference(expectedFiles, actualFiles)})`);
errors.push(
`provider data files do not match the generated catalog (${describeSetDifference(expectedFiles, actualFiles)})`,
);
}
const manifestPath = join(dataDir, MODEL_DATA_MANIFEST_FILE);
@ -225,7 +230,9 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi
else {
const manifestFileNames = Object.keys(manifestFiles).sort();
if (!sameStrings(expectedFiles, manifestFileNames)) {
errors.push(`manifest file hashes do not match provider data files (${describeSetDifference(expectedFiles, manifestFileNames)})`);
errors.push(
`manifest file hashes do not match provider data files (${describeSetDifference(expectedFiles, manifestFileNames)})`,
);
}
}
@ -259,7 +266,9 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi
const expectedModelIds = Object.keys(expectedModels).sort();
const actualModelIds = Array.from(actualModels.keys()).sort();
if (!sameStrings(expectedModelIds, actualModelIds)) {
errors.push(`${filename} model IDs do not match the generated catalog (${describeSetDifference(expectedModelIds, actualModelIds)})`);
errors.push(
`${filename} model IDs do not match the generated catalog (${describeSetDifference(expectedModelIds, actualModelIds)})`,
);
}
for (const [modelId, expectedApi] of Object.entries(expectedModels)) {
const actualApi = actualModels.get(modelId);

View file

@ -0,0 +1,166 @@
# Model catalog generation
`scripts/generate-models.ts` builds pi's built-in model catalog. It reads upstream
catalogs (models.dev, OpenRouter, Vercel AI Gateway, Radius, NVIDIA NIM), applies pi's
own knowledge about each model, and writes `src/providers/data/<provider>.json` plus the
typed `src/providers/<provider>.models.ts` wrappers.
This directory holds that knowledge as declarations instead of code paths.
## Why declarations
The previous generator was one 3300-line script. Three kinds of logic were interleaved:
1. **Ingestion**: ~25 near-identical loops mapping a models.dev provider onto `Model`.
2. **Hand-maintained models**: providers without an upstream catalog.
3. **Corrections**: hundreds of `if (provider === X && id === Y)` patches for context
windows, prices, thinking levels, and compat flags, spread over a dozen passes whose
order mattered and was implicit.
Adding one fact ("this model accepts effort `xhigh`") meant finding the right pass,
matching the right id spelling, and hoping nothing later overwrote it. Nothing type-checked
the script, so a misspelled compat flag was silently dropped from the catalog.
Now a fact is one `rule()` with a `why`, in one place, applied in a documented order, and
`npm run check` rejects unknown flags.
## Layout
```
scripts/generate-models.ts CLI: options, run the engine, write outputs
scripts/model-generation/
dsl.ts rule(), defineProvider(), matchers, patch semantics
engine.ts applies sources, provider rules, then rule packs
sources.ts upstream fetchers, response types, snapshot replay
models-dev.ts generic models.dev -> Model mapping
output.ts JSON data, manifest, generated TypeScript shards
shared.ts constants used by more than one file
providers/<provider>.ts one declaration per provider (families share a file)
providers/index.ts generation order
rules/<concern>.ts catalog-wide rule packs
rules/index.ts pack order
```
## Concepts
### Provider definition
```ts
export const xai = defineProvider({
id: "xai",
source: modelsDev({
provider: "xai",
key: "xai", // models.dev provider key
api: "openai-responses",
baseUrl: "https://api.x.ai/v1",
compat: { supportsLongCacheRetention: false },
}),
models: [/* hand-maintained Model objects, added when the source lacks the id */],
rules: [/* corrections scoped to this provider */],
});
```
`source` is a function `(context) => Promise<Model[]>`. `modelsDev()` builds one from
options; OpenRouter, Vercel, Radius, NVIDIA and Azure use small custom functions. A
provider may list several sources; the first source to produce an id wins.
`modelsDev()` handles the repeated parts of ingestion and exposes a few knobs for the
recurring variations: `include`, `skipDeprecated`, `mapId`, `route` (per-model api,
baseUrl, compat), `aliases`, `defaults`, `costTiers`. Everything else is a rule.
### Rules
```ts
rule({
why: "OpenAI rejects minimal effort on GPT-5.5.",
match: { provider: "openai", id: "gpt-5.5" },
set: { thinkingLevelMap: { minimal: null } },
});
```
- `match` is plain data where possible: `provider`, `api`, `id`, `idPrefix`, `idIncludes`,
`idPattern`, `reasoning`, `not`. Arrays mean "any of". `when(model, context)` is the
escape hatch for computed conditions.
- `set` merges into the model. Object fields (`compat`, `cost`, `thinkingLevelMap`,
`headers`, `promptCache`) merge one level deep; other fields are assigned.
- `replace` assigns whole values. `apply(model, context)` computes a patch. `drop: true`
removes the model.
- `context` gives rules the models.dev entry the model came from (`source`,
`reasoningOptions`), the full models.dev catalog, and `lookup(provider, id)` for other
generated models.
- `why` is required. Put the date, link, or issue number there for anything that
corrects upstream data.
### Order of application
1. For each provider in `providers/index.ts`: run its sources, add static `models` that
the sources did not produce, then apply the provider's `rules` in order.
2. For each pack in `rules/index.ts`, for each model, apply the pack's rules in order.
3. Normalize: drop empty `compat`, give every model the same key order.
Rules within a pack run per model in array order, so a later rule may refine an earlier
one and `when` conditions see the model's current state. Packs are ordered by dependency:
transport compat first, then models.dev effort options, then thinking-level corrections,
then tool and cache capabilities, then fallbacks that read other models' final prices.
Where a correction lives:
- Single provider: that provider's `rules`.
- Several providers, one concern: the matching pack (`thinking-levels`, `tool-capabilities`,
...). `catalog-overrides` holds cross-provider data corrections that fit nowhere else.
## Diverging from models.dev
The generator trusts models.dev by default and records every divergence as a rule. To keep
divergences from piling up:
- Each run prints rules that matched nothing. Delete the rule, or mark it
`mayMatchNothing: true` when it targets a model upstream may legitimately stop listing.
- `--explain <provider>:<id>` prints every rule that touched a model, in order, with its
`why` and source location.
- `node scripts/diff-model-catalog.mjs [provider]` (repo root) diffs the generated catalog
between HEAD and the worktree.
- `--upstream-snapshot <dir>` records upstream responses on first use and replays them
afterwards, so two runs can be compared without upstream drift.
## Adding a field
New per-model metadata (for example image input limits) is a change to `Model` in
`src/types.ts` plus a rule or a `modelsDev()` option. Because `set` is typed against
`Model`, the field is immediately usable in every rule:
```ts
rule({
why: "Anthropic documents 8000x8000 px and 5 MB per image. https://...",
match: { idPrefix: "claude-" },
set: { imageLimits: { maxDimension: 8000, maxBytes: 5_000_000 } },
});
```
Matching on the id rather than the provider expresses "this is a property of the model,
wherever it is served", which the old per-provider passes could not do cleanly.
## Verifying a change
```sh
node scripts/generate-models.ts --strict --json-only --json-output /tmp/catalog --upstream-snapshot /tmp/upstream
node scripts/generate-models.ts --strict --json-only --json-output /tmp/catalog --explain openai:gpt-5.5
node ../../scripts/diff-model-catalog.mjs openai
npm run check
```
Generation tests spawn the script with a mocked `fetch`; see
`test/generate-models-strict.test.ts` and `test/fireworks-model-generation.test.ts`.
## Not done yet
This is the first step. The generated data is unchanged except for key order. Candidates
for follow-up, each a deliberate behavior change:
- `costTiers` is only enabled for GitHub Copilot; other providers still drop models.dev
context-size price tiers.
- `skipDeprecated` is opt-in per provider.
- `detectOpenAICompletionsCompat` (endpoint-family request defaults) is still a function
keyed on provider ids and base URLs. It could become a table of endpoint families.
- Rules whose `set` values already equal the upstream value could be reported as redundant,
so corrections retire when models.dev catches up.

View file

@ -0,0 +1,231 @@
import type { Api, Model, ModelCost, ModelPromptCache, ThinkingLevelMap } from "../../src/types.ts";
import type { ModelsDevReasoningOption } from "../models-dev-reasoning-options.ts";
import type { ModelsDevCatalog, ModelsDevModel, UpstreamCatalogs } from "./sources.ts";
/**
* Declarative model catalog rules.
*
* A provider declaration says where its models come from (`source`), which
* hand-maintained entries to add when the source lacks them (`models`), and
* which corrections apply to its models (`rules`). Cross-provider concerns
* (thinking levels, transport compat flags) live in ordered rule packs.
*
* Every rule carries a `why`, matches models with plain data where possible,
* and patches them with `set` (merged into the model) or `replace`. `apply`
* and `when` are escape hatches for values that must be computed.
*/
export type GeneratedModel = Model<Api>;
type CompatUnion = NonNullable<GeneratedModel["compat"]>;
type KeysOfUnion<T> = T extends unknown ? keyof T : never;
type ValueOfUnion<T, K extends PropertyKey> = T extends unknown ? (K extends keyof T ? T[K] : never) : never;
/**
* Union of every API's compat flags. Unknown flag names fail type checking; mixing
* flags across APIs does not, so keep rules scoped to one `api` where it matters.
*/
export type CompatPatch = { [K in KeysOfUnion<CompatUnion>]?: ValueOfUnion<CompatUnion, K> };
/** Fields a rule may change. Identity fields (`id`, `provider`, `api`) are fixed by the source. */
export interface ModelPatch {
name?: string;
baseUrl?: string;
reasoning?: boolean;
thinkingLevelMap?: ThinkingLevelMap;
input?: GeneratedModel["input"];
cost?: Partial<ModelCost>;
promptCache?: ModelPromptCache;
contextWindow?: number;
maxTokens?: number;
samplingParams?: Record<string, unknown>;
headers?: Record<string, string>;
compat?: CompatPatch;
}
export interface RuleContext {
readonly strict: boolean;
/** The full models.dev catalog, for rules that read sibling providers (for example reference prices). */
readonly modelsDev: ModelsDevCatalog;
/** models.dev key the current model was generated from, when it came from models.dev. */
readonly sourceId: string | undefined;
/** models.dev entry the current model was generated from, when it came from models.dev. */
readonly source: ModelsDevModel | undefined;
/** Shorthand for `source?.reasoning_options`. */
readonly reasoningOptions: readonly ModelsDevReasoningOption[] | undefined;
/** Another generated model in its current state. Providers are generated in declaration order. */
lookup(provider: string, id: string): GeneratedModel | undefined;
}
export interface ModelMatch {
provider?: string | readonly string[];
api?: Api | readonly Api[];
/** Exact model id(s). */
id?: string | readonly string[];
idPrefix?: string | readonly string[];
/** Substring(s); any match counts. */
idIncludes?: string | readonly string[];
idPattern?: RegExp | readonly RegExp[];
reasoning?: boolean;
/** Excludes models matching this nested matcher. */
not?: ModelMatch;
/** Computed condition evaluated against the model's current state. */
when?: (model: GeneratedModel, context: RuleContext) => boolean;
}
export interface ModelRule {
/** Why the rule exists. Include links, dates, or issue numbers for upstream corrections. */
why: string;
match: ModelMatch;
/** Merged into the model: object fields (`compat`, `cost`, `thinkingLevelMap`, ...) merge one level deep, everything else is assigned. */
set?: ModelPatch;
/** Assigned to the model wholesale. */
replace?: ModelPatch;
/** Computed patch for values that depend on upstream metadata or other models. */
apply?: (model: GeneratedModel, context: RuleContext) => void;
/** Remove matching models from the catalog. */
drop?: boolean;
/** Suppress the unmatched-rule report, for rules targeting models upstream may stop listing. */
mayMatchNothing?: boolean;
}
export interface DefinedRule extends ModelRule {
/** Source location of the `rule()` call, for reports. */
readonly definedAt: string;
}
const DSL_FILE_PATTERN = /model-generation[\\/]dsl\.ts/;
function captureDefinitionSite(): string {
const stack = new Error().stack?.split("\n") ?? [];
const frame = stack.slice(1).find((line) => line.includes("model-generation") && !DSL_FILE_PATTERN.test(line));
if (!frame) return "unknown";
const match = /\(?((?:file:\/\/)?[^()]*?):(\d+):\d+\)?\s*$/.exec(frame.trim());
if (!match) return frame.trim();
const path = match[1].replace(/^file:\/\//, "");
const relative = path.slice(path.lastIndexOf("model-generation"));
return `${relative}:${match[2]}`;
}
export function rule(definition: ModelRule): DefinedRule {
if (!definition.set && !definition.replace && !definition.apply && !definition.drop) {
throw new Error(`Rule "${definition.why}" does not change anything`);
}
return { ...definition, definedAt: captureDefinitionSite() };
}
function toArray<T>(value: T | readonly T[]): readonly T[] {
return Array.isArray(value) ? (value as readonly T[]) : [value as T];
}
export function matchesModel(model: GeneratedModel, match: ModelMatch, context: RuleContext): boolean {
if (match.provider !== undefined && !toArray(match.provider).includes(model.provider)) return false;
if (match.api !== undefined && !toArray(match.api).includes(model.api)) return false;
if (match.id !== undefined && !toArray(match.id).includes(model.id)) return false;
if (match.idPrefix !== undefined && !toArray(match.idPrefix).some((prefix) => model.id.startsWith(prefix)))
return false;
if (match.idIncludes !== undefined && !toArray(match.idIncludes).some((part) => model.id.includes(part)))
return false;
if (match.idPattern !== undefined && !toArray(match.idPattern).some((pattern) => pattern.test(model.id)))
return false;
if (match.reasoning !== undefined && model.reasoning !== match.reasoning) return false;
if (match.not !== undefined && matchesModel(model, match.not, context)) return false;
if (match.when !== undefined && !match.when(model, context)) return false;
return true;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Model fields that `set` merges one level deep instead of replacing. */
const MERGED_FIELDS = new Set<keyof ModelPatch>([
"compat",
"cost",
"thinkingLevelMap",
"headers",
"promptCache",
"samplingParams",
]);
function cloneValue<T>(value: T): T {
if (Array.isArray(value)) return [...value] as T;
if (isPlainObject(value)) return { ...value } as T;
return value;
}
export function applyModelPatch(model: GeneratedModel, patch: ModelPatch, mode: "merge" | "replace"): void {
const target = model as unknown as Record<string, unknown>;
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) continue;
const existing = target[key];
if (
mode === "merge" &&
MERGED_FIELDS.has(key as keyof ModelPatch) &&
isPlainObject(value) &&
isPlainObject(existing)
) {
target[key] = { ...existing, ...value };
continue;
}
target[key] = cloneValue(value);
}
}
/** The model's compat flags read through the merged flag type, for conditions on any API's flags. */
export function compatOf(model: GeneratedModel): CompatPatch {
return (model.compat ?? {}) as CompatPatch;
}
/** Merges `patch` into `model.compat`. Rules use `set: { compat }`; sources use this directly. */
export function mergeCompat(model: GeneratedModel, patch: CompatPatch): void {
applyModelPatch(model, { compat: patch }, "merge");
}
export function mergeThinkingLevelMap(model: GeneratedModel, map: ThinkingLevelMap): void {
applyModelPatch(model, { thinkingLevelMap: map }, "merge");
}
export interface SourceContext {
readonly strict: boolean;
readonly upstream: UpstreamCatalogs;
/** Models already generated for another provider, in their post-rule state. */
generated(provider: string): readonly GeneratedModel[];
/** Remember the models.dev entry a model came from so rules can read it. */
attachSource(model: GeneratedModel, sourceId: string, entry: ModelsDevModel): void;
}
export type ModelSource = (context: SourceContext) => Promise<GeneratedModel[]>;
export interface ProviderDefinition {
id: string;
/** Upstream source(s). Concatenated in order; the first model with a given id wins. */
source?: ModelSource | readonly ModelSource[];
/** Hand-maintained models, added when no source produced the same id. */
models?: readonly GeneratedModel[];
/** Rules scoped to this provider's models, applied after sources and static models. */
rules?: readonly DefinedRule[];
}
export function defineProvider(definition: ProviderDefinition): ProviderDefinition {
for (const model of definition.models ?? []) {
if (model.provider !== definition.id) {
throw new Error(`Static model ${model.id} declares provider ${model.provider} inside ${definition.id}`);
}
}
return definition;
}
/** A named, ordered group of rules applied to the whole catalog. */
export interface RulePack {
name: string;
rules: readonly DefinedRule[];
}
export function definePack(name: string, rules: readonly DefinedRule[]): RulePack {
return { name, rules };
}
export function roundCost(value: number): number {
return Number(value.toFixed(6));
}

View file

@ -0,0 +1,219 @@
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;
}

View file

@ -0,0 +1,115 @@
import type { Api, ModelCost } from "../../src/types.ts";
import type { CompatPatch, GeneratedModel, ModelSource } from "./dsl.ts";
import type { ModelsDevModel } from "./sources.ts";
/** Per-model transport overrides resolved from the upstream entry. */
export interface ModelsDevRoute {
api?: Api;
baseUrl?: string;
compat?: CompatPatch;
headers?: Record<string, string>;
}
export interface ModelsDevSourceOptions {
/** Provider id in the generated catalog. */
provider: string;
/** models.dev provider key(s); the first key present in the catalog is used. */
key: string | readonly string[];
api: Api;
baseUrl: string | ((id: string, entry: ModelsDevModel) => string);
compat?: CompatPatch;
headers?: Record<string, string>;
/** Skip entries with `status: "deprecated"`. */
skipDeprecated?: boolean;
/** Extra filter beyond the always-applied `tool_call` check. Receives the models.dev id. */
include?: (id: string, entry: ModelsDevModel, models: Readonly<Record<string, ModelsDevModel>>) => boolean;
/** Rename an entry. Return `undefined` to skip it. Receives the models.dev id. */
mapId?: (id: string, entry: ModelsDevModel) => string | undefined;
/** Per-model api, baseUrl, compat, or headers. Receives the models.dev id. */
route?: (id: string, entry: ModelsDevModel) => ModelsDevRoute | undefined;
/**
* Entries whose capabilities, limits, and prices come from another entry of the same
* provider (`{ alias: target }`). The alias keeps its own id and name.
*/
aliases?: Readonly<Record<string, string>>;
/** Fallbacks when models.dev omits limits. Default: 4096 for both. */
defaults?: { contextWindow?: number; maxTokens?: number };
/** Import models.dev context-size price tiers as `cost.tiers`. */
costTiers?: boolean;
}
export function getModelsDevCost(cost: ModelsDevModel["cost"], includeTiers = false): ModelCost {
const tiers = includeTiers
? cost?.tiers?.flatMap((tier) => {
const context = tier.tier;
if (context?.type !== "context" || context.size === undefined) return [];
return [
{
inputTokensAbove: context.size,
input: tier.input || 0,
output: tier.output || 0,
cacheRead: tier.cache_read || 0,
cacheWrite: tier.cache_write || 0,
},
];
})
: undefined;
return {
input: cost?.input || 0,
output: cost?.output || 0,
cacheRead: cost?.cache_read || 0,
cacheWrite: cost?.cache_write || 0,
...(tiers && tiers.length > 0 ? { tiers } : {}),
};
}
export function getModelsDevInput(entry: ModelsDevModel): GeneratedModel["input"] {
return entry.modalities?.input?.includes("image") ? ["text", "image"] : ["text"];
}
/** Maps tool-capable models.dev entries of one provider onto pi's `Model` shape. */
export function modelsDev(options: ModelsDevSourceOptions): ModelSource {
return async (context) => {
const catalog = await context.upstream.modelsDev();
const keys = Array.isArray(options.key) ? (options.key as readonly string[]) : [options.key as string];
const key = keys.find((candidate) => catalog[candidate]?.models);
const models = key ? (catalog[key]?.models ?? {}) : {};
const generated: GeneratedModel[] = [];
for (const [sourceId, entry] of Object.entries(models)) {
if (entry.tool_call !== true) continue;
if (options.skipDeprecated && entry.status === "deprecated") continue;
if (options.include && !options.include(sourceId, entry, models)) continue;
const id = options.mapId ? options.mapId(sourceId, entry) : sourceId;
if (id === undefined) continue;
const aliasTarget = options.aliases?.[sourceId];
const data = aliasTarget ? (models[aliasTarget] ?? entry) : entry;
const route = options.route?.(sourceId, entry);
const compat = route?.compat ?? options.compat;
const headers = route?.headers ?? options.headers;
const baseUrl =
route?.baseUrl ?? (typeof options.baseUrl === "string" ? options.baseUrl : options.baseUrl(id, entry));
const model: GeneratedModel = {
id,
name: entry.name || id,
api: route?.api ?? options.api,
provider: options.provider,
baseUrl,
reasoning: data.reasoning === true,
input: getModelsDevInput(data),
cost: getModelsDevCost(data.cost, options.costTiers === true),
contextWindow: data.limit?.context || options.defaults?.contextWindow || 4096,
maxTokens: data.limit?.output || options.defaults?.maxTokens || 4096,
...(headers ? { headers: { ...headers } } : {}),
...(compat ? { compat: { ...compat } as GeneratedModel["compat"] } : {}),
};
context.attachSource(model, sourceId, data);
generated.push(model);
}
return generated;
};
}

View file

@ -0,0 +1,197 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import type { Api, Model } from "../../src/types.ts";
import {
createModelDataManifest,
MODEL_DATA_MANIFEST_FILE,
type ModelDataStructure,
readModelDataProviderIds,
validateGeneratedModelData,
validateModelDataDirectory,
} from "../model-data.ts";
import type { GeneratedModel } from "./dsl.ts";
export interface OutputOptions {
packageRoot: string;
/** Only refresh the ignored JSON data for providers already present in the committed catalog. */
dataOnly: boolean;
/** Skip the package sources and only write the public JSON catalog. */
jsonOnly: boolean;
jsonOutputDir: string | undefined;
pretty: boolean;
}
export type SortedCatalog = Record<string, Record<string, GeneratedModel>>;
/** Providers and models sorted by id, for deterministic output. */
export function sortCatalog(providers: ReadonlyMap<string, readonly GeneratedModel[]>): SortedCatalog {
const catalog: SortedCatalog = {};
for (const providerId of Array.from(providers.keys()).sort()) {
const models = providers.get(providerId) ?? [];
catalog[providerId] = {};
for (const model of [...models].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
catalog[providerId][model.id] = model;
}
}
return catalog;
}
const GENERATED_HEADER = `// This file is auto-generated by scripts/generate-models.ts
// Do not edit manually - run 'npm run generate-models' to update
`;
function catalogConstName(providerId: string): string {
return `${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_MODELS`;
}
export function writeCatalog(catalog: SortedCatalog, options: OutputOptions): void {
const sortedProviderIds = Object.keys(catalog);
const serializeJson = (value: unknown) => `${JSON.stringify(value, null, options.pretty ? 2 : undefined)}\n`;
const writeJson = (path: string, value: unknown) => writeFileSync(path, serializeJson(value));
const generatedDataProviderIds = options.dataOnly
? readModelDataProviderIds(options.packageRoot)
: sortedProviderIds;
const missingProviderIds = generatedDataProviderIds.filter((providerId) => !catalog[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>>>> = {};
const modelDataStructure: ModelDataStructure = {};
for (const providerId of generatedDataProviderIds) {
const models = catalog[providerId];
generatedDataProviders[providerId] = {};
modelDataStructure[providerId] = {};
const apiIds = Array.from(new Set(Object.values(models).map((model) => model.api))).sort();
for (const api of apiIds) {
generatedDataProviders[providerId][api] = {};
for (const [modelId, model] of Object.entries(models)) {
if (model.api !== api) continue;
generatedDataProviders[providerId][api][modelId] = model;
modelDataStructure[providerId][modelId] = api;
}
}
}
const generatedAt = new Date().toISOString();
if (!options.jsonOnly) {
// Stage and validate all provider values before replacing the current generated data.
const providersDir = join(options.packageRoot, "src/providers");
const dataDir = join(providersDir, "data");
const stagingRoot = mkdtempSync(join(providersDir, ".model-generation-"));
const stagedDataDir = join(stagingRoot, "data");
const previousDataDir = join(stagingRoot, "previous-data");
let restoreGeneratedCatalog: (() => void) | undefined;
try {
mkdirSync(stagedDataDir, { recursive: true });
const fileContents: Record<string, string> = {};
for (const providerId of generatedDataProviderIds) {
const filename = `${providerId}.json`;
const content = serializeJson(generatedDataProviders[providerId]);
fileContents[filename] = content;
writeFileSync(join(stagedDataDir, filename), content);
}
writeJson(
join(stagedDataDir, MODEL_DATA_MANIFEST_FILE),
createModelDataManifest(modelDataStructure, fileContents, generatedAt),
);
validateModelDataDirectory(modelDataStructure, stagedDataDir);
if (!options.dataOnly) {
const previousShardContents = new Map(
readdirSync(providersDir)
.filter((entry) => entry.endsWith(".models.ts"))
.map((entry) => [entry, readFileSync(join(providersDir, entry), "utf8")] as const),
);
const aggregatorPath = join(options.packageRoot, "src/models.generated.ts");
const previousAggregator = readFileSync(aggregatorPath, "utf8");
restoreGeneratedCatalog = () => {
for (const entry of readdirSync(providersDir)) {
if (entry.endsWith(".models.ts")) rmSync(join(providersDir, entry));
}
for (const [entry, content] of previousShardContents) {
writeFileSync(join(providersDir, entry), content);
}
writeFileSync(aggregatorPath, previousAggregator);
};
const generatedShardFiles = new Set<string>();
for (const providerId of sortedProviderIds) {
let output = GENERATED_HEADER;
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`;
const filename = `${providerId}.models.ts`;
generatedShardFiles.add(filename);
writeFileSync(join(providersDir, filename), output);
}
for (const entry of readdirSync(providersDir)) {
if (entry.endsWith(".models.ts") && !generatedShardFiles.has(entry)) rmSync(join(providersDir, entry));
}
let output = GENERATED_HEADER;
for (const providerId of sortedProviderIds) {
output += `import { ${catalogConstName(providerId)} } from "./providers/${providerId}.models.ts";\n`;
}
output += `\nexport const MODELS: {\n`;
for (const providerId of sortedProviderIds) {
output += `\treadonly ${JSON.stringify(providerId)}: typeof ${catalogConstName(providerId)};\n`;
}
output += `} = {\n`;
for (const providerId of sortedProviderIds) {
output += `\t${JSON.stringify(providerId)}: ${catalogConstName(providerId)},\n`;
}
output += `};\n`;
writeFileSync(aggregatorPath, output);
console.log("Generated provider catalogs and src/models.generated.ts");
}
const hadPreviousData = existsSync(dataDir);
if (hadPreviousData) renameSync(dataDir, previousDataDir);
try {
renameSync(stagedDataDir, dataDir);
validateGeneratedModelData(options.packageRoot);
} catch (error) {
rmSync(dataDir, { recursive: true, force: true });
if (hadPreviousData && existsSync(previousDataDir)) renameSync(previousDataDir, dataDir);
throw error;
}
restoreGeneratedCatalog = undefined;
console.log(
options.dataOnly
? "Hydrated JSON model values under src/providers/data/"
: "Generated JSON model values under src/providers/data/",
);
} catch (error) {
restoreGeneratedCatalog?.();
throw error;
} finally {
rmSync(stagingRoot, { recursive: true, force: true });
}
}
if (options.jsonOutputDir) {
const providerOutputDir = join(options.jsonOutputDir, "providers");
rmSync(options.jsonOutputDir, { recursive: true, force: true });
mkdirSync(providerOutputDir, { recursive: true });
writeJson(join(options.jsonOutputDir, "models.json"), catalog);
writeJson(join(options.jsonOutputDir, "providers.json"), sortedProviderIds);
for (const providerId of sortedProviderIds) {
writeJson(join(providerOutputDir, `${providerId}.json`), catalog[providerId]);
}
console.log(`Generated JSON model catalog under ${options.jsonOutputDir}`);
}
}

View file

@ -0,0 +1,25 @@
import { defineProvider } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
const BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS = new Set(["anthropic.claude-opus-5"]);
export const amazonBedrock = defineProvider({
id: "amazon-bedrock",
source: modelsDev({
provider: "amazon-bedrock",
key: "amazon-bedrock",
api: "bedrock-converse-stream",
baseUrl: (id) =>
id.startsWith("eu.")
? "https://bedrock-runtime.eu-central-1.amazonaws.com"
: "https://bedrock-runtime.us-east-1.amazonaws.com",
include: (id) =>
// Only reachable through inference profiles.
!BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS.has(id) &&
// Jamba does not support tool use in streaming mode.
!id.startsWith("ai21.jamba") &&
// Mistral 7B does not support system messages.
!id.startsWith("mistral.mistral-7b-instruct-v0"),
route: (_id, entry) => (entry.structured_output === true ? { compat: { supportsStrictMode: true } } : undefined),
}),
});

View file

@ -0,0 +1,12 @@
import { defineProvider } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
export const anthropic = defineProvider({
id: "anthropic",
source: modelsDev({
provider: "anthropic",
key: "anthropic",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
}),
});

View file

@ -0,0 +1,42 @@
import { defineProvider, rule } from "../dsl.ts";
// Azure Foundry deploys these with larger context windows than OpenAI's own short-tier defaults.
// See models-sold-directly-by-azure docs.
const AZURE_CONTEXT_WINDOW_OVERRIDES: Record<string, number> = {
"gpt-5.4": 1050000,
"gpt-5.5": 1050000,
"gpt-5.6-luna": 1050000,
"gpt-5.6-sol": 1050000,
"gpt-5.6-terra": 1050000,
};
export const azureOpenaiResponses = defineProvider({
id: "azure-openai-responses",
// Mirrors the OpenAI Responses catalog after OpenAI's own corrections. The base URL is
// per deployment and long-context price tiers do not apply to Azure billing.
source: async (context) =>
context
.generated("openai")
.filter((model) => model.api === "openai-responses")
.map((model) => ({
...model,
api: "azure-openai-responses",
provider: "azure-openai-responses",
baseUrl: "",
cost: {
input: model.cost.input,
output: model.cost.output,
cacheRead: model.cost.cacheRead,
cacheWrite: model.cost.cacheWrite,
},
})),
rules: [
rule({
why: "Azure Foundry deploys these models with larger context windows than OpenAI's short-tier defaults.",
match: { id: Object.keys(AZURE_CONTEXT_WINDOW_OVERRIDES) },
apply: (model) => {
model.contextWindow = AZURE_CONTEXT_WINDOW_OVERRIDES[model.id];
},
}),
],
});

View file

@ -0,0 +1,104 @@
import type { OpenAICompletionsCompat, ThinkingLevelMap } from "../../../src/types.ts";
import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts";
import { defineProvider, rule } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
import type { ModelsDevModel } from "../sources.ts";
const BASETEN_BASE_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
supportsUsageInStreaming: true,
maxTokensField: "max_tokens",
supportsStrictMode: true,
// Baseten automatic prompt caching needs session affinity so related
// requests land on the same replica. See:
// https://docs.baseten.co/inference/model-apis/pricing-and-limits
sendSessionAffinityHeaders: true,
supportsLongCacheRetention: false,
};
const BASETEN_EFFORT_COMPAT: OpenAICompletionsCompat = {
...BASETEN_BASE_COMPAT,
supportsReasoningEffort: true,
thinkingFormat: "openai",
};
const BASETEN_TOGGLE_COMPAT: OpenAICompletionsCompat = {
...BASETEN_BASE_COMPAT,
thinkingFormat: "baseten",
chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } },
};
const BASETEN_TOGGLE_EFFORT_COMPAT: OpenAICompletionsCompat = {
...BASETEN_EFFORT_COMPAT,
thinkingFormat: "baseten",
chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } },
};
const BASETEN_TOGGLE_THINKING_LEVEL_MAP: ThinkingLevelMap = {
off: "off",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: null,
};
const BASETEN_GLM52_THINKING_LEVEL_MAP: ThinkingLevelMap = {
off: "none",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: "max",
};
// GLM-5.2 supports toggle and effort controls that models.dev does not catalog.
const BASETEN_GLM52_MODEL_IDS = new Set(["zai-org/GLM-5.2", "zai-org/GLM-5.2-Fast"]);
function getBasetenReasoningControls(id: string, entry: ModelsDevModel) {
const reasoningOptions = entry.reasoning_options ?? [];
const isGlm52 = BASETEN_GLM52_MODEL_IDS.has(id);
const supportsToggle = isGlm52 || reasoningOptions.some((option) => option.type === "toggle");
const supportsEffort = isGlm52 || reasoningOptions.some((option) => option.type === "effort");
const compat =
supportsToggle && supportsEffort
? BASETEN_TOGGLE_EFFORT_COMPAT
: supportsToggle
? BASETEN_TOGGLE_COMPAT
: supportsEffort
? BASETEN_EFFORT_COMPAT
: BASETEN_BASE_COMPAT;
const thinkingLevelMap = isGlm52
? BASETEN_GLM52_THINKING_LEVEL_MAP
: supportsToggle
? BASETEN_TOGGLE_THINKING_LEVEL_MAP
: getEffortThinkingLevelMap(reasoningOptions);
return { compat, thinkingLevelMap };
}
export const baseten = defineProvider({
id: "baseten",
source: modelsDev({
provider: "baseten",
key: "baseten",
api: "openai-completions",
baseUrl: "https://inference.baseten.co/v1",
skipDeprecated: true,
route: (id, entry) => ({ compat: getBasetenReasoningControls(id, entry).compat }),
}),
rules: [
rule({
why: "Baseten reasoning controls follow the cataloged toggle/effort options, with GLM-5.2's undocumented controls filled in.",
match: {},
apply: (model, context) => {
if (!context.source) return;
const { thinkingLevelMap } = getBasetenReasoningControls(model.id, context.source);
if (thinkingLevelMap) model.thinkingLevelMap = { ...thinkingLevelMap };
},
}),
rule({
why: "Baseten's GLM-5.2 endpoints are text-only despite models.dev reporting image input.",
match: { id: Array.from(BASETEN_GLM52_MODEL_IDS) },
set: { input: ["text"] },
}),
],
});

View file

@ -0,0 +1,85 @@
import {
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL,
CLOUDFLARE_WORKERS_AI_BASE_URL,
} from "../../../src/api/cloudflare.ts";
import { defineProvider, rule } from "../dsl.ts";
import { type ModelsDevRoute, modelsDev } from "../models-dev.ts";
import { OPENAI_GPT_56_STANDARD_COSTS, withOpenAiLongContextPricing } from "../shared.ts";
export const cloudflareWorkersAi = defineProvider({
id: "cloudflare-workers-ai",
source: modelsDev({
provider: "cloudflare-workers-ai",
key: "cloudflare-workers-ai",
api: "openai-completions",
baseUrl: CLOUDFLARE_WORKERS_AI_BASE_URL,
compat: { sendSessionAffinityHeaders: true },
}),
});
// Gateway ids are `<upstream>/<native id>`. OpenAI and Anthropic passthroughs use the
// native id; Workers AI keeps the prefix because it is served through the /compat endpoint.
const GATEWAY_ROUTES: Record<string, (nativeId: string) => ModelsDevRoute & { id: string }> = {
openai: (nativeId) => ({ id: nativeId, api: "openai-responses", baseUrl: CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL }),
anthropic: (nativeId) => ({
id: nativeId,
api: "anthropic-messages",
baseUrl: CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
// Gateway passthroughs forward session affinity headers to upstreams that use them.
compat: { sendSessionAffinityHeaders: true },
}),
"workers-ai": (nativeId) => ({
id: `workers-ai/${nativeId}`,
api: "openai-completions",
baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
compat: { sendSessionAffinityHeaders: true },
}),
};
function splitGatewayId(prefixedId: string): { upstream: string; nativeId: string } | undefined {
const slashIndex = prefixedId.indexOf("/");
if (slashIndex === -1) return undefined;
return { upstream: prefixedId.slice(0, slashIndex), nativeId: prefixedId.slice(slashIndex + 1) };
}
function resolveGatewayRoute(prefixedId: string): (ModelsDevRoute & { id: string }) | undefined {
const parts = splitGatewayId(prefixedId);
return parts ? GATEWAY_ROUTES[parts.upstream]?.(parts.nativeId) : undefined;
}
export const cloudflareAiGateway = defineProvider({
id: "cloudflare-ai-gateway",
source: [
modelsDev({
provider: "cloudflare-ai-gateway",
key: "cloudflare-ai-gateway",
api: "openai-completions",
baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
mapId: (id) => resolveGatewayRoute(id)?.id,
route: (id) => resolveGatewayRoute(id),
}),
// The gateway proxies Workers AI through its OpenAI-compatible /compat endpoint, but
// models.dev may omit or intermittently drop those `workers-ai/*` entries from the AI
// Gateway catalog. Mirror the Workers AI catalog under the documented prefix so the
// gateway keeps its OpenAI-compatible models stable. Ids already produced above win.
modelsDev({
provider: "cloudflare-ai-gateway",
key: "cloudflare-workers-ai",
api: "openai-completions",
baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
compat: { sendSessionAffinityHeaders: true },
mapId: (id) => `workers-ai/${id}`,
}),
],
rules: [
rule({
why: "Cloudflare AI Gateway passes OpenAI usage through at OpenAI list prices, including the GPT-5.6 price cut and long-context tiers.",
match: { id: Object.keys(OPENAI_GPT_56_STANDARD_COSTS) },
apply: (model) => {
model.cost = withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS[model.id]);
},
}),
],
});

View file

@ -0,0 +1,71 @@
import type { AnthropicMessagesCompat, OpenAICompletionsCompat } from "../../../src/types.ts";
import { defineProvider } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
// Fireworks prompt caching uses automatic prefix matching + session affinity.
// x-session-affinity routes requests to the same replica for cache hits.
// cache_control on tools and eager_input_streaming are not supported.
// See: https://docs.fireworks.ai/tools-sdks/anthropic-compatibility
const FIREWORKS_MESSAGES_COMPAT: AnthropicMessagesCompat = {
allowEmptySignature: true,
sendSessionAffinityHeaders: true,
supportsEagerToolInputStreaming: false,
supportsCacheControlOnTools: false,
supportsLongCacheRetention: false,
};
const FIREWORKS_COMPLETIONS_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
sendSessionAffinityHeaders: true,
supportsLongCacheRetention: false,
};
const FIREWORKS_KIMI_K3_COMPAT: OpenAICompletionsCompat = {
...FIREWORKS_COMPLETIONS_COMPAT,
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "openai",
};
// Verified against Fireworks Messages raw_output on 2026-09-10 (#9323).
// Fall back to verified support when models.dev omits effort metadata; this is
// not an allowlist. Any Fireworks Messages model advertising effort uses adaptive thinking.
const FIREWORKS_ADAPTIVE_THINKING_FALLBACK_MODEL_IDS = new Set([
"accounts/fireworks/models/deepseek-v4-flash-0731",
"accounts/fireworks/models/deepseek-v4-flash-vision-exp",
"accounts/fireworks/models/deepseek-v4-pro-0813",
"accounts/fireworks/models/qwen3p8-max",
"accounts/fireworks/models/qwen3p8-2p4t-a95b",
]);
export const fireworks = defineProvider({
id: "fireworks",
source: modelsDev({
provider: "fireworks",
key: "fireworks-ai",
// Fireworks Anthropic-compatible API - SDK appends /v1/messages.
api: "anthropic-messages",
baseUrl: "https://api.fireworks.ai/inference",
route: (id, entry) => {
// GLM and Kimi K3 are served through the OpenAI-compatible endpoint.
if (id.includes("glm-")) {
return {
api: "openai-completions",
baseUrl: "https://api.fireworks.ai/inference/v1",
compat: FIREWORKS_COMPLETIONS_COMPAT,
};
}
if (id.includes("kimi-k3")) {
return {
api: "openai-completions",
baseUrl: "https://api.fireworks.ai/inference/v1",
compat: FIREWORKS_KIMI_K3_COMPAT,
};
}
// Use adaptive thinking for cataloged effort controls, with verified fallbacks where
// models.dev is incomplete. New models need no allowlist entry.
const adaptive =
entry.reasoning_options?.some((option) => option.type === "effort") ||
FIREWORKS_ADAPTIVE_THINKING_FALLBACK_MODEL_IDS.has(id);
return { compat: { ...FIREWORKS_MESSAGES_COMPAT, ...(adaptive ? { forceAdaptiveThinking: true } : {}) } };
},
}),
});

View file

@ -0,0 +1,67 @@
import type { Api } from "../../../src/types.ts";
import { defineProvider, rule } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
const COPILOT_STATIC_HEADERS = {
"User-Agent": "GitHubCopilotChat/0.35.0",
"Editor-Version": "vscode/1.107.0",
"Editor-Plugin-Version": "copilot-chat/0.35.0",
"Copilot-Integration-Id": "vscode-chat",
} as const;
// GitHub's "Models with extended capabilities" table lists these Copilot models as supporting
// the extended 1 million token context window.
const GITHUB_COPILOT_EXTENDED_CONTEXT_MODEL_IDS = [
"claude-fable-5",
"claude-opus-4.6",
"claude-opus-4.7",
"claude-opus-4.8",
"claude-opus-5",
"claude-sonnet-4.6",
"claude-sonnet-5",
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.5",
];
function copilotApi(modelId: string): Api {
// Claude 4.x and 5.x models route to Anthropic Messages API
if (/^claude-(haiku|sonnet|opus|fable)-[45]([.-]|$)/.test(modelId)) return "anthropic-messages";
// GPT, Grok, OSWE, and MAI-Code models are only served through the Copilot /responses endpoint.
if (
modelId.startsWith("gpt-") ||
modelId.startsWith("grok-") ||
modelId.startsWith("oswe") ||
modelId.startsWith("mai-")
) {
return "openai-responses";
}
return "openai-completions";
}
export const githubCopilot = defineProvider({
id: "github-copilot",
source: modelsDev({
provider: "github-copilot",
key: "github-copilot",
api: "openai-completions",
baseUrl: "https://api.individual.githubcopilot.com",
headers: COPILOT_STATIC_HEADERS,
skipDeprecated: true,
defaults: { contextWindow: 128000, maxTokens: 8192 },
costTiers: true,
route: (id) => {
const api = copilotApi(id);
return api === "openai-completions"
? { api, compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false } }
: { api };
},
}),
rules: [
rule({
why: "GitHub's extended-capabilities table lists these Copilot models with the 1M token context window.",
match: { id: GITHUB_COPILOT_EXTENDED_CONTEXT_MODEL_IDS },
set: { contextWindow: 1000000 },
}),
],
});

View file

@ -0,0 +1,74 @@
import type { ThinkingLevelMap } from "../../../src/types.ts";
import type { ModelsDevReasoningOption } from "../../models-dev-reasoning-options.ts";
import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts";
import { defineProvider, rule } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
import { isGemma4Model } from "../shared.ts";
export const VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com";
// models.dev lists the rolling `-latest` aliases without capability metadata.
const GEMINI_LATEST_ALIASES = {
"gemini-flash-latest": "gemini-3.5-flash",
"gemini-flash-lite-latest": "gemini-3.1-flash-lite",
} as const;
export function getGoogleThinkingLevelMap(
modelId: string,
reasoningOptions: readonly ModelsDevReasoningOption[],
): ThinkingLevelMap | undefined {
const effortMap = getEffortThinkingLevelMap(reasoningOptions);
if (effortMap) return effortMap;
if (isGemma4Model(modelId)) {
return { off: null, minimal: "MINIMAL", low: null, medium: null, high: "HIGH" };
}
return undefined;
}
const geminiThinkingLevels = rule({
why: "Gemini exposes cataloged effort levels; Gemma 4 only documents MINIMAL and HIGH.",
match: {},
apply: (model, context) => {
const map = getGoogleThinkingLevelMap(model.id, context.reasoningOptions ?? []);
if (map) model.thinkingLevelMap = map;
},
});
export const google = defineProvider({
id: "google",
source: modelsDev({
provider: "google",
key: "google",
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
aliases: GEMINI_LATEST_ALIASES,
}),
rules: [geminiThinkingLevels],
});
export const googleVertex = defineProvider({
id: "google-vertex",
source: modelsDev({
provider: "google-vertex",
key: "google-vertex",
api: "google-vertex",
baseUrl: VERTEX_BASE_URL,
// The google-vertex models.dev catalog also includes Claude, OpenAI, and other
// MaaS models that do not use the @google/genai Gemini streaming path.
include: (id) => id.startsWith("gemini-") && id !== "gemini-3.1-flash-lite-preview",
aliases: GEMINI_LATEST_ALIASES,
}),
rules: [
geminiThinkingLevels,
rule({
why: "pi only accounts cachedContentTokenCount as cacheRead on Vertex; models.dev cache_write values do not apply.",
match: {},
set: { cost: { cacheWrite: 0 } },
}),
rule({
why: "models.dev reports Vertex cache_read for Gemini 2.5 Flash that does not match the official Gemini API pricing table.",
match: { id: "gemini-2.5-flash" },
set: { cost: { cacheRead: 0.03 } },
}),
],
});

View file

@ -0,0 +1,73 @@
import type { ProviderDefinition } from "../dsl.ts";
import { amazonBedrock } from "./amazon-bedrock.ts";
import { anthropic } from "./anthropic.ts";
import { azureOpenaiResponses } from "./azure.ts";
import { baseten } from "./baseten.ts";
import { cloudflareAiGateway, cloudflareWorkersAi } from "./cloudflare.ts";
import { fireworks } from "./fireworks.ts";
import { githubCopilot } from "./github-copilot.ts";
import { google, googleVertex } from "./google.ts";
import { kimiCoding } from "./kimi-coding.ts";
import { minimax, minimaxCn } from "./minimax.ts";
import { mistral } from "./mistral.ts";
import { moonshotai, moonshotaiCn } from "./moonshot.ts";
import { nvidia } from "./nvidia.ts";
import { openai } from "./openai.ts";
import { opencode, opencodeGo } from "./opencode.ts";
import { openrouter } from "./openrouter.ts";
import { qwenTokenPlan, qwenTokenPlanCn, qwenTokenPlanIndividual } from "./qwen-token-plan.ts";
import { radius } from "./radius.ts";
import { cerebras, groq, huggingface, meta, xai } from "./simple-openai-compatible.ts";
import { antLing, deepseek, openaiCodex } from "./static.ts";
import { together } from "./together.ts";
import { vercelAiGateway } from "./vercel-ai-gateway.ts";
import { xiaomi, xiaomiTokenPlanAms, xiaomiTokenPlanCn, xiaomiTokenPlanSgp } from "./xiaomi.ts";
import { zai, zaiCodingCn } from "./zai.ts";
/**
* Providers in generation order. Derived providers (Azure mirrors OpenAI) must follow
* the provider they read from.
*/
export const PROVIDERS: readonly ProviderDefinition[] = [
amazonBedrock,
anthropic,
google,
googleVertex,
openai,
groq,
cerebras,
cloudflareWorkersAi,
cloudflareAiGateway,
xai,
meta,
zai,
zaiCodingCn,
mistral,
huggingface,
fireworks,
nvidia,
together,
baseten,
opencode,
opencodeGo,
githubCopilot,
minimax,
minimaxCn,
kimiCoding,
moonshotai,
moonshotaiCn,
xiaomi,
xiaomiTokenPlanCn,
xiaomiTokenPlanAms,
xiaomiTokenPlanSgp,
qwenTokenPlan,
qwenTokenPlanIndividual,
qwenTokenPlanCn,
openrouter,
vercelAiGateway,
radius,
deepseek,
antLing,
openaiCodex,
azureOpenaiResponses,
];

View file

@ -0,0 +1,62 @@
import type { ModelCost } from "../../../src/types.ts";
import { defineProvider, rule } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
import { KIMI_K3_COST } from "../shared.ts";
// models.dev may expose versioned aliases of the canonical Kimi For Coding model.
const KIMI_CODING_ALIASES = new Set(["k2p5", "k2p6", "k2p7"]);
// Kimi Coding is subscription-backed, so models.dev reports zero cost. Use the
// equivalent Moonshot API rates to estimate the value of subscription usage.
const KIMI_CODING_IMPLIED_COSTS: Record<string, ModelCost> = {
k3: KIMI_K3_COST,
"kimi-for-coding": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 },
"kimi-for-coding-highspeed": { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 0 },
"kimi-k2-thinking": { input: 0.6, output: 2.5, cacheRead: 0.15, cacheWrite: 0 },
};
export const kimiCoding = defineProvider({
id: "kimi-coding",
source: modelsDev({
provider: "kimi-coding",
key: "kimi-code-plan-global",
api: "anthropic-messages",
// Kimi For Coding's Anthropic-compatible API - SDK appends /v1/messages
baseUrl: "https://api.kimi.com/coding",
compat: { forceAdaptiveThinking: true },
// Drop aliases when the canonical model exists; otherwise emit them under the canonical id.
include: (id, _entry, models) => !(KIMI_CODING_ALIASES.has(id) && Object.hasOwn(models, "kimi-for-coding")),
mapId: (id) => (KIMI_CODING_ALIASES.has(id) ? "kimi-for-coding" : id),
}),
rules: [
rule({
why: "Aliases normalized to the canonical id also take the canonical name.",
match: { id: "kimi-for-coding", when: (_model, context) => KIMI_CODING_ALIASES.has(context.sourceId ?? "") },
set: { name: "Kimi For Coding" },
mayMatchNothing: true,
}),
rule({
why: "Kimi K3 and Kimi For Coding replay thinking without signatures.",
match: { id: ["k3", "kimi-for-coding"] },
set: { compat: { allowEmptySignature: true } },
}),
rule({
why: "Kimi K3 always reasons even when the catalog omits the flag.",
match: { id: "k3" },
set: { reasoning: true },
}),
rule({
why: "Subscription pricing is reported as zero; fill in the equivalent Moonshot API rates per missing field.",
match: { id: Object.keys(KIMI_CODING_IMPLIED_COSTS) },
apply: (model) => {
const implied = KIMI_CODING_IMPLIED_COSTS[model.id];
model.cost = {
input: model.cost.input || implied.input,
output: model.cost.output || implied.output,
cacheRead: model.cost.cacheRead || implied.cacheRead,
cacheWrite: model.cost.cacheWrite || implied.cacheWrite,
};
},
}),
],
});

View file

@ -0,0 +1,35 @@
import { defineProvider, rule } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
// Only these models are served directly by MiniMax's Anthropic-compatible API.
const MINIMAX_DIRECT_SUPPORTED_MODEL_IDS = ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M3"];
const dropUnsupported = rule({
why: "MiniMax's Anthropic-compatible endpoint only serves the M2.7 and M3 models.",
match: { not: { id: MINIMAX_DIRECT_SUPPORTED_MODEL_IDS } },
drop: true,
mayMatchNothing: true,
});
export const minimax = defineProvider({
id: "minimax",
source: modelsDev({
provider: "minimax",
key: "minimax",
api: "anthropic-messages",
// MiniMax's Anthropic-compatible API - SDK appends /v1/messages
baseUrl: "https://api.minimax.io/anthropic",
}),
rules: [dropUnsupported],
});
export const minimaxCn = defineProvider({
id: "minimax-cn",
source: modelsDev({
provider: "minimax-cn",
key: "minimax-cn",
api: "anthropic-messages",
baseUrl: "https://api.minimaxi.com/anthropic",
}),
rules: [dropUnsupported],
});

View file

@ -0,0 +1,41 @@
import { defineProvider, roundCost, rule } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
const MISTRAL_BASE_URL = "https://api.mistral.ai";
export const mistral = defineProvider({
id: "mistral",
source: modelsDev({
provider: "mistral",
key: "mistral",
api: "mistral-conversations",
baseUrl: MISTRAL_BASE_URL,
}),
models: [
{
// Missing from models.dev.
id: "mistral-medium-3.5",
name: "Mistral Medium 3.5",
api: "mistral-conversations",
provider: "mistral",
baseUrl: MISTRAL_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 1.5, output: 7.5, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 262144,
},
],
rules: [
rule({
why: "Mistral bills cached input at 10% of the input price when models.dev omits cache_read.",
match: {
when: (_model, context) => context.source !== undefined && context.source.cost?.cache_read === undefined,
},
apply: (model, context) => {
const input = context.source?.cost?.input;
model.cost = { ...model.cost, cacheRead: input ? roundCost(input * 0.1) : 0 };
},
}),
],
});

View file

@ -0,0 +1,64 @@
import type { OpenAICompletionsCompat } from "../../../src/types.ts";
import { type DefinedRule, defineProvider, rule } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
import { KIMI_K3_COST } from "../shared.ts";
const MOONSHOT_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
thinkingFormat: "deepseek",
};
const kimiK3Rules: DefinedRule[] = [
rule({
why: "Kimi K3 always reasons, takes OpenAI reasoning_effort, and needs reasoning_content replayed on assistant messages.",
match: { id: "kimi-k3" },
set: {
reasoning: true,
compat: {
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "openai",
supportsReasoningEffort: true,
},
},
}),
rule({
why: "models.dev has no Kimi K3 prices yet; fill in the published Moonshot rates per missing field.",
match: { id: "kimi-k3" },
apply: (model) => {
model.cost = {
input: model.cost.input || KIMI_K3_COST.input,
output: model.cost.output || KIMI_K3_COST.output,
cacheRead: model.cost.cacheRead || KIMI_K3_COST.cacheRead,
cacheWrite: model.cost.cacheWrite || KIMI_K3_COST.cacheWrite,
};
},
}),
];
export const moonshotai = defineProvider({
id: "moonshotai",
source: modelsDev({
provider: "moonshotai",
key: "moonshotai",
api: "openai-completions",
baseUrl: "https://api.moonshot.ai/v1",
compat: MOONSHOT_COMPAT,
}),
rules: kimiK3Rules,
});
export const moonshotaiCn = defineProvider({
id: "moonshotai-cn",
source: modelsDev({
provider: "moonshotai-cn",
key: "moonshotai-cn",
api: "openai-completions",
baseUrl: "https://api.moonshot.cn/v1",
compat: MOONSHOT_COMPAT,
}),
rules: kimiK3Rules,
});

View file

@ -0,0 +1,69 @@
import type { OpenAICompletionsCompat } from "../../../src/types.ts";
import { defineProvider } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
export const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
const NVIDIA_OPENAI_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
supportsLongCacheRetention: false,
};
// Listed by NIM but rejecting tool calls or the chat completions request shape.
const NVIDIA_NIM_UNSUPPORTED_MODEL_IDS = new Set([
"abacusai/dracarys-llama-3.1-70b-instruct",
"bytedance/seed-oss-36b-instruct",
"deepseek-ai/deepseek-v4-flash",
"deepseek-ai/deepseek-v4-pro",
"google/gemma-2-2b-it",
"google/gemma-3n-e2b-it",
"google/gemma-3n-e4b-it",
"google/gemma-4-31b-it",
"meta/llama-3.2-1b-instruct",
"meta/llama-4-maverick-17b-128e-instruct",
"microsoft/phi-4-mini-instruct",
"minimaxai/minimax-m2.7",
"mistralai/mistral-nemotron",
"nvidia/nemotron-mini-4b-instruct",
"qwen/qwen3-next-80b-a3b-instruct",
"qwen/qwen3.5-397b-a17b",
"sarvamai/sarvam-m",
"upstage/solar-10.7b-instruct",
]);
function normalizeNvidiaModelId(modelId: string): string {
return modelId.toLowerCase().replaceAll("_", ".");
}
export const nvidia = defineProvider({
id: "nvidia",
// models.dev ids do not always match the ids NIM serves; only emit models NIM lists,
// under the live id.
source: async (context) => {
const catalog = await context.upstream.modelsDev();
if (!catalog.nvidia?.models) return [];
const liveModelIds = new Map<string, string>();
for (const model of await context.upstream.nvidiaNim()) {
liveModelIds.set(model.id, model.id);
liveModelIds.set(normalizeNvidiaModelId(model.id), model.id);
}
return modelsDev({
provider: "nvidia",
key: "nvidia",
api: "openai-completions",
baseUrl: NVIDIA_BASE_URL,
headers: { "NVCF-POLL-SECONDS": "3600" },
compat: NVIDIA_OPENAI_COMPAT,
include: (_id, entry) =>
entry.modalities?.input?.includes("text") === true && entry.modalities?.output?.includes("text") === true,
mapId: (id) => {
const liveModelId = liveModelIds.get(id) ?? liveModelIds.get(normalizeNvidiaModelId(id));
return liveModelId && !NVIDIA_NIM_UNSUPPORTED_MODEL_IDS.has(liveModelId) ? liveModelId : undefined;
},
})(context);
},
});

View file

@ -0,0 +1,97 @@
import type { Model } from "../../../src/types.ts";
import { defineProvider, rule } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
import {
OPENAI_BASE_URL,
OPENAI_GPT_6_ASTRA_COST,
OPENAI_GPT_56_SOL_COST,
OPENAI_GPT_56_STANDARD_COSTS,
OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
OPENAI_LONG_CONTEXT_PRICING_MODEL_IDS,
withOpenAiLongContextPricing,
} from "../shared.ts";
// models.dev lists this alias, but it is not accepted by OpenAI APIs.
const MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS = new Set(["gpt-5.6"]);
// Keep direct OpenAI requests in the short-context pricing tier by default. Users can opt into the
// larger context through model overrides, so retain long-context cost metadata on the capped models.
const OPENAI_SHORT_CONTEXT_CAPPED_MODEL_IDS = [
"gpt-5.4",
"gpt-5.5",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-6-astra",
];
function openAiResponsesModel(
id: string,
name: string,
model: Pick<Model<"openai-responses">, "reasoning" | "input" | "cost" | "contextWindow" | "maxTokens">,
): Model<"openai-responses"> {
return { id, name, api: "openai-responses", provider: "openai", baseUrl: OPENAI_BASE_URL, ...model };
}
const shortContextFrontier: Pick<Model<"openai-responses">, "reasoning" | "input" | "contextWindow" | "maxTokens"> = {
reasoning: true,
input: ["text", "image"],
contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
maxTokens: 128000,
};
export const openai = defineProvider({
id: "openai",
source: modelsDev({
provider: "openai",
key: "openai",
api: "openai-responses",
baseUrl: OPENAI_BASE_URL,
include: (id) => !MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS.has(id),
}),
// Models missing from models.dev.
models: [
openAiResponsesModel("gpt-6-astra", "GPT-6 Astra", {
...shortContextFrontier,
cost: withOpenAiLongContextPricing(OPENAI_GPT_6_ASTRA_COST),
}),
openAiResponsesModel("gpt-5.6-sol", "GPT-5.6 Sol", {
...shortContextFrontier,
cost: withOpenAiLongContextPricing(OPENAI_GPT_56_SOL_COST),
}),
openAiResponsesModel("gpt-5.6-terra", "GPT-5.6 Terra", {
...shortContextFrontier,
cost: withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS["gpt-5.6-terra"]),
}),
openAiResponsesModel("gpt-5.6-luna", "GPT-5.6 Luna", {
...shortContextFrontier,
cost: withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS["gpt-5.6-luna"]),
}),
openAiResponsesModel("gpt-5-chat-latest", "GPT-5 Chat Latest", {
reasoning: false,
input: ["text", "image"],
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
}),
],
rules: [
rule({
why: "Keep direct OpenAI requests in the short-context pricing tier by default; users opt into the larger context via model overrides.",
match: { id: OPENAI_SHORT_CONTEXT_CAPPED_MODEL_IDS },
set: { contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD, maxTokens: 128000 },
}),
rule({
why: "Long-context requests are billed at higher rates; models.dev only carries the short-context price. GPT-5.6 Terra/Luna use the 2026-07-30 price cut.",
match: { id: OPENAI_LONG_CONTEXT_PRICING_MODEL_IDS },
apply: (model) => {
model.cost = withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS[model.id] ?? model.cost);
},
}),
rule({
why: "models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit); the actual max output is 128000.",
match: { id: "gpt-5-pro" },
set: { maxTokens: 128000 },
}),
],
});

View file

@ -0,0 +1,153 @@
import type { Api, OpenAICompletionsCompat, OpenAIResponsesCompat } from "../../../src/types.ts";
import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts";
import { type DefinedRule, defineProvider, rule } from "../dsl.ts";
import { type ModelsDevRoute, modelsDev } from "../models-dev.ts";
import { getGoogleThinkingLevelMap } from "./google.ts";
// OpenCode Zen and Go serve several upstream APIs behind one base path. models.dev's
// provider.npm field says which SDK the model expects:
// - @ai-sdk/openai → openai-responses
// - @ai-sdk/anthropic → anthropic-messages
// - @ai-sdk/google → google-generative-ai
// - null/undefined/@ai-sdk/openai-compatible → openai-completions
const OPENCODE_LONG_CACHE_RETENTION_UNSUPPORTED_MODEL_IDS = new Set([
"opencode:deepseek-v4-flash",
"opencode:deepseek-v4-pro",
"opencode:kimi-k2.5",
"opencode:kimi-k2.6",
"opencode:minimax-m2.7",
"opencode-go:kimi-k2.6",
]);
function opencodeRoute(provider: "opencode" | "opencode-go", basePath: string) {
return (id: string, entry: { provider?: { npm?: string } }): ModelsDevRoute => {
const npm = entry.provider?.npm;
let api: Api;
let baseUrl: string;
let compat: OpenAICompletionsCompat | OpenAIResponsesCompat | undefined;
if (npm === "@ai-sdk/openai") {
api = "openai-responses";
baseUrl = `${basePath}/v1`;
compat = { sessionAffinityFormat: "openai-nosession" };
} else if (npm === "@ai-sdk/anthropic") {
api = "anthropic-messages";
// Anthropic SDK appends /v1/messages to baseURL
baseUrl = basePath;
} else if (npm === "@ai-sdk/google") {
api = "google-generative-ai";
baseUrl = `${basePath}/v1`;
} else if (npm === "@ai-sdk/alibaba") {
api = "openai-completions";
baseUrl = `${basePath}/v1`;
compat = { cacheControlFormat: "anthropic" };
} else {
api = "openai-completions";
baseUrl = `${basePath}/v1`;
}
if (provider === "opencode" && id === "grok-build-0.1") {
compat = { ...(compat ?? {}), supportsReasoningEffort: false };
}
if (id === "kimi-k2.6") {
// OpenCode Kimi K2.6 accepts Anthropic-style thinking objects
// and rejects string thinking values or combined reasoning_effort.
compat = { ...(compat ?? {}), thinkingFormat: "deepseek", supportsReasoningEffort: false };
}
// Fix known mismatches between models.dev npm data and actual OpenCode Go endpoint
// behaviour. models.dev reports these models as @ai-sdk/anthropic, but the OpenCode Go
// endpoints either don't accept Anthropic SDK auth (MiniMax M2.7) or are served through
// the OpenAI-compatible /v1/chat/completions path (Qwen 3.5/3.6). Switch them to
// openai-completions so requests use Bearer auth and the standard endpoint.
if (provider === "opencode-go") {
if (id === "minimax-m2.7") {
api = "openai-completions";
baseUrl = `${basePath}/v1`;
}
if (id === "qwen3.5-plus" || id === "qwen3.6-plus") {
api = "openai-completions";
baseUrl = `${basePath}/v1`;
// Qwen/DashScope uses enable_thinking at the top level.
compat = { ...(compat ?? {}), thinkingFormat: "qwen" };
}
}
if (api === "openai-completions") {
compat = { ...(compat ?? {}), maxTokensField: "max_tokens" };
if (OPENCODE_LONG_CACHE_RETENTION_UNSUPPORTED_MODEL_IDS.has(`${provider}:${id}`)) {
compat = { ...compat, supportsLongCacheRetention: false };
}
}
return { api, baseUrl, ...(compat ? { compat } : {}) };
};
}
const geminiThinkingLevels = rule({
why: "Gemini models keep their cataloged effort levels through OpenCode.",
match: { api: "google-generative-ai" },
apply: (model, context) => {
const map = getGoogleThinkingLevelMap(model.id, context.reasoningOptions ?? []);
if (map) model.thinkingLevelMap = map;
},
});
const sharedRules: DefinedRule[] = [
geminiThinkingLevels,
rule({
why: "OpenCode only serves Codex Spark through the ChatGPT backend, not its Zen/Go endpoints.",
match: { id: "gpt-5.3-codex-spark" },
drop: true,
mayMatchNothing: true,
}),
rule({
why: "OpenCode lists Claude Sonnet 4/4.5 with 1M context; the actual limit is 200K.",
match: { id: ["claude-sonnet-4-5", "claude-sonnet-4"] },
set: { contextWindow: 200000 },
mayMatchNothing: true,
}),
rule({
why: "OpenCode serves GPT-5.4 with OpenAI's short-context limits.",
match: { id: "gpt-5.4" },
set: { contextWindow: 272000, maxTokens: 128000 },
mayMatchNothing: true,
}),
];
export const opencode = defineProvider({
id: "opencode",
source: modelsDev({
provider: "opencode",
key: "opencode",
api: "openai-completions",
baseUrl: "https://opencode.ai/zen/v1",
skipDeprecated: true,
route: opencodeRoute("opencode", "https://opencode.ai/zen"),
}),
rules: sharedRules,
});
export const opencodeGo = defineProvider({
id: "opencode-go",
source: modelsDev({
provider: "opencode-go",
key: "opencode-go",
api: "openai-completions",
baseUrl: "https://opencode.ai/zen/go/v1",
skipDeprecated: true,
route: opencodeRoute("opencode-go", "https://opencode.ai/zen/go"),
}),
rules: [
...sharedRules,
rule({
why: "OpenCode Go passes DeepSeek V4.1 Flash effort levels through as cataloged (#9485).",
match: { id: "deepseek-v4.1-flash" },
apply: (model, context) => {
const map = getEffortThinkingLevelMap(context.reasoningOptions ?? []);
if (map) model.thinkingLevelMap = map;
},
}),
],
});

View file

@ -0,0 +1,91 @@
import type { Model } from "../../../src/types.ts";
import { getOpenRouterThinkingLevelMap } from "../../openrouter-reasoning-options.ts";
import { defineProvider, type GeneratedModel, roundCost, rule } from "../dsl.ts";
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
function perMillion(value: string | undefined): number {
return roundCost(parseFloat(value || "0") * 1_000_000);
}
function openRouterRouterModel(
id: string,
name: string,
model: Pick<Model<"openai-completions">, "input" | "contextWindow" | "maxTokens">,
): GeneratedModel {
return {
id,
name,
api: "openai-completions",
provider: "openrouter",
baseUrl: OPENROUTER_BASE_URL,
reasoning: true,
// Routers charge for the underlying model, so no price is known up front.
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
...model,
};
}
export const openrouter = defineProvider({
id: "openrouter",
source: async (context) => {
const models: GeneratedModel[] = [];
for (const model of await context.upstream.openRouter()) {
// Only include models that support tools
if (!model.supported_parameters?.includes("tools")) continue;
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);
const useAnthropicMessages = /^anthropic\//.test(model.id) && !model.id.endsWith(":batch");
models.push({
id: model.id,
name: model.name,
api: useAnthropicMessages ? "anthropic-messages" : "openai-completions",
baseUrl: useAnthropicMessages ? "https://openrouter.ai/api" : OPENROUTER_BASE_URL,
provider: "openrouter",
reasoning: model.supported_parameters?.includes("reasoning") || false,
...(thinkingLevelMap && { thinkingLevelMap }),
input: model.architecture?.modality?.includes("image") ? ["text", "image"] : ["text"],
cost: {
input: perMillion(model.pricing?.prompt),
output: perMillion(model.pricing?.completion),
cacheRead: perMillion(model.pricing?.input_cache_read),
cacheWrite: perMillion(model.pricing?.input_cache_write),
},
contextWindow: model.top_provider?.context_length || model.context_length || 4096,
maxTokens: model.top_provider?.max_completion_tokens || 4096,
});
}
console.log(`Fetched ${models.length} tool-capable models from OpenRouter`);
return models;
},
models: [
openRouterRouterModel("auto", "Auto", { input: ["text", "image"], contextWindow: 2000000, maxTokens: 30000 }),
// OpenRouter exposes Fusion as a router alias/plugin entry point; its model metadata does
// not advertise tools, but the alias resolves to a concrete model that can invoke caller
// tools and has the openrouter:fusion server tool auto-injected.
openRouterRouterModel("openrouter/fusion", "OpenRouter: Fusion", {
input: ["text"],
contextWindow: 1000000,
maxTokens: 30000,
}),
],
rules: [
rule({
why: "Keep Kimi K2.5 metadata stable until OpenRouter's listing settles.",
match: { id: "moonshotai/kimi-k2.5" },
set: { cost: { input: 0.41, output: 2.06, cacheRead: 0.07 }, maxTokens: 4096 },
mayMatchNothing: true,
}),
rule({
why: "OpenRouter Kimi K2.6 rejects the developer role and needs reasoning_content replayed on assistant messages.",
match: { idPrefix: "moonshotai/kimi-k2.6" },
set: { compat: { supportsDeveloperRole: false, requiresReasoningContentOnAssistantMessages: true } },
mayMatchNothing: true,
}),
rule({
why: "Keep GLM-5 prices stable until OpenRouter's listing settles.",
match: { id: "z-ai/glm-5" },
set: { cost: { input: 0.6, output: 1.9, cacheRead: 0.119 } },
mayMatchNothing: true,
}),
],
});

View file

@ -0,0 +1,118 @@
import type { OpenAICompletionsCompat, ThinkingLevelMap } from "../../../src/types.ts";
import { assertExactModelIds } from "../../model-data.ts";
import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts";
import { type DefinedRule, defineProvider, type ProviderDefinition, rule } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
import type { ModelsDevModel } from "../sources.ts";
// Alibaba Cloud Model Studio Token Plan. International and China use separate endpoints
// and API keys (sk-sp- prefix). The Individual provider reuses the international source
// and endpoint with a narrower catalog. models.dev keys are "alibaba-token-plan[-cn]";
// pi exposes them as "qwen-token-plan[-cn]" plus the Individual catalog view.
const QWEN_TOKEN_PLAN_COMPAT: OpenAICompletionsCompat = {
thinkingFormat: "qwen",
supportsDeveloperRole: false,
supportsStore: false,
supportsReasoningEffort: true,
};
const QWEN_TOKEN_PLAN_FALLBACK_THINKING_LEVEL_MAP: ThinkingLevelMap = {
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: "max",
};
// GLM 5.x accept reasoning_effort even when models.dev omits the options.
const QWEN_TOKEN_PLAN_REASONING_EFFORT_FALLBACK_MODEL_IDS = new Set(["glm-5", "glm-5.1"]);
// Retired preview id — models.dev may still list it after GA ships.
const QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS = new Set(["qwen3.8-max-preview"]);
// QwenCloud Token Plan Individual text-model allowlist, verified 2026-09-03.
// Retired models remain excluded above even if the public catalog lags.
// https://docs.qwencloud.com/token-plan/personal/token-plan-personal-overview
const QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS = new Set<string>([
"deepseek-v4-flash-0731",
"deepseek-v4-pro",
"deepseek-v4-pro-0813",
"glm-5.2",
"qwen3.6-flash",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.8-flash",
"qwen3.8-max",
]);
function getQwenThinkingLevelMap(id: string, entry: ModelsDevModel): ThinkingLevelMap | undefined {
return (
getEffortThinkingLevelMap(entry.reasoning_options ?? []) ??
(QWEN_TOKEN_PLAN_REASONING_EFFORT_FALLBACK_MODEL_IDS.has(id)
? QWEN_TOKEN_PLAN_FALLBACK_THINKING_LEVEL_MAP
: undefined)
);
}
const thinkingLevels: DefinedRule = rule({
why: "Cataloged effort levels (or the GLM 5 fallback) enable reasoning_effort; other models cannot take it.",
match: {},
apply: (model, context) => {
if (!context.source) return;
const map = getQwenThinkingLevelMap(model.id, context.source);
if (map) {
model.thinkingLevelMap = { ...map };
} else {
model.compat = { ...model.compat, supportsReasoningEffort: false };
}
},
});
function qwenTokenPlanProvider(options: {
id: string;
key: string;
baseUrl: string;
modelIds?: ReadonlySet<string>;
}): ProviderDefinition {
const source = modelsDev({
provider: options.id,
key: options.key,
api: "openai-completions",
baseUrl: options.baseUrl,
compat: QWEN_TOKEN_PLAN_COMPAT,
include: (id) => !QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS.has(id) && (options.modelIds?.has(id) ?? true),
});
return defineProvider({
id: options.id,
source: async (context) => {
const models = await source(context);
// The allowlist is verified by hand; a missing model means the plan changed and needs review.
if (options.modelIds && context.strict) {
assertExactModelIds(
options.id,
options.modelIds,
models.map((model) => model.id),
);
}
return models;
},
rules: [thinkingLevels],
});
}
const INTERNATIONAL_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
export const qwenTokenPlan = qwenTokenPlanProvider({
id: "qwen-token-plan",
key: "alibaba-token-plan",
baseUrl: INTERNATIONAL_BASE_URL,
});
export const qwenTokenPlanIndividual = qwenTokenPlanProvider({
id: "qwen-token-plan-individual",
key: "alibaba-token-plan",
baseUrl: INTERNATIONAL_BASE_URL,
modelIds: QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS,
});
export const qwenTokenPlanCn = qwenTokenPlanProvider({
id: "qwen-token-plan-cn",
key: "alibaba-token-plan-cn",
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
});

View file

@ -0,0 +1,14 @@
import { getRadiusModelsFromConfig } from "../../../src/providers/radius-config.ts";
import { defineProvider } from "../dsl.ts";
// Radius ships its unauthenticated public catalog; authenticated clients overlay it at runtime.
export const radius = defineProvider({
id: "radius",
source: async (context) => {
const config = await context.upstream.radius();
if (!config) return [];
const models = getRadiusModelsFromConfig("radius", config);
console.log(`Fetched ${models.length} models from Radius`);
return models;
},
});

View file

@ -0,0 +1,68 @@
import { defineProvider, rule } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
// Providers whose models.dev catalog maps onto one API with no per-model corrections.
export const groq = defineProvider({
id: "groq",
source: modelsDev({
provider: "groq",
key: "groq",
api: "openai-completions",
baseUrl: "https://api.groq.com/openai/v1",
}),
});
export const cerebras = defineProvider({
id: "cerebras",
source: modelsDev({
provider: "cerebras",
key: "cerebras",
api: "openai-completions",
baseUrl: "https://api.cerebras.ai/v1",
}),
});
export const huggingface = defineProvider({
id: "huggingface",
source: modelsDev({
provider: "huggingface",
key: "huggingface",
api: "openai-completions",
baseUrl: "https://router.huggingface.co/v1",
compat: { supportsDeveloperRole: false },
}),
});
export const meta = defineProvider({
id: "meta",
source: modelsDev({ provider: "meta", key: "meta", api: "openai-responses", baseUrl: "https://api.meta.ai/v1" }),
});
export const xai = defineProvider({
id: "xai",
source: modelsDev({
provider: "xai",
key: "xai",
api: "openai-responses",
baseUrl: "https://api.x.ai/v1",
compat: { supportsLongCacheRetention: false },
}),
rules: [
rule({
why: "Retired or non-tool xAI ids that models.dev still lists.",
match: {
id: [
"grok-3",
"grok-3-fast",
"grok-4.20-0309-non-reasoning",
"grok-4.20-0309-reasoning",
"grok-build-0.1",
"grok-code-fast-1",
],
},
drop: true,
mayMatchNothing: true,
}),
],
});

View file

@ -0,0 +1,154 @@
import type { Model, OpenAICompletionsCompat } from "../../../src/types.ts";
import { defineProvider } from "../dsl.ts";
import {
DEEPSEEK_COMPAT,
DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP,
OPENAI_GPT_6_ASTRA_COST,
OPENAI_GPT_56_SOL_COST,
OPENAI_GPT_56_STANDARD_COSTS,
withOpenAiLongContextPricing,
} from "../shared.ts";
// Providers without an upstream catalog. Their models are maintained by hand.
const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
export const deepseek = defineProvider({
id: "deepseek",
models: [
{
id: "deepseek-flash",
name: "DeepSeek V4.1 Flash",
api: "openai-completions",
baseUrl: DEEPSEEK_BASE_URL,
provider: "deepseek",
reasoning: true,
thinkingLevelMap: DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP,
input: ["text", "image"],
// DeepSeek also offers time-based off-peak rates, which the cost schema cannot represent yet.
cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 384000,
compat: DEEPSEEK_COMPAT,
},
{
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
api: "openai-completions",
baseUrl: DEEPSEEK_BASE_URL,
provider: "deepseek",
reasoning: true,
input: ["text"],
// DeepSeek also offers time-based off-peak rates, which the cost schema cannot represent yet.
cost: { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 384000,
compat: DEEPSEEK_COMPAT,
},
],
});
const ANT_LING_BASE_URL = "https://api.ant-ling.com/v1";
const ANT_LING_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsLongCacheRetention: false,
};
function antLingModel(
id: string,
name: string,
model: Pick<Model<"openai-completions">, "reasoning" | "cost" | "compat">,
): Model<"openai-completions"> {
return {
id,
name,
api: "openai-completions",
baseUrl: ANT_LING_BASE_URL,
provider: "ant-ling",
input: ["text"],
contextWindow: 262144,
maxTokens: 65536,
...model,
};
}
export const antLing = defineProvider({
id: "ant-ling",
models: [
antLingModel("Ling-2.6-flash", "Ling 2.6 Flash", {
reasoning: false,
cost: { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0 },
compat: ANT_LING_COMPAT,
}),
antLingModel("Ling-2.6-1T", "Ling 2.6 1T", {
reasoning: false,
cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 },
compat: ANT_LING_COMPAT,
}),
antLingModel("Ring-2.6-1T", "Ring 2.6 1T", {
reasoning: true,
cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 },
compat: { ...ANT_LING_COMPAT, thinkingFormat: "ant-ling" },
}),
],
});
// OpenAI Codex (ChatGPT OAuth) models are not fetched from models.dev; keep a small,
// explicit list to avoid aliases. Older model limits are based on observed server
// behavior; GPT-5.6 and GPT-6 Astra use Codex's 272k default catalog limit.
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
const CODEX_CONTEXT = 272000;
const CODEX_SPARK_CONTEXT = 128000;
const CODEX_MAX_TOKENS = 128000;
function codexModel(
id: string,
name: string,
model: Pick<Model<"openai-codex-responses">, "input" | "cost"> & { contextWindow?: number },
): Model<"openai-codex-responses"> {
return {
id,
name,
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
...model,
};
}
export const openaiCodex = defineProvider({
id: "openai-codex",
models: [
codexModel("gpt-6-astra", "GPT-6 Astra", {
input: ["text", "image"],
cost: withOpenAiLongContextPricing(OPENAI_GPT_6_ASTRA_COST),
}),
codexModel("gpt-5.3-codex-spark", "GPT-5.3 Codex Spark", {
input: ["text"],
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
contextWindow: CODEX_SPARK_CONTEXT,
}),
codexModel("gpt-5.5", "GPT-5.5", {
input: ["text", "image"],
cost: withOpenAiLongContextPricing({ input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }),
}),
codexModel("gpt-5.6-luna", "GPT-5.6 Luna", {
input: ["text", "image"],
cost: withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS["gpt-5.6-luna"]),
}),
codexModel("gpt-5.6-sol", "GPT-5.6 Sol", {
input: ["text", "image"],
cost: withOpenAiLongContextPricing(OPENAI_GPT_56_SOL_COST),
}),
codexModel("gpt-5.6-terra", "GPT-5.6 Terra", {
input: ["text", "image"],
cost: withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS["gpt-5.6-terra"]),
}),
],
});

View file

@ -0,0 +1,72 @@
import type { OpenAICompletionsCompat, ThinkingLevelMap } from "../../../src/types.ts";
import { defineProvider, rule } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
import { TOGETHER_BASE_URL, TOGETHER_REASONING_ONLY_MODEL_IDS } from "../shared.ts";
const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
supportsLongCacheRetention: false,
};
// Together exposes reasoning three ways: a toggle (`reasoning: { enabled }`), OpenAI
// `reasoning_effort`, or both. Models not listed below use the toggle.
const TOGETHER_REASONING_EFFORT_MODEL_IDS = new Set(["openai/gpt-oss-20b", "openai/gpt-oss-120b"]);
const TOGETHER_TOGGLE_REASONING_EFFORT_MODEL_IDS = new Set(["deepseek-ai/DeepSeek-V4-Pro"]);
const TOGETHER_REASONING_ONLY = new Set<string>(TOGETHER_REASONING_ONLY_MODEL_IDS);
interface TogetherReasoningMode {
compat: OpenAICompletionsCompat;
thinkingLevelMap: ThinkingLevelMap;
}
const TOGETHER_REASONING_MODES = {
effort: {
compat: { ...TOGETHER_BASE_COMPAT, supportsReasoningEffort: true, thinkingFormat: "openai" },
thinkingLevelMap: { off: null, minimal: null },
},
toggleAndEffort: {
compat: { ...TOGETHER_BASE_COMPAT, thinkingFormat: "together", supportsReasoningEffort: true },
thinkingLevelMap: { minimal: null, low: null, medium: null, high: "high", xhigh: null },
},
reasoningOnly: {
compat: TOGETHER_BASE_COMPAT,
thinkingLevelMap: { off: null, minimal: null, low: null, medium: null },
},
toggle: {
compat: { ...TOGETHER_BASE_COMPAT, thinkingFormat: "together" },
thinkingLevelMap: { minimal: null, low: null, medium: null },
},
} satisfies Record<string, TogetherReasoningMode>;
function getTogetherReasoningMode(modelId: string): TogetherReasoningMode {
if (TOGETHER_REASONING_EFFORT_MODEL_IDS.has(modelId)) return TOGETHER_REASONING_MODES.effort;
if (TOGETHER_TOGGLE_REASONING_EFFORT_MODEL_IDS.has(modelId)) return TOGETHER_REASONING_MODES.toggleAndEffort;
if (TOGETHER_REASONING_ONLY.has(modelId)) return TOGETHER_REASONING_MODES.reasoningOnly;
return TOGETHER_REASONING_MODES.toggle;
}
export const together = defineProvider({
id: "together",
source: modelsDev({
provider: "together",
key: ["together", "togetherai", "together-ai"],
api: "openai-completions",
baseUrl: TOGETHER_BASE_URL,
compat: TOGETHER_BASE_COMPAT,
skipDeprecated: true,
route: (id, entry) => (entry.reasoning === true ? { compat: getTogetherReasoningMode(id).compat } : undefined),
}),
rules: [
rule({
why: "Together reasoning controls differ per model family; expose only the levels each mode can honor.",
match: { reasoning: true },
apply: (model) => {
model.thinkingLevelMap = { ...getTogetherReasoningMode(model.id).thinkingLevelMap };
},
}),
],
});

View file

@ -0,0 +1,40 @@
import { defineProvider, type GeneratedModel, roundCost } from "../dsl.ts";
const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh";
function perMillion(value: string | number | undefined): number {
const parsed = typeof value === "number" ? value : parseFloat(value ?? "0");
return roundCost((Number.isFinite(parsed) ? parsed : 0) * 1_000_000);
}
export const vercelAiGateway = defineProvider({
id: "vercel-ai-gateway",
source: async (context) => {
const models: GeneratedModel[] = [];
for (const model of await context.upstream.vercelAiGateway()) {
const tags = Array.isArray(model.tags) ? model.tags : [];
// Only include models that support tools
if (!tags.includes("tool-use")) continue;
models.push({
id: model.id,
name: model.name || model.id,
api: "anthropic-messages",
baseUrl: AI_GATEWAY_BASE_URL,
provider: "vercel-ai-gateway",
reasoning: tags.includes("reasoning"),
input: tags.includes("vision") ? ["text", "image"] : ["text"],
compat: { allowEmptySignature: true },
cost: {
input: perMillion(model.pricing?.input),
output: perMillion(model.pricing?.output),
cacheRead: perMillion(model.pricing?.input_cache_read),
cacheWrite: perMillion(model.pricing?.input_cache_write),
},
contextWindow: model.context_window || 4096,
maxTokens: model.max_tokens || 4096,
});
}
console.log(`Fetched ${models.length} tool-capable models from Vercel AI Gateway`);
return models;
},
});

View file

@ -0,0 +1,30 @@
import type { OpenAICompletionsCompat } from "../../../src/types.ts";
import { defineProvider, type ProviderDefinition } from "../dsl.ts";
import { modelsDev } from "../models-dev.ts";
// Built-in `xiaomi` targets the API billing endpoint (single stable URL, keys from
// platform.xiaomimimo.com). The three `xiaomi-token-plan-*` providers cover prepaid
// Token Plan endpoints in cn / ams / sgp.
const XIAOMI_COMPAT: OpenAICompletionsCompat = {
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "deepseek",
};
function xiaomiProvider(id: string, baseUrl: string): ProviderDefinition {
return defineProvider({
id,
source: modelsDev({
provider: id,
key: id,
api: "openai-completions",
baseUrl,
compat: XIAOMI_COMPAT,
skipDeprecated: true,
}),
});
}
export const xiaomi = xiaomiProvider("xiaomi", "https://api.xiaomimimo.com/v1");
export const xiaomiTokenPlanCn = xiaomiProvider("xiaomi-token-plan-cn", "https://token-plan-cn.xiaomimimo.com/v1");
export const xiaomiTokenPlanAms = xiaomiProvider("xiaomi-token-plan-ams", "https://token-plan-ams.xiaomimimo.com/v1");
export const xiaomiTokenPlanSgp = xiaomiProvider("xiaomi-token-plan-sgp", "https://token-plan-sgp.xiaomimimo.com/v1");

View file

@ -0,0 +1,55 @@
import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts";
import { type DefinedRule, defineProvider, rule } from "../dsl.ts";
import { getModelsDevCost, modelsDev } from "../models-dev.ts";
const ZAI_TOOL_STREAM_UNSUPPORTED_MODEL_IDS = ["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"];
const zaiRules: DefinedRule[] = [
rule({
why: "z.ai streams tool call deltas with top-level tool_stream on every model after GLM 4.5.",
match: { not: { id: ZAI_TOOL_STREAM_UNSUPPORTED_MODEL_IDS } },
set: { compat: { zaiToolStream: true } },
}),
rule({
why: "Cataloged effort levels enable reasoning_effort; GLM 5.2 additionally accepts `none` to disable thinking.",
match: { when: (_model, context) => getEffortThinkingLevelMap(context.reasoningOptions ?? []) !== undefined },
apply: (model, context) => {
const map = getEffortThinkingLevelMap(context.reasoningOptions ?? []);
if (!map) return;
if (model.id === "glm-5.2" || model.id === "glm-5.2-highspeed") map.off = "none";
model.thinkingLevelMap = map;
model.compat = { ...model.compat, supportsReasoningEffort: true };
},
}),
rule({
why: "Coding plans are subscription-priced; report the pay-as-you-go z.ai rates so usage has an estimated value.",
match: { when: (model, context) => context.modelsDev.zai?.models?.[model.id]?.cost !== undefined },
apply: (model, context) => {
model.cost = getModelsDevCost(context.modelsDev.zai?.models?.[model.id]?.cost);
},
}),
];
export const zai = defineProvider({
id: "zai",
source: modelsDev({
provider: "zai",
key: "zai-coding-plan",
api: "openai-completions",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: { supportsDeveloperRole: false, thinkingFormat: "zai" },
}),
rules: zaiRules,
});
export const zaiCodingCn = defineProvider({
id: "zai-coding-cn",
source: modelsDev({
provider: "zai-coding-cn",
key: "zhipuai-coding-plan",
api: "openai-completions",
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
compat: { supportsDeveloperRole: false, thinkingFormat: "zai" },
}),
rules: zaiRules,
});

View file

@ -0,0 +1,54 @@
import { definePack, rule } from "../dsl.ts";
import { supportsAnthropicMidConvoEffort, supportsAnthropicMidConvoSystemMessages } from "../shared.ts";
// Anthropic Messages transport capabilities that depend on the exact model and relay.
export const anthropicCompatPack = definePack("anthropic-compat", [
rule({
why: "Verified effort-only system messages and thinking binding on direct Anthropic and OpenRouter for Opus 5 and Fable/Mythos 5.1. OpenRouter rejects configuration_update on Opus 5 while accepting it on Fable 5.1.",
match: {
api: "anthropic-messages",
provider: ["anthropic", "openrouter"],
when: (model) => supportsAnthropicMidConvoEffort(model.id),
not: { provider: "openrouter", id: "anthropic/claude-opus-5" },
},
set: { compat: { supportsMidConvoEffort: true }, thinkingLevelMap: { off: null } },
}),
rule({
why: "Direct Anthropic accepts system messages and tool_addition/tool_removal blocks mid-conversation on Opus 4.8+ and Fable/Mythos 5.",
match: {
api: "anthropic-messages",
provider: "anthropic",
when: (model) => supportsAnthropicMidConvoSystemMessages(model.id),
},
set: { compat: { supportsMidConvoSystemMessages: true, supportsMidConvoToolChanges: true } },
}),
rule({
why: "OpenCode Zen and GitHub Copilot forward mid-conversation system messages but reject tool_addition/tool_removal blocks, so tool changes stay top-level there.",
match: {
api: "anthropic-messages",
provider: ["opencode", "github-copilot"],
when: (model) => supportsAnthropicMidConvoSystemMessages(model.id),
},
set: { compat: { supportsMidConvoSystemMessages: true } },
}),
rule({
why: "GitHub Copilot rejects per-tool eager_input_streaming on these Claude models.",
match: {
api: "anthropic-messages",
provider: "github-copilot",
id: ["claude-haiku-4.5", "claude-sonnet-4", "claude-sonnet-4.5"],
},
set: { compat: { supportsEagerToolInputStreaming: false } },
mayMatchNothing: true,
}),
rule({
why: "Xiaomi's Anthropic-compatible endpoints replay thinking without signatures.",
match: {
api: "anthropic-messages",
when: (model) => model.provider === "xiaomi" || model.provider.startsWith("xiaomi-token-plan-"),
},
set: { compat: { allowEmptySignature: true } },
mayMatchNothing: true,
}),
]);

View file

@ -0,0 +1,32 @@
import { compatOf, definePack, mergeCompat, rule } from "../dsl.ts";
import { supportsAnthropicMidConvoEffort } from "../shared.ts";
// Models direct Anthropic may transparently serve in place of the requested one.
const ANTHROPIC_ALLOWED_FALLBACK_MODEL_IDS: Record<string, string[]> = {
"claude-fable-5": ["claude-opus-4-8", "claude-opus-5"],
"claude-opus-5": ["claude-opus-4-8"],
};
export const anthropicFallbacksPack = definePack("anthropic-fallbacks", [
rule({
why: "Record the fallback models Anthropic may serve, with their prices, so responses from a fallback are priced correctly. Effort-bound models only fall back to models that also support mid-conversation effort.",
match: {
api: "anthropic-messages",
provider: "anthropic",
id: Object.keys(ANTHROPIC_ALLOWED_FALLBACK_MODEL_IDS),
},
apply: (model, context) => {
const fallbackModelIds = ANTHROPIC_ALLOWED_FALLBACK_MODEL_IDS[model.id];
const compatibleFallbackModelIds = compatOf(model).supportsMidConvoEffort
? fallbackModelIds.filter(supportsAnthropicMidConvoEffort)
: fallbackModelIds;
const allowedFallbackModels = compatibleFallbackModelIds.flatMap((fallbackModelId) => {
const fallbackModel = context.lookup("anthropic", fallbackModelId);
return fallbackModel && fallbackModel.api === "anthropic-messages"
? [{ provider: fallbackModel.provider, model: fallbackModel.id, cost: fallbackModel.cost }]
: [];
});
if (allowedFallbackModels.length > 0) mergeCompat(model, { allowedFallbackModels });
},
}),
]);

View file

@ -0,0 +1,42 @@
import { definePack, rule } from "../dsl.ts";
import { DEEPSEEK_COMPAT, KIMI_K3_MAX_TOKENS, QWEN_TOKEN_PLAN_PROVIDER_IDS } from "../shared.ts";
// Corrections that span providers. Single-provider corrections live with their provider.
export const catalogOverridesPack = definePack("catalog-overrides", [
rule({
why: "Claude Opus/Sonnet 4.6 have a 1M context window; upstream catalogs still report 200K.",
match: {
provider: ["anthropic", "opencode", "opencode-go"],
id: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-opus-4.6", "claude-sonnet-4.6"],
},
set: { contextWindow: 1000000 },
}),
rule({
why: "Keep Kimi K3's canonical output limit when gateway metadata is missing or incorrect.",
match: {
when: (model) =>
(model.provider === "openrouter" &&
(model.id === "moonshotai/kimi-k3" || model.id === "~moonshotai/kimi-latest")) ||
(model.provider === "vercel-ai-gateway" && model.id === "moonshotai/kimi-k3"),
},
set: { maxTokens: KIMI_K3_MAX_TOKENS },
mayMatchNothing: true,
}),
rule({
why: "DeepSeek V4 needs reasoning_content replayed on assistant messages everywhere; direct-style endpoints also take DeepSeek's thinking format, while OpenRouter and OpenCode keep native reasoning_effort (#9485).",
match: { api: "openai-completions", idIncludes: "deepseek-v4", not: { provider: QWEN_TOKEN_PLAN_PROVIDER_IDS } },
apply: (model) => {
const preservesNativeReasoningEffort = model.provider === "openrouter" || model.provider === "opencode";
model.compat = {
...model.compat,
...(preservesNativeReasoningEffort
? {
requiresReasoningContentOnAssistantMessages:
DEEPSEEK_COMPAT.requiresReasoningContentOnAssistantMessages,
}
: DEEPSEEK_COMPAT),
};
},
}),
]);

View file

@ -0,0 +1,24 @@
import type { RulePack } from "../dsl.ts";
import { anthropicCompatPack } from "./anthropic-compat.ts";
import { anthropicFallbacksPack } from "./anthropic-fallbacks.ts";
import { catalogOverridesPack } from "./catalog-overrides.ts";
import { openAiCompletionsCompatPack } from "./openai-completions-compat.ts";
import { promptCachePack } from "./prompt-cache.ts";
import { reasoningOptionsPack } from "./reasoning-options.ts";
import { thinkingLevelsPack } from "./thinking-levels.ts";
import { toolCapabilitiesPack } from "./tool-capabilities.ts";
/**
* Catalog-wide rule packs, applied in this order after every provider has been
* generated. Later packs see the effects of earlier ones.
*/
export const RULE_PACKS: readonly RulePack[] = [
catalogOverridesPack,
openAiCompletionsCompatPack,
anthropicCompatPack,
reasoningOptionsPack,
thinkingLevelsPack,
toolCapabilitiesPack,
promptCachePack,
anthropicFallbacksPack,
];

View file

@ -0,0 +1,170 @@
import type { Model, OpenAICompletionsCompat } from "../../../src/types.ts";
import { definePack, type GeneratedModel, rule } from "../dsl.ts";
import { TOGETHER_REASONING_ONLY_MODEL_IDS } from "../shared.ts";
// Request-shape defaults for OpenAI-compatible chat completions endpoints, keyed off the
// provider id or base URL. This is computed rather than declared because it describes
// endpoint families; per-model exceptions belong in provider rules, which win over it.
export const OPENAI_COMPLETIONS_DEFAULT_COMPAT = {
supportsStore: true,
supportsDeveloperRole: true,
supportsReasoningEffort: true,
supportsUsageInStreaming: true,
supportsFinishReason: true,
maxTokensField: "max_completion_tokens",
requiresToolResultName: false,
requiresAssistantAfterToolResult: false,
requiresThinkingAsText: false,
requiresReasoningContentOnAssistantMessages: false,
thinkingFormat: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsStrictMode: true,
supportsOpenAIGrammarTools: false,
supportsMidConvoSystemMessages: false,
supportsMidConvoToolAdditions: false,
sendSessionAffinityHeaders: false,
supportsLongCacheRetention: true,
} satisfies OpenAICompletionsCompat;
export type OpenAICompletionsResolvedCompat = OpenAICompletionsCompat &
Record<keyof typeof OPENAI_COMPLETIONS_DEFAULT_COMPAT, unknown>;
const TOGETHER_REASONING_ONLY = new Set<string>(TOGETHER_REASONING_ONLY_MODEL_IDS);
export function detectOpenAICompletionsCompat(model: Model<"openai-completions">): OpenAICompletionsResolvedCompat {
const provider = model.provider;
const baseUrl = model.baseUrl;
const isZai =
provider === "zai" ||
provider === "zai-coding-cn" ||
baseUrl.includes("api.z.ai") ||
baseUrl.includes("open.bigmodel.cn");
const isTogether =
provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz");
const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot.");
const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com");
const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com");
const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com");
const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com");
const isCerebras = provider === "cerebras" || baseUrl.includes("cerebras.ai");
const isTogetherReasoningOnly = isTogether && TOGETHER_REASONING_ONLY.has(model.id);
const isDeepSeek = provider === "deepseek" || baseUrl.toLowerCase().includes("deepseek.com");
const isNonStandard =
isNvidia ||
isCerebras ||
provider === "xai" ||
baseUrl.includes("api.x.ai") ||
isTogether ||
baseUrl.includes("chutes.ai") ||
isDeepSeek ||
isZai ||
isMoonshot ||
provider === "opencode" ||
baseUrl.includes("opencode.ai") ||
isCloudflareWorkersAI ||
isCloudflareAiGateway ||
isAntLing;
const useMaxTokens =
baseUrl.includes("chutes.ai") ||
isDeepSeek ||
isMoonshot ||
isCloudflareAiGateway ||
isTogether ||
isNvidia ||
isAntLing ||
isZai;
const isGrok = provider === "xai" || baseUrl.includes("api.x.ai");
const isOpenRouterDeveloperRoleModel =
isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/"));
const cacheControlFormat = provider === "openrouter" && /^~?anthropic\//.test(model.id) ? "anthropic" : undefined;
return {
supportsStore: !isNonStandard,
supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter),
supportsReasoningEffort:
!isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing,
supportsUsageInStreaming: true,
supportsFinishReason: true,
maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
requiresToolResultName: false,
requiresAssistantAfterToolResult: false,
requiresThinkingAsText: false,
requiresReasoningContentOnAssistantMessages: isDeepSeek,
thinkingFormat: isDeepSeek
? "deepseek"
: isZai
? "zai"
: isTogether && !isTogetherReasoningOnly
? "together"
: isAntLing
? "ant-ling"
: isOpenRouter
? "openrouter"
: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isCerebras,
supportsOpenAIGrammarTools: false,
supportsMidConvoSystemMessages: false,
supportsMidConvoToolAdditions: false,
...(cacheControlFormat ? { cacheControlFormat } : {}),
sendSessionAffinityHeaders: isOpenRouter,
supportsLongCacheRetention: !(
isTogether ||
isCloudflareWorkersAI ||
isCloudflareAiGateway ||
isNvidia ||
isAntLing
),
};
}
function isPlainEmptyObject(value: unknown): boolean {
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0;
}
/** The detected flags that differ from the runtime defaults, so generated data stays small. */
function openAICompletionsCompatDelta(compat: OpenAICompletionsResolvedCompat): OpenAICompletionsCompat {
const delta: Record<string, unknown> = {};
const defaults = OPENAI_COMPLETIONS_DEFAULT_COMPAT as Record<string, unknown>;
for (const [key, value] of Object.entries(compat)) {
const defaultValue = defaults[key];
if (isPlainEmptyObject(value) && isPlainEmptyObject(defaultValue)) continue;
if (value !== defaultValue) delta[key] = value;
}
return delta as OpenAICompletionsCompat;
}
/** Effective completions compat for a model: detected defaults under its declared flags. */
export function resolveOpenAICompletionsCompat(model: GeneratedModel): OpenAICompletionsResolvedCompat {
return {
...detectOpenAICompletionsCompat(model as Model<"openai-completions">),
...(model.compat as OpenAICompletionsCompat | undefined),
};
}
export const openAiCompletionsCompatPack = definePack("openai-completions-compat", [
rule({
why: "Record endpoint-family request defaults that differ from the runtime defaults; declared flags win.",
match: { api: "openai-completions" },
apply: (model) => {
const detected = openAICompletionsCompatDelta(
detectOpenAICompletionsCompat(model as Model<"openai-completions">),
);
model.compat = { ...detected, ...(model.compat as OpenAICompletionsCompat | undefined) };
},
}),
]);

View file

@ -0,0 +1,17 @@
import { definePack, rule } from "../dsl.ts";
export const promptCachePack = definePack("prompt-cache", [
rule({
why: "OpenAI charges prompt-cache writes starting with the GPT-5.6 family, and exactly those models accept prompt_cache_options; older models reject the parameter. https://developers.openai.com/api/docs/guides/prompt-caching",
match: { api: "openai-responses", provider: "openai", when: (model) => model.cost.cacheWrite > 0 },
set: { compat: { supportsExplicitPromptCacheMode: true } },
}),
// Do not add OpenAI lifetimes yet. Before enabling warming for explicit OpenAI caches,
// re-evaluate it using observed expiry, replay, and billing behavior; a documented TTL
// alone does not establish full cache loss.
rule({
why: "Anthropic ephemeral entries have a hard five-minute lifetime; ttl 1h extends it to one hour. Only direct Anthropic is annotated so cache warming does not assume equivalent behavior through proxies. https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
match: { api: "anthropic-messages", provider: "anthropic" },
set: { promptCache: { short: 300, long: 3600 } },
}),
]);

View file

@ -0,0 +1,31 @@
import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts";
import { compatOf, definePack, type GeneratedModel, mergeThinkingLevelMap, rule } from "../dsl.ts";
import { resolveOpenAICompletionsCompat } from "./openai-completions-compat.ts";
/** Whether pi sends effort values straight through for this model, so cataloged efforts apply verbatim. */
export function supportsDirectReasoningEffort(model: GeneratedModel): boolean {
if (model.api === "anthropic-messages") return compatOf(model).forceAdaptiveThinking === true;
if (
model.api === "openai-responses" ||
model.api === "azure-openai-responses" ||
model.api === "openai-codex-responses"
) {
return true;
}
if (model.api !== "openai-completions") return false;
const compat = resolveOpenAICompletionsCompat(model);
return compat.thinkingFormat === "openai" && compat.supportsReasoningEffort === true;
}
export const reasoningOptionsPack = definePack("models-dev-reasoning-options", [
rule({
why: "Expose the effort levels models.dev verified for models whose transport passes effort through unchanged.",
match: {
when: (model, context) => context.reasoningOptions !== undefined && supportsDirectReasoningEffort(model),
},
apply: (model, context) => {
const map = getEffortThinkingLevelMap(context.reasoningOptions ?? []);
if (map) mergeThinkingLevelMap(model, map);
},
}),
]);

View file

@ -0,0 +1,256 @@
import type { ThinkingLevelMap } from "../../../src/types.ts";
import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts";
import { compatOf, definePack, mergeThinkingLevelMap, rule } from "../dsl.ts";
import {
ANTHROPIC_ADAPTIVE_THINKING_ID_PARTS,
DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP,
DEEPSEEK_V4_THINKING_LEVEL_MAP,
OPENAI_MAX_MODEL_ID_PARTS,
OPENAI_XHIGH_MODEL_ID_PARTS,
supportsOpenAiXhigh,
} from "../shared.ts";
// Which pi thinking levels each model accepts and how they spell them. Rules apply in
// order and merge, so later rules refine earlier ones for the same model.
const OPENAI_RESPONSES_APIS = ["openai-responses", "azure-openai-responses", "openai-codex-responses"] as const;
const OPENAI_RESPONSES_NONE_REASONING_MODEL_IDS = [
"gpt-5.1",
"gpt-5.2",
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.5",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
];
// Checked manually against the authenticated GitHub Copilot /models endpoint on 2026-06-15.
// Keep this to narrow corrections over models.dev metadata instead of snapshotting Copilot's catalog.
const GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES: Record<string, ThinkingLevelMap> = {
"claude-opus-4.7": { minimal: "low" },
"claude-opus-4.8": { minimal: "low" },
"claude-opus-5": { minimal: "low" },
"claude-sonnet-4.6": { minimal: "low", max: "max" },
};
const ANTHROPIC_TEMPERATURE_UNSUPPORTED_ID_PARTS = ["opus-4-7", "opus-4.7", "opus-4-8", "opus-4.8", "opus-5", "opus.5"];
export const thinkingLevelsPack = definePack("thinking-levels", [
rule({
why: "GPT-5 Responses models reason by default; pi has no way to switch reasoning off.",
match: { api: ["openai-responses", "azure-openai-responses"], idPrefix: "gpt-5" },
set: { thinkingLevelMap: { off: null } },
}),
rule({
why: "GPT-6 Astra documents low through max and no minimal.",
match: { api: OPENAI_RESPONSES_APIS, id: "gpt-6-astra" },
set: {
thinkingLevelMap: {
off: null,
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: "max",
},
},
}),
rule({
why: "GitHub Copilot maps minimal GPT-5 effort to low.",
match: { provider: "github-copilot", idPrefix: "gpt-5" },
set: { thinkingLevelMap: { minimal: "low" } },
}),
rule({
why: "These OpenAI Responses models accept reasoning effort `none`.",
match: { provider: "openai", api: "openai-responses", id: OPENAI_RESPONSES_NONE_REASONING_MODEL_IDS },
set: { thinkingLevelMap: { off: "none" } },
}),
rule({
why: "xAI models without verified effort options must not send the undocumented none/minimal efforts.",
match: { provider: "xai", api: "openai-responses", when: (model) => model.thinkingLevelMap === undefined },
set: { thinkingLevelMap: { off: null, minimal: null } },
mayMatchNothing: true,
}),
rule({
why: "GPT-5.2+ accept reasoning effort xhigh everywhere they are served.",
match: { idIncludes: OPENAI_XHIGH_MODEL_ID_PARTS },
set: { thinkingLevelMap: { xhigh: "xhigh" } },
}),
rule({
why: "GPT-5.6 and GPT-6 Astra accept reasoning effort max on OpenAI-style APIs.",
match: { idIncludes: OPENAI_MAX_MODEL_ID_PARTS, api: [...OPENAI_RESPONSES_APIS, "openai-completions"] },
set: { thinkingLevelMap: { max: "max" } },
}),
rule({
why: "OpenAI rejects minimal effort on GPT-5.5.",
match: { provider: "openai", id: "gpt-5.5" },
set: { thinkingLevelMap: { minimal: null } },
}),
rule({
why: "GPT-5.5 Pro only runs at high effort and above.",
match: { idPattern: /gpt-5\.5-pro$/ },
set: { thinkingLevelMap: { off: null, minimal: null, low: null } },
}),
// Anthropic adaptive-thinking effort support (per Anthropic adaptive thinking docs):
// - "max" is available on all adaptive-thinking Claude models.
// - "xhigh" is only available on Opus 4.7/4.8/5, Sonnet 5, and Fable 5.
rule({
why: "Claude Opus/Sonnet 4.6 accept effort max.",
match: { idIncludes: ["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"] },
set: { thinkingLevelMap: { max: "max" } },
}),
rule({
why: "Claude Opus 4.7+/5 and Sonnet 5 accept effort xhigh and max.",
match: {
idIncludes: ["opus-4-7", "opus-4.7", "opus-4-8", "opus-4.8", "opus-5", "opus.5", "sonnet-5", "sonnet.5"],
},
set: { thinkingLevelMap: { xhigh: "xhigh", max: "max" } },
}),
rule({
why: "Claude Fable 5 always thinks and accepts effort xhigh and max.",
match: { idIncludes: "fable-5" },
set: { thinkingLevelMap: { off: null, xhigh: "xhigh", max: "max" } },
}),
rule({
why: "Claude 4.6+ models require the adaptive thinking request format.",
match: { api: "anthropic-messages", idIncludes: ANTHROPIC_ADAPTIVE_THINKING_ID_PARTS },
set: { compat: { forceAdaptiveThinking: true } },
}),
rule({
why: "Claude Opus 4.7+ rejects non-default temperature values.",
match: {
api: "anthropic-messages",
when: (model) =>
ANTHROPIC_TEMPERATURE_UNSUPPORTED_ID_PARTS.some((part) => model.id.toLowerCase().includes(part)),
},
set: { compat: { supportsTemperature: false } },
}),
rule({
why: "DeepSeek V4 documents high/max efforts (Flash also low); OpenRouter adds xhigh and drops max. Only fills in models without cataloged efforts.",
match: {
api: "openai-completions",
idIncludes: "deepseek-v4",
when: (model) => model.thinkingLevelMap === undefined,
},
apply: (model) => {
mergeThinkingLevelMap(
model,
model.provider === "openrouter"
? { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, xhigh: "xhigh", max: null }
: (model.provider === "deepseek" || model.provider === "opencode" || model.provider === "opencode-go") &&
model.id.includes("deepseek-v4-flash")
? DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP
: DEEPSEEK_V4_THINKING_LEVEL_MAP,
);
},
}),
rule({
why: "Groq's Qwen 3.6 27B only accepts the `default` reasoning effort.",
match: { provider: "groq", id: "qwen/qwen3.6-27b" },
set: { thinkingLevelMap: { minimal: null, low: null, medium: null, high: "default" } },
mayMatchNothing: true,
}),
rule({
why: "The ChatGPT Codex backend maps minimal effort to low on xhigh-capable models.",
match: { provider: "openai-codex", when: (model) => supportsOpenAiXhigh(model.id) },
set: { thinkingLevelMap: { minimal: "low" } },
}),
rule({
why: 'Kimi K2.7 Code is always-thinking. Official docs say `thinking: { type: "disabled" }` is rejected, and callers can omit the thinking parameter to use the enabled default.',
match: { provider: ["moonshotai", "moonshotai-cn"], id: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"] },
set: { thinkingLevelMap: { off: null } },
}),
rule({
why: "Mercury 2 in instant mode (reasoning_effort: \"none\") disables tool calling. Mark off unsupported so the openai-completions provider omits the reasoning param instead of sending effort none. Pi's low/medium/high pass through verbatim; OpenRouter normalizes to Mercury's vocabulary.",
match: { provider: "openrouter", idPrefix: "inception/mercury-2" },
set: { thinkingLevelMap: { off: null } },
mayMatchNothing: true,
}),
rule({
why: "OpenRouter accepts xhigh for GLM-5.2.",
match: { provider: "openrouter", id: "z-ai/glm-5.2" },
set: { thinkingLevelMap: { xhigh: "xhigh" } },
mayMatchNothing: true,
}),
rule({
why: "Fireworks Qwen Max currently advertises only a toggle. Prefer upstream effort metadata once available instead of replacing it with this fallback.",
match: {
provider: "fireworks",
api: "anthropic-messages",
id: "accounts/fireworks/models/qwen3p8-max",
when: (model) => compatOf(model).forceAdaptiveThinking === true && !model.thinkingLevelMap,
},
apply: (model) => {
model.thinkingLevelMap = getEffortThinkingLevelMap([{ type: "effort", values: ["low", "medium", "xhigh"] }]);
},
mayMatchNothing: true,
}),
rule({
why: "Fireworks Messages models with a cataloged toggle accept effort none; the Qwen 2.4T alias omits its verified toggle in models.dev.",
match: {
provider: "fireworks",
api: "anthropic-messages",
when: (model, context) =>
compatOf(model).forceAdaptiveThinking === true &&
(context.reasoningOptions?.some((option) => option.type === "toggle") === true ||
model.id === "accounts/fireworks/models/qwen3p8-2p4t-a95b"),
},
set: { thinkingLevelMap: { off: "none" } },
}),
rule({
why: "Fireworks DeepSeek V4 Pro accepts effort low despite models.dev omitting it (#9323).",
match: {
provider: "fireworks",
api: "anthropic-messages",
id: "accounts/fireworks/models/deepseek-v4-pro-0813",
when: (model) => compatOf(model).forceAdaptiveThinking === true,
},
set: { thinkingLevelMap: { low: "low" } },
mayMatchNothing: true,
}),
rule({
why: "Fireworks GLM 5.2 and its fast router support off/high/max. Fireworks maps low and medium to high, so do not expose those aliases as distinct levels.",
match: { provider: "fireworks", idIncludes: "glm-5p2" },
set: { thinkingLevelMap: { off: "none", minimal: null, low: null, medium: null, max: "max" } },
mayMatchNothing: true,
}),
rule({
why: "Fireworks maps Kimi K3 medium to high on both APIs; do not expose it as a distinct level.",
match: { provider: "fireworks", idIncludes: "kimi-k3" },
set: { thinkingLevelMap: { medium: null } },
mayMatchNothing: true,
}),
rule({
why: "OpenCode Go GLM-5.2 exposes only high and max.",
match: { provider: "opencode-go", id: "glm-5.2" },
set: { thinkingLevelMap: { off: null, minimal: null, low: null, medium: null, high: "high", max: "max" } },
mayMatchNothing: true,
}),
rule({
why: "OpenCode Go exposes Kimi K2.6 thinking as on/off, not distinct effort tiers.",
match: { provider: "opencode-go", id: "kimi-k2.6" },
set: { thinkingLevelMap: { minimal: null, low: null, medium: null } },
mayMatchNothing: true,
}),
rule({
why: "OpenCode Zen Grok Build reasons by default but rejects explicit reasoningEffort.",
match: { provider: "opencode", id: "grok-build-0.1" },
set: { thinkingLevelMap: { off: null, minimal: null, low: null, medium: null } },
mayMatchNothing: true,
}),
rule({
why: "Ring reasons by default. Only high/xhigh have documented explicit effort controls.",
match: { provider: "ant-ling", reasoning: true },
set: { thinkingLevelMap: { off: null, minimal: null, low: null, medium: null, high: "high", xhigh: "xhigh" } },
}),
rule({
why: "Narrow corrections over models.dev metadata, checked against the authenticated GitHub Copilot /models endpoint on 2026-06-15.",
match: { provider: "github-copilot", id: Object.keys(GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES) },
apply: (model) => mergeThinkingLevelMap(model, GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES[model.id]),
}),
]);

View file

@ -0,0 +1,117 @@
import { definePack, rule } from "../dsl.ts";
import { OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS, OPENAI_TOOL_SEARCH_MODEL_IDS } from "../shared.ts";
// Tool-definition and transcript features that depend on the exact model and relay.
// Responses endpoints verified (OpenAI, ChatGPT Codex backend, GitHub Copilot,
// opencode zen) or documented (Azure OpenAI, Cloudflare AI Gateway) to pass
// OpenAI custom grammar tools through. OpenAI rejects `type: "custom"` tools
// for pre-GPT-5 models (gpt-4.x, gpt-4o, o-series).
const OPENAI_GRAMMAR_TOOL_PROVIDERS = [
"openai",
"openai-codex",
"azure-openai-responses",
"github-copilot",
"opencode",
"cloudflare-ai-gateway",
];
const OPENAI_GRAMMAR_TOOL_APIS = ["openai-responses", "azure-openai-responses", "openai-codex-responses"] as const;
const GPT_5_OR_LATER = /^gpt-(?:[5-9]|[1-9]\d+)/;
const OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra"];
// Newer OpenAI Responses models accept developer messages after the conversation has started.
// OpenCode Zen, OpenCode Go, and GitHub Copilot pass both those messages and
// `additional_tools` items through to OpenAI unchanged; tool search is not verified
// through those proxies.
const OPENAI_RESPONSES_PROXY_PROVIDERS = ["opencode", "opencode-go", "github-copilot"];
// Moonshot Kimi K2.6/K2.7 accept system text after the conversation starts but reject
// tool-bearing system messages. Kimi K3 accepts both forms; Fireworks and OpenCode pass
// its tool-bearing form through. GitHub Copilot forwards K3 text but silently drops its
// tool-bearing message. DeepSeek V4 Pro and OpenAI models behind OpenRouter also accept
// plain system text in place.
const KIMI_K3_FULL_MID_CONVO = {
api: "openai-completions",
when: (model: { provider: string; id: string }) =>
(model.provider.startsWith("moonshot") && model.id === "kimi-k3") ||
(model.provider === "fireworks" && model.id.includes("kimi-k3")) ||
((model.provider === "opencode" || model.provider === "opencode-go") && model.id === "kimi-k3"),
} as const;
export const toolCapabilitiesPack = definePack("tool-capabilities", [
rule({
why: "OpenAI and Cloudflare AI Gateway Responses accept strict JSON-schema function tools.",
match: { api: "openai-responses", provider: ["openai", "cloudflare-ai-gateway"] },
set: { compat: { supportsStrictMode: true } },
}),
rule({
why: "Direct Anthropic accepts strict tool schemas.",
match: { api: "anthropic-messages", provider: "anthropic" },
set: { compat: { supportsStrictTools: true } },
}),
rule({
why: "Responses endpoints verified or documented to pass OpenAI custom grammar tools through; OpenAI rejects them for pre-GPT-5 models.",
match: { api: OPENAI_GRAMMAR_TOOL_APIS, provider: OPENAI_GRAMMAR_TOOL_PROVIDERS, idPattern: GPT_5_OR_LATER },
set: { compat: { supportsOpenAIGrammarTools: true } },
}),
rule({
why: "These OpenAI Responses models support client-executed tool search and message-anchored additional_tools.",
match: { api: "openai-responses", provider: "openai", id: OPENAI_TOOL_SEARCH_MODEL_IDS },
set: { compat: { supportsToolSearch: true, supportsAdditionalTools: true } },
}),
rule({
why: "The ChatGPT Codex backend supports tool search on these models; additional_tools only on GPT-5.6+.",
match: { api: "openai-codex-responses", provider: "openai-codex", id: OPENAI_TOOL_SEARCH_MODEL_IDS },
apply: (model) => {
model.compat = {
...model.compat,
...(OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS.includes(model.id) ? { supportsAdditionalTools: true } : {}),
supportsToolSearch: true,
};
},
}),
rule({
why: "Kimi K3 accepts system messages and tool additions mid-conversation on Moonshot, Fireworks, and OpenCode.",
match: KIMI_K3_FULL_MID_CONVO,
set: { compat: { supportsMidConvoSystemMessages: true, supportsMidConvoToolAdditions: true } },
}),
rule({
why: "These chat completions models accept plain system text mid-conversation but not tool-bearing system messages.",
match: {
api: "openai-completions",
when: (model) =>
(model.provider.startsWith("moonshot") &&
(model.id === "kimi-k2.6" ||
model.id === "kimi-k2.7-code" ||
model.id === "kimi-k2.7-code-highspeed")) ||
(model.provider === "github-copilot" && model.id === "kimi-k3") ||
(model.provider === "deepseek" && model.id === "deepseek-v4-pro") ||
(model.provider === "openrouter" &&
model.id.startsWith("openai/") &&
(OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS as readonly string[]).includes(
model.id.slice("openai/".length),
)),
},
set: { compat: { supportsMidConvoSystemMessages: true } },
}),
rule({
why: "Newer OpenAI Responses models accept developer messages after the conversation has started.",
match: {
id: OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS,
when: (model) =>
(model.provider === "openai" && model.api === "openai-responses") ||
(model.provider === "openai-codex" && model.api === "openai-codex-responses"),
},
set: { compat: { supportsMidConvoSystemMessages: true } },
}),
rule({
why: "OpenCode Zen, OpenCode Go, and GitHub Copilot pass mid-conversation developer messages and additional_tools through to OpenAI unchanged.",
match: {
api: "openai-responses",
provider: OPENAI_RESPONSES_PROXY_PROVIDERS,
id: OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS,
},
set: { compat: { supportsMidConvoSystemMessages: true, supportsAdditionalTools: true } },
}),
]);

View file

@ -0,0 +1,136 @@
import type { ModelCost, OpenAICompletionsCompat, ThinkingLevelMap } from "../../src/types.ts";
import { roundCost } from "./dsl.ts";
// Values shared by more than one provider declaration or rule pack. Anything used in a
// single place belongs next to its provider or rule instead.
export const OPENAI_BASE_URL = "https://api.openai.com/v1";
export const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272000;
/** OpenAI models whose long-context requests are billed at higher rates. */
export const OPENAI_LONG_CONTEXT_PRICING_MODEL_IDS = [
"gpt-5.4",
"gpt-5.4-pro",
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-6-astra",
] as const;
export function withOpenAiLongContextPricing(cost: ModelCost): ModelCost {
return {
...cost,
tiers: [
{
inputTokensAbove: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
input: roundCost(cost.input * 2),
output: roundCost(cost.output * 1.5),
cacheRead: roundCost(cost.cacheRead * 2),
cacheWrite: roundCost(cost.cacheWrite * 2),
},
],
};
}
// OpenAI reduced GPT-5.6 Terra and Luna prices on 2026-07-30. Keep these
// authoritative values until models.dev and passthrough catalogs catch up.
// https://developers.openai.com/api/docs/pricing
export const OPENAI_GPT_56_STANDARD_COSTS: Record<string, ModelCost> = {
"gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
"gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 },
};
export const OPENAI_GPT_56_SOL_COST: ModelCost = { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 };
export const OPENAI_GPT_6_ASTRA_COST: ModelCost = { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 };
/** OpenAI Responses models with client-executed tool search. */
export const OPENAI_TOOL_SEARCH_MODEL_IDS = [
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-pro",
"gpt-5.5",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-6-astra",
] as const;
/** OpenAI models that accept developer messages after the conversation has started. */
export const OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS = OPENAI_TOOL_SEARCH_MODEL_IDS;
export const OPENAI_XHIGH_MODEL_ID_PARTS = [
"gpt-5.2",
"gpt-5.3",
"gpt-5.4",
"gpt-5.5",
"gpt-5.6",
"gpt-6-astra",
] as const;
export const OPENAI_MAX_MODEL_ID_PARTS = ["gpt-5.6", "gpt-6-astra"] as const;
export function supportsOpenAiXhigh(modelId: string): boolean {
return OPENAI_XHIGH_MODEL_ID_PARTS.some((part) => modelId.includes(part));
}
export const KIMI_K3_MAX_TOKENS = 131072;
export const KIMI_K3_COST: ModelCost = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 };
export const DEEPSEEK_COMPAT: OpenAICompletionsCompat = {
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "deepseek",
};
export const DEEPSEEK_V4_THINKING_LEVEL_MAP: ThinkingLevelMap = {
minimal: null,
low: null,
medium: null,
high: "high",
max: "max",
};
export const DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP: ThinkingLevelMap = {
...DEEPSEEK_V4_THINKING_LEVEL_MAP,
low: "low",
};
export const QWEN_TOKEN_PLAN_PROVIDER_IDS = [
"qwen-token-plan",
"qwen-token-plan-cn",
"qwen-token-plan-individual",
] as const;
export const TOGETHER_BASE_URL = "https://api.together.ai/v1";
/** Together models that always reason and reject the reasoning toggle. */
export const TOGETHER_REASONING_ONLY_MODEL_IDS = ["deepseek-ai/DeepSeek-R1", "MiniMaxAI/MiniMax-M2.7"] as const;
/** Claude id fragments across providers; providers spell versions with `-` or `.`. */
export const ANTHROPIC_ADAPTIVE_THINKING_ID_PARTS = [
"opus-4-6",
"opus-4.6",
"opus-4-7",
"opus-4.7",
"opus-4-8",
"opus-4.8",
"opus-5",
"opus.5",
"sonnet-4-6",
"sonnet-4.6",
"sonnet-5",
"sonnet.5",
"fable-5",
"mythos-5",
] as const;
export function supportsAnthropicMidConvoEffort(modelId: string): boolean {
const id = modelId.toLowerCase().replace(/^~?anthropic\//, "");
return /^claude-opus-5(?:-\d{8})?$/.test(id) || /^claude-(?:fable|mythos)-5(?:[.-]1)(?:-\d{8})?$/.test(id);
}
export function supportsAnthropicMidConvoSystemMessages(modelId: string): boolean {
return (
/^claude-opus-(?:4[.-]8|5)(?:-\d{8})?$/.test(modelId) ||
/^claude-(?:fable|mythos)-5(?:[.-]1)?(?:-\d{8})?$/.test(modelId)
);
}
export function isGemma4Model(modelId: string): boolean {
return /gemma-?4/.test(modelId.toLowerCase());
}

View file

@ -0,0 +1,236 @@
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;
}
}

View file

@ -49,6 +49,13 @@ function sanitizeRadiusGatewayConfig(config: unknown): RadiusGatewayConfig | und
};
}
/** Validates a raw `/v1/config` payload from a Radius gateway. */
export function parseRadiusGatewayConfig(payload: unknown, gateway: string): RadiusGatewayConfig {
const config = sanitizeRadiusGatewayConfig(payload);
if (!config) throw new Error(`Invalid Radius config from ${gateway}`);
return config;
}
export function normalizeRadiusGatewayUrl(value: string): string {
const withScheme = /^https?:\/\//iu.test(value) ? value : `https://${value}`;
return withScheme.replace(/\/+$/u, "");
@ -90,7 +97,5 @@ export async function loadRadiusGatewayConfig(
`Could not load Radius config from ${gateway}: ${response.status}: ${truncateHttpBody(await response.text())}`,
);
}
const config = sanitizeRadiusGatewayConfig(await response.json());
if (!config) throw new Error(`Invalid Radius config from ${gateway}`);
return config;
return parseRadiusGatewayConfig(await response.json(), gateway);
}

View file

@ -54,6 +54,6 @@
"@earendil-works/pi-agent-old/*": ["./packages/agent-old/src/*"]
}
},
"include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/session-backends/*/src/**/*", "packages/session-backends/*/test/**/*", "packages/coding-agent/examples/**/*"],
"include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/ai/scripts/**/*", "packages/session-backends/*/src/**/*", "packages/session-backends/*/test/**/*", "packages/coding-agent/examples/**/*"],
"exclude": ["**/dist/**", "packages/coding-agent/examples/extensions/gondolin/**"]
}