mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
3390bd9363
commit
8d95fd138b
51 changed files with 3879 additions and 3264 deletions
|
|
@ -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/`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
166
packages/ai/scripts/model-generation/README.md
Normal file
166
packages/ai/scripts/model-generation/README.md
Normal 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.
|
||||
231
packages/ai/scripts/model-generation/dsl.ts
Normal file
231
packages/ai/scripts/model-generation/dsl.ts
Normal 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));
|
||||
}
|
||||
219
packages/ai/scripts/model-generation/engine.ts
Normal file
219
packages/ai/scripts/model-generation/engine.ts
Normal 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;
|
||||
}
|
||||
115
packages/ai/scripts/model-generation/models-dev.ts
Normal file
115
packages/ai/scripts/model-generation/models-dev.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
197
packages/ai/scripts/model-generation/output.ts
Normal file
197
packages/ai/scripts/model-generation/output.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}),
|
||||
});
|
||||
12
packages/ai/scripts/model-generation/providers/anthropic.ts
Normal file
12
packages/ai/scripts/model-generation/providers/anthropic.ts
Normal 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",
|
||||
}),
|
||||
});
|
||||
42
packages/ai/scripts/model-generation/providers/azure.ts
Normal file
42
packages/ai/scripts/model-generation/providers/azure.ts
Normal 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];
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
104
packages/ai/scripts/model-generation/providers/baseten.ts
Normal file
104
packages/ai/scripts/model-generation/providers/baseten.ts
Normal 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"] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
85
packages/ai/scripts/model-generation/providers/cloudflare.ts
Normal file
85
packages/ai/scripts/model-generation/providers/cloudflare.ts
Normal 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]);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
71
packages/ai/scripts/model-generation/providers/fireworks.ts
Normal file
71
packages/ai/scripts/model-generation/providers/fireworks.ts
Normal 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 } : {}) } };
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
@ -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 },
|
||||
}),
|
||||
],
|
||||
});
|
||||
74
packages/ai/scripts/model-generation/providers/google.ts
Normal file
74
packages/ai/scripts/model-generation/providers/google.ts
Normal 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 } },
|
||||
}),
|
||||
],
|
||||
});
|
||||
73
packages/ai/scripts/model-generation/providers/index.ts
Normal file
73
packages/ai/scripts/model-generation/providers/index.ts
Normal 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,
|
||||
];
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
35
packages/ai/scripts/model-generation/providers/minimax.ts
Normal file
35
packages/ai/scripts/model-generation/providers/minimax.ts
Normal 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],
|
||||
});
|
||||
41
packages/ai/scripts/model-generation/providers/mistral.ts
Normal file
41
packages/ai/scripts/model-generation/providers/mistral.ts
Normal 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 };
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
64
packages/ai/scripts/model-generation/providers/moonshot.ts
Normal file
64
packages/ai/scripts/model-generation/providers/moonshot.ts
Normal 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,
|
||||
});
|
||||
69
packages/ai/scripts/model-generation/providers/nvidia.ts
Normal file
69
packages/ai/scripts/model-generation/providers/nvidia.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
97
packages/ai/scripts/model-generation/providers/openai.ts
Normal file
97
packages/ai/scripts/model-generation/providers/openai.ts
Normal 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 },
|
||||
}),
|
||||
],
|
||||
});
|
||||
153
packages/ai/scripts/model-generation/providers/opencode.ts
Normal file
153
packages/ai/scripts/model-generation/providers/opencode.ts
Normal 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;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
91
packages/ai/scripts/model-generation/providers/openrouter.ts
Normal file
91
packages/ai/scripts/model-generation/providers/openrouter.ts
Normal 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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
14
packages/ai/scripts/model-generation/providers/radius.ts
Normal file
14
packages/ai/scripts/model-generation/providers/radius.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
154
packages/ai/scripts/model-generation/providers/static.ts
Normal file
154
packages/ai/scripts/model-generation/providers/static.ts
Normal 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"]),
|
||||
}),
|
||||
],
|
||||
});
|
||||
72
packages/ai/scripts/model-generation/providers/together.ts
Normal file
72
packages/ai/scripts/model-generation/providers/together.ts
Normal 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 };
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
30
packages/ai/scripts/model-generation/providers/xiaomi.ts
Normal file
30
packages/ai/scripts/model-generation/providers/xiaomi.ts
Normal 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");
|
||||
55
packages/ai/scripts/model-generation/providers/zai.ts
Normal file
55
packages/ai/scripts/model-generation/providers/zai.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
}),
|
||||
]);
|
||||
|
|
@ -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 });
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
}),
|
||||
]);
|
||||
24
packages/ai/scripts/model-generation/rules/index.ts
Normal file
24
packages/ai/scripts/model-generation/rules/index.ts
Normal 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,
|
||||
];
|
||||
|
|
@ -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) };
|
||||
},
|
||||
}),
|
||||
]);
|
||||
17
packages/ai/scripts/model-generation/rules/prompt-cache.ts
Normal file
17
packages/ai/scripts/model-generation/rules/prompt-cache.ts
Normal 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 } },
|
||||
}),
|
||||
]);
|
||||
|
|
@ -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);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
256
packages/ai/scripts/model-generation/rules/thinking-levels.ts
Normal file
256
packages/ai/scripts/model-generation/rules/thinking-levels.ts
Normal 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]),
|
||||
}),
|
||||
]);
|
||||
117
packages/ai/scripts/model-generation/rules/tool-capabilities.ts
Normal file
117
packages/ai/scripts/model-generation/rules/tool-capabilities.ts
Normal 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 } },
|
||||
}),
|
||||
]);
|
||||
136
packages/ai/scripts/model-generation/shared.ts
Normal file
136
packages/ai/scripts/model-generation/shared.ts
Normal 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());
|
||||
}
|
||||
236
packages/ai/scripts/model-generation/sources.ts
Normal file
236
packages/ai/scripts/model-generation/sources.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/**"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue