Merge origin/main into replication

Resolved by taking main's storage (StoreStorage and the Chord store's outbox
replace the rebuilt log): drops ReplayableStorage, storage/log.ts, the v3
sequence stamps and the *_by_seq indexes. sync/ is ported in the next commit.
This commit is contained in:
Cristina Poncela Cubeiro 2026-11-02 14:59:31 +01:00
commit 42b98d3954
1531 changed files with 206488 additions and 14293 deletions

View file

@ -407,3 +407,5 @@ Jay-Madden issue
lalyos pr
Lincoln504 pr
vimtor pr

View file

@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
jobs:
build-check-test:
build-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
@ -23,12 +23,6 @@ jobs:
node-version: 22
cache: npm
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
sudo ln -s $(which fdfind) /usr/local/bin/fd
- name: Install dependencies
run: npm ci --ignore-scripts
@ -55,11 +49,61 @@ jobs:
- name: Check
run: npm run check
# Tests are split across parallel jobs so the slowest suites do not run back to back.
test:
name: test (${{ matrix.name }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: coding-agent
- name: durable
- name: other
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
# fd and ripgrep are used by coding-agent tools tests and tui autocomplete tests.
- name: Install system dependencies
if: matrix.name != 'durable'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends fd-find ripgrep
sudo ln -s $(which fdfind) /usr/local/bin/fd
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Build
run: npm run build
- name: Build the pi-env daemon
if: matrix.name == 'other'
run: npm run build:daemon --workspace packages/env
- name: Test
run: npm test
env:
SUITE: ${{ matrix.name }}
run: |
if [ "$SUITE" != other ]; then
npm run test --workspace "packages/$SUITE"
exit 0
fi
# Every other workspace, so new packages are tested without editing this workflow.
args=()
for dir in packages/*/; do
case "$dir" in packages/coding-agent/ | packages/durable/) continue ;; esac
args+=(--workspace "${dir%/}")
done
npm run test:scripts
npm run test --if-present "${args[@]}"
mcp-conformance:
runs-on: ubuntu-latest

View file

@ -218,7 +218,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
};
pi.on("before_agent_start", async (event, ctx) => {
if (!ctx.hasUI) return;
if (!ctx.hasUI || event.messageType !== "user") return;
const match = extractPromptMatch(event.prompt);
if (!match) {
return;

View file

@ -23,11 +23,11 @@ Most providers reuse an existing API such as `openai-completions`; skip this ste
- Register the lazy wrapper in the builtin API list in `src/compat.ts`.
- Add `export type` re-exports in `src/index.ts` for option types that should be available from the root entry. The `./api/*` subpath export in `package.json` already covers the new module.
## 3. Model Generation (`packages/ai/scripts/generate-models.ts`)
## 3. Model Catalog (`packages/ai/catalog/providers/<id>.ts`)
- Add logic to fetch/parse models from the provider source.
- Map to the standardized `Model` interface.
- Regenerate. This writes the stable catalog wrapper `src/providers/<id>.models.ts` (exporting `<ID>_MODELS`) that the provider factory imports, and updates `src/models.generated.ts`.
- Add a provider definition with `defineProvider()` and register it in `catalog/providers/index.ts`. Read `packages/ai/catalog/README.md` first.
- For a models.dev provider, use `modelsDevModels()`; set compat defaults per API in `compat` and provider-specific behavior in `finalize`.
- Regenerate with `npm run generate-models` in `packages/ai`. This writes the stable catalog wrapper `src/providers/<id>.models.ts` (exporting `<ID>_MODELS`) that the provider factory imports, and updates `src/models.generated.ts`.
## 4. Provider Factory and Auth (`packages/ai/src/providers/`)

View file

@ -25,12 +25,27 @@
- Always ask before removing functionality or code that appears intentional.
- Do not preserve backward compatibility unless the user asks for it.
- Never hardcode key checks (e.g. `matchesKey(keyData, "ctrl+x")`). Add defaults to `DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS` so they stay configurable.
- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead, then regenerate. Including the resulting `models.generated.ts` diff is always OK, even if regeneration includes unrelated upstream model metadata changes. The generated model data in `packages/ai/src/providers/data/` is gitignored: never stage it, and don't look for it in git history.
- Never modify `packages/ai/src/models.generated.ts` directly; update the catalog definitions in `packages/ai/catalog/` instead (see its README), then regenerate. Including the resulting `models.generated.ts` diff is always OK, even if regeneration includes unrelated upstream model metadata changes. The generated model data in `packages/ai/src/providers/data/` is gitignored: never stage it, and don't look for it in git history.
## Chord v2 Code Style
Binding for `packages/chord/src/next/` and its tests, when writing and when reviewing. The model is pi-durable's source.
- Files: one concept per file, named after it; no one-helper files, no grab-bag files. First line: a comment saying what the file is for. Order: imports, exported types, then the public entry point first and its helpers below it in the order they are used; constants right above their first user. A blank line between top-level declarations. Split files by concept, never by size: one concept may take a long file (up to about 1,000 lines is fine); a second concept in a file is the reason to split.
- Functions: every export has a one-line doc comment saying what it does or guarantees. Inline comments explain a non-obvious why (cost, race, decided rule), never what the next line does. Short functions with one job (about 40 lines needs a reason; one `switch` over a tagged union is fine). No boolean flag parameters that switch behaviour: two functions or a tagged value. Never write the same lines twice. Context last: required args, then `context`/`ctx` (always required, as in Durable; optional only on error-reporting paths), then optional args.
- Types and checks: tagged unions for every state and result. Casts only where TypeScript cannot express a decided invariant, each with a short why. Every remaining runtime check names the trust boundary it serves (wire input, schema-checked params and arguments, declaration policy the types cannot express). Same-host values are plain data, immutable by convention: never copied, frozen or walked to enforce it; freeze only Chord-owned metadata when a decision says so. No defensive code against broken adapters, callers or our own modules.
- Cost and memory: no hot-path work a decision did not ask for (walks, copies, compilations, per-call allocations). The function doing counted work counts it through its context; call sites never count for others. Caches are `WeakMap`s keyed by their owner, each with a GC test; no module-level strong caches.
- Names and API: the design's vocabulary (`params`, `binding`, `principal key`), no invented synonyms; decided `ChordError` codes and reasons. Export only what a later layer or app needs. No dev or test modes, no unrequested options.
- Tests: one test file per source file, one `describe` per mechanism, the layer spec's test list case by case; cost guards through the in-memory telemetry adapter; GC tests for every cache and binding. Every slice also adds one or more runnable examples in `packages/chord/test/next/examples/NN-topic.ts`, one topic each, numbered across slices, written as references for Chord's users (Durable's `test/examples/` format: header with what it shows and how to run it, commented steps, printed results).
- Slices: Chord v2 is built in vertical slices (`packages/chord/docs/next/slices.md`). Implementers and reviewers read the slice spec, the feature map in `slices.md` and the whole of `packages/chord/docs/next/design.md` in full, and check the work against all three; slice specs cite design sections for orientation, not as a reading limit.
- Deferred work: anything left to a later slice goes into the section "Carry-over" of `packages/chord/docs/next/slices.md` under that slice, in the same change; a slice's spec and build include every entry of its section.
- Before reporting: re-read every changed file top to bottom as a stranger would; each file must be understandable without jumping around. Then `npm run check` and the layer's tests.
## Commands
- After code changes (not docs): `npm run check` (full output, no tail). Fix all errors, warnings, and infos before committing. Does not run tests.
- Never run `npm run build` or `npm test` unless requested by the user.
- Run `npm run build` whenever something needs built output (`dist/`): tests or benchmarks that import built packages, Durable's workerd suite. Never use it to type check or lint: that is `npm run check`, the preferred check.
- Never run `npm test` unless requested by the user.
- Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run `./test.sh` from the repo root. Otherwise run specific tests from the package root:
- Vitest: `node "$(git rev-parse --show-toplevel)/node_modules/vitest/dist/cli.js" --run test/specific.test.ts`
- `packages/tui` (`node:test`): `node --test test/specific.test.ts`

View file

@ -29,6 +29,7 @@
"scripts/model-catalog-protocol.test.ts",
"packages/*/src/**/*.ts",
"packages/*/test/**/*.ts",
"packages/ai/catalog/**/*.ts",
"packages/coding-agent/examples/**/*.ts",
"packages/agent/examples/**/*.ts",
"!**/node_modules/**/*",

View file

@ -1,3 +1,3 @@
{
"revision": "sha256-7fe1b41feaf43a7aad4bc832e6b306c7ced56e6d41bbc724b74e91eeacff6edf"
"revision": "sha256-f4dbabe79cda4b38aee4a761b23fd5eaf526ac2e28fed3c9ee86e37ca0e5d71e"
}

861
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,17 +8,16 @@
"packages/coding-agent/examples/extensions/with-deps",
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
"packages/coding-agent/examples/extensions/sandbox",
"packages/coding-agent/examples/extensions/gondolin"
],
"scripts": {
"clean": "npm run clean --workspaces",
"build": "cd packages/telemetry && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
"build:offline": "cd packages/telemetry && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build:offline && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
"build": "cd packages/telemetry && npm run build && cd ../schema && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
"build:offline": "cd packages/telemetry && npm run build && cd ../schema && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build:offline && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
"build:native:darwin": "npm --prefix packages/tui run build:native:darwin",
"build:native:linux": "npm --prefix packages/tui run build:native:linux",
"build:native:win32": "npm --prefix packages/tui run build:native:win32",
"check": "node scripts/biome-check.mjs --write && npm run check:pinned-deps && npm run check:runtime-deps && npm run check:ts-imports && npm run check:entry-graphs && npm run check:install-lock:coding-agent && tsc --noEmit && npm run check:browser-smoke",
"check": "node scripts/biome-check.mjs --write && npm run check:pinned-deps && npm run check:runtime-deps && npm run check:ts-imports && npm run check:entry-graphs && npm run check:install-lock:coding-agent && tsc --noEmit && tsc --noEmit -p packages/coding-agent/examples/tsconfig.json && npm run check:browser-smoke",
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
"check:runtime-deps": "node scripts/check-runtime-deps.mjs",
@ -57,14 +56,12 @@
"prepare": "husky"
},
"devDependencies": {
"@anthropic-ai/sandbox-runtime": "0.0.26",
"@biomejs/biome": "2.3.5",
"@biomejs/wasm-nodejs": "2.3.5",
"@types/node": "22.19.19",
"esbuild": "0.28.2",
"husky": "9.1.7",
"pnpm": "11.15.1",
"shx": "0.3.4",
"typescript": "7.0.2",
"yaml": "2.9.0"
},

View file

@ -2,6 +2,12 @@
## [Unreleased]
## [1.5.0] - 2026-11-02
### Breaking Changes
- `AgentTool` takes any JSON Schema object as `parameters` (`AgentTool<TParameters extends object>`) and types `params` and `prepareArguments` through `@earendil-works/pi-schema` (`AgentToolParams<TParameters>`): pi-schema's builders give their static type, TypeBox schemas give TypeBox's static type for the common kinds (String, Number, Integer, Boolean, Null, Literal, Array, Object with optional and readonly properties, Union, Record, Unsafe, Unknown, Any; other kinds such as Enum, Tuple and Intersect give `unknown`), other schemas give `unknown`, `AgentTool<any>` stays `any`. `outputSchema` is any JSON Schema object. `typebox` is no longer a dependency. Migration: build parameters with pi-schema (`obj({ path: str() })` instead of `Type.Object({ path: Type.String() })`), or keep TypeBox schemas (narrow `params` yourself only where they use other kinds).
### Fixed
- Fixed `streamProxy()` re-parsing the accumulated tool-call arguments on every delta and keeping their raw text on the partial content block ([#9265](https://github.com/earendil-works/pi/issues/9265))

View file

@ -455,18 +455,20 @@ const agent = new Agent({
## Tools
Define tools using `AgentTool`:
Define tools using `AgentTool`. `parameters` is any JSON Schema object; built with `@earendil-works/pi-schema`'s builders, it also types `params` (other schemas give `unknown`, `AgentTool<any>` gives `any`):
```typescript
import { Type } from "typebox";
import { obj, str } from "@earendil-works/pi-schema";
const readFileTool: AgentTool = {
const readFileParameters = obj({
path: str({ description: "File path" }),
});
const readFileTool: AgentTool<typeof readFileParameters> = {
name: "read_file",
label: "Read File", // For UI display
description: "Read a file's contents",
parameters: Type.Object({
path: Type.String({ description: "File path" }),
}),
parameters: readFileParameters,
// Override execution mode for this tool (optional).
// "sequential" forces the entire batch to run one at a time.
// "parallel" allows concurrent execution with other tool calls.

View file

@ -18,7 +18,7 @@ import {
renderDeclarations,
} from "@earendil-works/pi-codemode";
import { type McpClient, toLlmContent } from "@earendil-works/pi-mcp";
import { Type } from "typebox";
import { obj, str } from "@earendil-works/pi-schema";
/** Provider tool names are limited to 64 characters of `[A-Za-z0-9_-]`. */
function toToolName(prefix: string, name: string): string {
@ -45,12 +45,12 @@ export async function createMcpTools(client: McpClient, prefix: string): Promise
label: tool.title ?? tool.name,
description: tool.description ?? tool.title ?? `MCP tool ${tool.name}`,
// Providers require an object schema, and some reject one without `properties`.
parameters: Type.Unsafe({
parameters: {
...tool.inputSchema,
type: "object",
properties: tool.inputSchema.properties ?? {},
}),
outputSchema: tool.outputSchema ? Type.Unsafe<Record<string, unknown>>(tool.outputSchema) : undefined,
},
outputSchema: tool.outputSchema,
execute: async (_toolCallId, params, signal, onUpdate) => {
const result = await client.callTool(tool.name, params as Record<string, unknown>, {
signal,
@ -104,9 +104,11 @@ export function createNestedToolRunner(
export const CODEMODE_TOOL_NAME = "codemode";
const codemodeParameters = Type.Object({
code: Type.String({ description: "JavaScript source. Top-level await and return work." }),
});
const codemodeParameters = obj(
{ code: str({ description: "JavaScript source. Top-level await and return work." }) },
// Open, as the tool accepted extra arguments before it moved from TypeBox.
{ open: true },
);
/**
* A tool that runs model-written JavaScript in the codemode sandbox, where `tools.<name>(args)`
@ -121,7 +123,6 @@ export function createCodemodeTool(
const sandboxTools: CodemodeTool[] = callable.map((tool) => ({
name: tool.name,
description: tool.description,
// TypeBox schemas are JSON Schema.
inputSchema: tool.parameters as CodemodeJsonSchema,
// What the script receives: the structured result if the tool declares one, else its text.
outputSchema: (tool.outputSchema as CodemodeJsonSchema | undefined) ?? { type: "string" },

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-agent-core",
"version": "1.4.0",
"version": "1.5.0",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
@ -17,14 +17,14 @@
"README.md"
],
"scripts": {
"clean": "shx rm -rf dist",
"clean": "node ../../scripts/package-files.mjs clean dist",
"build": "tsc -p tsconfig.build.json",
"test": "vitest --run",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@earendil-works/pi-ai": "1.4.0",
"typebox": "1.3.27"
"@earendil-works/pi-ai": "1.5.0",
"@earendil-works/pi-schema": "1.5.0"
},
"keywords": [
"ai",

View file

@ -14,7 +14,7 @@ import type {
TranscriptContext,
Usage,
} from "@earendil-works/pi-ai";
import type { Static, TSchema } from "typebox";
import type { Static } from "@earendil-works/pi-schema";
/**
* Stream function used by the agent loop. `Models.streamSimple` satisfies
@ -462,27 +462,39 @@ export interface AgentToolCallOutcome {
*/
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
/** Tool definition used by the agent runtime. */
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
/**
* The arguments type of a tool with parameter schema `TParameters`: pi-schema's `Static<TParameters>` (the builders'
* type, TypeBox's for the common TypeBox kinds, else `unknown`), and `any` for `AgentTool<any>`.
*/
export type AgentToolParams<TParameters> = IsAny<TParameters> extends true ? any : Static<TParameters>;
// `0 extends 1 & T` is resolved too early for a `T` constrained to `object`; this form waits for the argument.
type IsAny<T> = boolean extends (T extends never ? true : false) ? true : false;
/**
* Tool definition used by the agent runtime. `parameters` is any JSON Schema object; see {@link AgentToolParams} for
* the type `execute` receives.
*/
export interface AgentTool<TParameters extends object = object, TDetails = any> extends Tool<TParameters> {
/** Human-readable label for UI display. */
label: string;
/**
* Optional compatibility shim for raw tool-call arguments before schema validation.
* Must return an object that matches `TParameters`.
*/
prepareArguments?: (args: unknown) => Static<TParameters>;
prepareArguments?: (args: unknown) => AgentToolParams<TParameters>;
/**
* JSON Schema of `structuredContent` in successful results. Tools that declare it should always
* set `structuredContent`.
*/
outputSchema?: TSchema;
outputSchema?: object;
/**
* Execute the tool call. Throw on failure, or return a result with `isError: true`; do not only
* describe the failure in `content`.
*/
execute: (
toolCallId: string,
params: Static<TParameters>,
params: AgentToolParams<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;

View file

@ -6,7 +6,7 @@ import {
type ToolResultMessage,
type UserMessage,
} from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { arr, obj, str } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { agentLoop, agentLoopContinue, runAgentLoop, runToolCall } from "../src/agent-loop.ts";
import { setDefaultStreamFn } from "../src/index.ts";
@ -303,7 +303,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should handle tool calls and results", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executed: string[] = [];
const toolUsage = {
input: 1,
@ -400,7 +400,7 @@ describe("agentLoop with AgentMessage", () => {
// #10549
it("records how long execute() took on the tool result, excluding hooks", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
@ -454,7 +454,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should not execute tool calls from a length-truncated assistant message", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
@ -526,7 +526,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should execute mutated beforeToolCall args without revalidation", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executed: Array<string | number> = [];
const tool: AgentTool<typeof toolSchema, { value: string | number }> = {
name: "echo",
@ -587,8 +587,8 @@ describe("agentLoop with AgentMessage", () => {
});
it("should prepare tool arguments for validation", async () => {
const replaceSchema = Type.Object({ oldText: Type.String(), newText: Type.String() });
const toolSchema = Type.Object({ edits: Type.Array(replaceSchema) });
const replaceSchema = obj({ oldText: str(), newText: str() });
const toolSchema = obj({ edits: arr(replaceSchema) });
const executed: Array<Array<{ oldText: string; newText: string }>> = [];
const tool: AgentTool<typeof toolSchema, { count: number }> = {
name: "edit",
@ -666,7 +666,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should emit tool_execution_end in completion order but persist tool results in source order", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
let firstResolved = false;
let parallelObserved = false;
let releaseFirst: (() => void) | undefined;
@ -760,7 +760,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should inject queued messages after all tool calls complete", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
@ -865,7 +865,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should force sequential execution when a tool has executionMode=sequential even with default parallel config", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
let firstResolved = false;
let parallelObserved = false;
let releaseFirst: (() => void) | undefined;
@ -947,7 +947,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should force sequential execution when one of multiple tools has executionMode=sequential", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executionOrder: string[] = [];
let releaseSlow: (() => void) | undefined;
const slowDone = new Promise<void>((resolve) => {
@ -1033,7 +1033,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should allow parallel execution when all tools have executionMode=parallel", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
let firstResolved = false;
let parallelObserved = false;
let releaseFirst: (() => void) | undefined;
@ -1106,7 +1106,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("runs finishTurn after tool-result messages and before turn_end", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
@ -1215,7 +1215,7 @@ describe("agentLoop with AgentMessage", () => {
);
it("action:end skips queue polling and next-turn preparation", async () => {
const toolSchema = Type.Object({});
const toolSchema = obj({});
const tool: AgentTool<typeof toolSchema, undefined> = {
name: "noop",
label: "Noop",
@ -1305,7 +1305,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("lets a natural tool-result request satisfy continuation", async () => {
const toolSchema = Type.Object({});
const toolSchema = obj({});
const tool: AgentTool<typeof toolSchema, undefined> = {
name: "noop",
label: "Noop",
@ -1501,7 +1501,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should use prepareNextTurn snapshot before continuing", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
@ -1578,7 +1578,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("picks up steering queued during prepareNextTurn before the next request", async () => {
const toolSchema = Type.Object({});
const toolSchema = obj({});
const tool: AgentTool<typeof toolSchema, undefined> = {
name: "noop",
label: "Noop",
@ -1631,7 +1631,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("action:end receives finalized turn context and stops before queue polling", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
@ -1730,7 +1730,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should stop after a tool batch when every tool result sets terminate=true", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
@ -1781,7 +1781,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should stop after a blocked tool call when beforeToolCall sets terminate=true", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
let executed = false;
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
@ -1839,7 +1839,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should continue after a mixed batch with one terminating blocked call", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
@ -1897,7 +1897,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should continue after parallel tool calls when not all tool results terminate", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
@ -1962,7 +1962,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should allow afterToolCall to mark a tool batch as terminating", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
@ -2131,13 +2131,13 @@ describe("agentLoopContinue with AgentMessage", () => {
});
describe("runToolCall", () => {
const echoSchema = Type.Object({ value: Type.String() });
const echoSchema = obj({ value: str() });
const echo: AgentTool<typeof echoSchema> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: echoSchema,
outputSchema: Type.Object({ value: Type.String() }),
outputSchema: obj({ value: str() }),
async execute(_toolCallId, params, _signal, onUpdate) {
onUpdate?.({ content: [{ type: "text", text: "partial" }], details: {} });
return {
@ -2151,7 +2151,7 @@ describe("runToolCall", () => {
name: "failing",
label: "Failing",
description: "Returns an error result",
parameters: Type.Object({}),
parameters: obj({}),
async execute() {
return { content: [{ type: "text", text: "bad" }], details: { partial: true }, isError: true };
},

View file

@ -0,0 +1,65 @@
// AgentTool params typed from a TypeBox-built parameter schema. pi-agent-core does not depend on TypeBox: the schema
// below has TypeBox 1.x's type shapes as its d.mts declare them; pi-schema's own tests check the shapes against TypeBox.
import { describe, expectTypeOf, it } from "vitest";
import type { AgentTool } from "../src/types.ts";
interface TString {
"~kind": "String";
type: "string";
}
interface TLiteral<Value> {
"~kind": "Literal";
const: Value;
}
interface TUnion<Branches> {
"~kind": "Union";
anyOf: Branches;
}
interface TObject<Properties> {
"~kind": "Object";
type: "object";
properties: Properties;
required: string[];
}
type TOptional<Schema> = Schema & { "~optional": true };
const parameters: TObject<{ command: TString; mode: TOptional<TUnion<[TLiteral<"fast">, TLiteral<"deep">]>> }> = {
"~kind": "Object",
type: "object",
properties: {
command: { "~kind": "String", type: "string" },
mode: {
"~kind": "Union",
anyOf: [
{ "~kind": "Literal", const: "fast" },
{ "~kind": "Literal", const: "deep" },
],
"~optional": true,
},
},
required: ["command"],
};
type Params = { command: string; mode?: "fast" | "deep" };
describe("AgentTool params of a TypeBox schema", () => {
it("are typed by the schema in execute() and prepareArguments()", () => {
const tool: AgentTool<typeof parameters> = {
name: "bash",
label: "Bash",
description: "Runs a command",
parameters,
async execute(_toolCallId, params) {
expectTypeOf(params).toEqualTypeOf<Params>();
return { content: [], details: undefined };
},
};
expectTypeOf<Parameters<typeof tool.execute>[1]>().toEqualTypeOf<Params>();
expectTypeOf<ReturnType<NonNullable<typeof tool.prepareArguments>>>().toEqualTypeOf<Params>();
});
});

View file

@ -7,7 +7,7 @@ import {
toToolDeclaration,
type UserMessage,
} from "@earendil-works/pi-ai/compat";
import { Type } from "typebox";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import {
Agent,
@ -49,7 +49,7 @@ function createTool(name: string): AgentTool {
name,
label: name,
description: `${name} tool`,
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ content: [{ type: "text", text: name }], details: {} }),
};
}
@ -146,7 +146,7 @@ describe("Agent", () => {
name: "echo",
label: "Echo",
description: "Echo input",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
};
const agent = new Agent({
@ -200,7 +200,7 @@ describe("Agent", () => {
expect(update).toEqual({
role: "system",
content: "",
toolsAdded: [{ name: "second", description: "second tool", parameters: Type.Object({}) }],
toolsAdded: [{ name: "second", description: "second tool", parameters: obj({}) }],
toolsRemoved: [{ name: "first" }],
timestamp: expect.any(Number),
});
@ -214,7 +214,7 @@ describe("Agent", () => {
name: "echo",
label: "Echo",
description: "Echo input",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
};
const agent = new Agent({
@ -239,7 +239,7 @@ describe("Agent", () => {
role: "system",
content: "",
sections: { skills: "<skills>x</skills>" },
toolsAdded: [{ name: "echo", description: "Echo input", parameters: Type.Object({}) }],
toolsAdded: [{ name: "echo", description: "Echo input", parameters: obj({}) }],
timestamp: 1,
});
});
@ -284,7 +284,7 @@ describe("Agent", () => {
name: "echo",
label: "Echo",
description: "Echo input",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
};
const agent = new Agent({
@ -471,7 +471,7 @@ describe("Agent", () => {
});
it("should ignore tool updates after the tool execution settles", async () => {
const toolSchema = Type.Object({});
const toolSchema = obj({});
let delayedUpdate: AgentToolUpdateCallback<{ status: string }> | undefined;
const events: AgentEvent[] = [];
const unhandledRejections: unknown[] = [];
@ -536,7 +536,7 @@ describe("Agent", () => {
});
it("should ignore a settled parallel tool update while another tool is still running", async () => {
const toolSchema = Type.Object({});
const toolSchema = obj({});
const slowStarted = createDeferred();
const settledToolEnded = createDeferred();
const releaseSlow = createDeferred();
@ -858,7 +858,7 @@ describe("Agent", () => {
});
it("keeps legacy prepareNextTurn signal callback behavior", async () => {
const schema = Type.Object({});
const schema = obj({});
const tool: AgentTool<typeof schema> = {
name: "noop",
label: "Noop",
@ -899,7 +899,7 @@ describe("Agent", () => {
});
it("forwards finishTurn through AgentOptions with the active abort signal", async () => {
const schema = Type.Object({});
const schema = obj({});
const tool: AgentTool<typeof schema> = {
name: "noop",
label: "Noop",

View file

@ -1,5 +1,5 @@
import type { Usage } from "@earendil-works/pi-ai";
import { type Static, Type } from "typebox";
import { obj, type Static, str } from "@earendil-works/pi-schema";
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
export interface CalculateResult extends AgentToolResult<undefined> {
@ -16,9 +16,11 @@ export function calculate(expression: string): CalculateResult {
}
}
const calculateSchema = Type.Object({
expression: Type.String({ description: "The mathematical expression to evaluate" }),
});
const calculateSchema = obj(
{ expression: str({ description: "The mathematical expression to evaluate" }) },
// Open, as the tool accepted extra arguments before it moved from TypeBox.
{ open: true },
);
type CalculateParams = Static<typeof calculateSchema>;

View file

@ -1,4 +1,4 @@
import { type Static, Type } from "typebox";
import { obj, opt, type Static, str } from "@earendil-works/pi-schema";
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
export interface GetCurrentTimeResult extends AgentToolResult<{ utcTimestamp: number }> {}
@ -27,11 +27,11 @@ export async function getCurrentTime(timezone?: string): Promise<GetCurrentTimeR
};
}
const getCurrentTimeSchema = Type.Object({
timezone: Type.Optional(
Type.String({ description: "Optional timezone (e.g., 'America/New_York', 'Europe/London')" }),
),
});
const getCurrentTimeSchema = obj(
{ timezone: opt(str({ description: "Optional timezone (e.g., 'America/New_York', 'Europe/London')" })) },
// Open, as the tool accepted extra arguments before it moved from TypeBox.
{ open: true },
);
type GetCurrentTimeParams = Static<typeof getCurrentTimeSchema>;

View file

@ -2,18 +2,54 @@
## [Unreleased]
### Fixed
- Fixed OpenCode Go Claude Haiku 5.5 failing with a 400 (`Invalid signature in thinking block ... bound to a different conversation`) after the tool list, system prompt, or earlier history changed, which left the session failing on every retry. OpenCode Go and Zen Claude 5.5 models, Opus 5, and Fable 5.1 now use per-message effort and send `thinking.block_binding: drop_block`, like the Anthropic provider ([#10954](https://github.com/earendil-works/pi/issues/10954))
- Fixed Anthropic non-strict tool schemas dropping root `$defs` and `definitions`, which left property `$ref`s pointing at nothing. Claude then produced wrong arguments for such tools (common in MCP servers), for example a JSON string instead of an object, and some Anthropic-compatible gateways rejected the request outright ([#9557](https://github.com/earendil-works/pi/issues/9557))
## [1.5.0] - 2026-11-02
### Breaking Changes
- The main entry no longer re-exports TypeBox (`Type`, `Static`, `TSchema`), and `typebox` is no longer a dependency. `Tool.parameters` is any JSON Schema object (`Tool<TParameters extends object>`): `@earendil-works/pi-schema`'s builders, which also type the arguments through pi-schema's `Static`, TypeBox schemas, or plain objects. `validateToolCall()`/`validateToolArguments()` validate with pi-schema (`compile(schema, { coerce: true }).parse()`): every schema gets the same coercion, the superset of TypeBox's `Value.Convert` rules (which only applied to TypeBox-built schemas) and pi-ai's own, so plain JSON Schema tools (MCP tools) now also get, for example, `null` to `"null"` for a required string, `"3.5"` to 3 for an integer and a single value wrapped into an array. The error text lists pi-schema's messages (`- edits.0.oldText: missing`, `- limit: expected number, got string`) instead of TypeBox's. `StringEnum()` returns a pi-schema `Schema<T>` with the same JSON (`{ type: "string", enum }`). The model and compat schemas (`providers/model-schema`, `providers/compat-schema`) are built with pi-schema, with the same JSON Schema, and their types come from pi-schema's `Static`; `ProviderCompat` is exported. Migration:
```ts
// Before
import { Type, type Static, StringEnum } from "@earendil-works/pi-ai";
const params = Type.Object({ path: Type.String(), mode: StringEnum(["a", "b"]) });
type Params = Static<typeof params>;
// After: pi-schema builders (closed objects; pass { open: true } to allow other properties)
import { StringEnum } from "@earendil-works/pi-ai";
import { obj, type Static, str } from "@earendil-works/pi-schema";
const params = obj({ path: str(), mode: StringEnum(["a", "b"]) });
type Params = Static<typeof params>;
// Or keep TypeBox, imported from typebox itself (its schemas are JSON Schema)
import { Type, type Static } from "typebox";
```
- Tool arguments are validated as JSON Schema only; nothing runs through TypeBox's own validator any more, so TypeBox features outside JSON Schema no longer apply to tool arguments:
- `Type.Refine()` refinements are not run: arguments they would reject are accepted.
- `Type.BigInt()`, `Type.Undefined()` and `Type.Void()` reject every JSON argument (TypeBox converted to them).
- Codecs (`Type.Codec()`) do not decode: the tool receives the encoded JSON value.
- Formats registered with TypeBox's `Format.Set()` are not checked; the standard formats plus `url` and `json-pointer-uri-fragment` are.
- Property names are names, not regular expressions: `"a.b"` no longer also converts the argument `axb`.
- `additionalProperties` converts only undeclared properties; TypeBox also converted declared ones with it when an object had more than one property.
- A `$ref` that names nothing (a missing definition, an external document) rejects only arguments at its place (`- t: unresolved reference #/definitions/Missing`) instead of failing every call of the tool, as TypeBox's rejection did before.
- `appendGrammarToolInputJsonDelta()` (`api/constrained-sampling`) takes the input delta instead of the whole input so far and no longer closes the input; close it with the new `closeGrammarToolInputJson()` ([#9265](https://github.com/earendil-works/pi/issues/9265))
### Added
- Added optional `openBrowserOnConfirm` and `copyCodeOnConfirm` to `device_code` auth events and `OAuthDeviceCodeInfo`. Both default to `true`; set them to `false` to stop clients from offering to open the verification page or copy the code ([#9282](https://github.com/earendil-works/pi/issues/9282), [#9301](https://github.com/earendil-works/pi/pull/9301))
- Added `StreamingJsonParser`, an incremental, tolerant parser for JSON text that arrives in pieces (each piece is read once; snapshots of the partial value never change once returned), and `ToolCallArgumentsStream`, which every provider now uses for streamed tool-call arguments; its `append()` also returns the changes each piece made (`JsonChange`: a value set at a path, or text appended to a string), for writers that keep their own copy ([#9265](https://github.com/earendil-works/pi/issues/9265))
- Added GPT-6.1 Terra to the OpenAI, Azure OpenAI Responses, and OpenAI Codex providers with official pricing ($1 input, $0.05 cached input, $1.25 cache writes, $5 output, long-context tier above 272k), tool search, and mid-conversation effort changes. Unlike GPT-6.1 Sol it supports `off` (reasoning effort `none`); Codex serves it with a 500K context window
- Added `Usage.cost.totalFromProvider` to distinguish provider-reported billed totals from catalog estimates. Missing or `false` means an estimate; combined usage is marked provider-reported only when every part is ([#11178](https://github.com/earendil-works/pi/pull/11178))
- Added `responseModel` to pi-messages terminal events, preserving the concrete model reported by providers such as Radius when a virtual model is requested ([#11265](https://github.com/earendil-works/pi/pull/11265))
### Changed
- The faux provider streams partial tool-call arguments, so `toolcall_delta` partials show growing arguments as real providers do ([#9265](https://github.com/earendil-works/pi/issues/9265))
- The built-in model catalog is generated from models.dev's v2 catalog, and its per-provider rules moved from `scripts/generate-models.ts` into one TypeScript definition per provider under `catalog/`. Models that models.dev types as realtime or image models (`gpt-realtime-2.1`, `gemini-3.1-flash-live-preview`, `gemini-3.1-flash-lite-image`) are no longer offered as chat models, and models.dev's newer OpenAI and Google chat models (`chat-latest`, `gpt-5.6-cyber`, `gemini-robotics-er-2-preview`) are now included
### Fixed
@ -22,6 +58,20 @@
- Fixed streamed tool-call arguments being re-parsed in full on every delta, which made CPU time and memory grow quadratically with the size of a tool call (a 90 KB `write` on Anthropic peaked at about 140 MB of heap; a 500 KB one took minutes). Every provider and the pi-messages client now read each delta once, and keep the raw argument text and their other stream state (`partialJson`, `partialArgs`, `index`, `customInput`, `streamIndex`, Bedrock's encrypted reasoning bytes) off the content blocks, so it no longer reaches partial messages or transcripts of failed streams ([#9265](https://github.com/earendil-works/pi/issues/9265))
- Fixed grammar (custom) tool input streaming copying the whole input so far on every delta ([#9265](https://github.com/earendil-works/pi/issues/9265))
- Fixed bounded error texts (telemetry span messages, truncated provider error bodies, pi-messages diagnostics, Radius gateway errors) keeping the whole text they were cut from alive ([#9265](https://github.com/earendil-works/pi/issues/9265))
- Fixed Kimi K3.1 Pro and K3.1 Pro Highspeed on Kimi Coding, Moonshot, and OpenCode Go using Kimi K2 request settings. They now send `reasoning_effort` (low/high/max), return earlier reasoning, accept tools added mid-conversation, and report Moonshot rates on Kimi Coding. Kimi Coding `k3-256k` now reports Kimi K3 rates.
- Fixed OAuth logins opening server-supplied URLs that are not `http:` or `https:` URLs. Radius now checks its browser authorization endpoint and device verification URI, and GitHub Copilot, Kimi Coding, and Meta share one check
- Fixed pi-messages streams accepting a `contentIndex` that is not the next or an existing content block, such as `__proto__` or a huge index
- Fixed `pi-ai` CLI auth file being readable by other users. It is now written with mode `0600`
- Fixed OpenCode DeepSeek V4.1 Flash sending `prompt_cache_retention`, which the OpenCode gateway rejects, with long cache retention
- Fixed GPT-5.4 and GPT-5.5 failing with `Missing namespace for function_call` after switching from another model that called tools. On the OpenAI and OpenCode providers they now declare their tools in the top-level tool list instead of loading them through `additional_tools` or tool search ([#11415](https://github.com/earendil-works/pi/issues/11415))
- Fixed Bedrock requests failing when `strict: "prefer"` tools exceed Claude's strict tool limits, such as more than 16 optional parameters across all strict tools; requests now retry once with preferred tools non-strict while keeping `strict: "require"` tools strict, as the Anthropic provider already does ([#11416](https://github.com/earendil-works/pi/issues/11416))
- Fixed OpenRouter's `~anthropic/claude-*-latest` aliases losing Claude's earlier thinking after each tool call. They used the Chat Completions API, where pi dropped the thinking signature, and OpenRouter then ignored the unsigned thinking. They now use the Anthropic Messages API with adaptive thinking, like the versioned Claude models ([#9534](https://github.com/earendil-works/pi/issues/9534))
- Fixed OpenRouter cost totals using catalog estimates instead of reported billing for Chat Completions and Anthropic Messages. BYOK totals include both OpenRouter charges and reported upstream costs; component costs remain catalog estimates ([#9980](https://github.com/earendil-works/pi/issues/9980))
- Fixed OpenCode Zen and OpenCode Go requests using shared endpoints instead of their protocol-specific OpenAI, Anthropic, Google, and System One endpoints ([#11434](https://github.com/earendil-works/pi/pull/11434) by [@vimtor](https://github.com/vimtor))
- Fixed Zhipu/Z.ai usage-limit errors (code `1308`) being treated as transient HTTP 429 errors, causing automatic retries that cannot succeed until the usage cap resets ([#11537](https://github.com/earendil-works/pi/issues/11537))
- Fixed LiteLLM's `ContextWindowExceededError` not being recognized as context overflow ([#11492](https://github.com/earendil-works/pi/issues/11492))
- Fixed Gemini tool schemas sending unsupported `const` keywords. They are now converted to single-value `enum` schemas without rewriting literal data in defaults, examples, or enum values, or losing properties named `__proto__` ([#11308](https://github.com/earendil-works/pi/issues/11308))
- Fixed the faux provider counting simulated cache-write tokens as both input and cache writes
## [1.4.0] - 2026-10-21

View file

@ -103,15 +103,16 @@ Unified LLM API with provider collections, automatic auth resolution, token and
npm install @earendil-works/pi-ai
```
TypeBox exports are re-exported from `@earendil-works/pi-ai`: `Type`, `Static`, and `TSchema`.
Tool parameters are JSON Schema. The examples build them with `@earendil-works/pi-schema`, which also types the arguments (`npm install @earendil-works/pi-schema`); TypeBox schemas and plain JSON Schema objects work as well.
## Quick Start
You build a `Models` collection of providers and stream through it. The quickest start registers every built-in provider; apps that care about bundle size register individual providers instead (see [Provider Factories](#provider-factories) and [Bundling and Tree Shaking](#bundling-and-tree-shaking)).
```typescript
import { Type, type Context, type Tool } from '@earendil-works/pi-ai';
import { type Context, type Tool } from '@earendil-works/pi-ai';
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
import { obj, opt, str } from '@earendil-works/pi-schema';
// A Models collection with every built-in provider registered
const models = builtinModels();
@ -119,12 +120,12 @@ const models = builtinModels();
// Sync lookup against the collection
const model = models.getModel('openai', 'gpt-4o-mini')!;
// Define tools with TypeBox schemas for type safety and validation
// Define tools with JSON Schema; pi-schema's builders also type the arguments
const tools: Tool[] = [{
name: 'get_time',
description: 'Get the current time',
parameters: Type.Object({
timezone: Type.Optional(Type.String({ description: 'Optional timezone (e.g., America/New_York)' }))
parameters: obj({
timezone: opt(str({ description: 'Optional timezone (e.g., America/New_York)' }))
})
}];
@ -538,34 +539,35 @@ Amazon Bedrock resolves ambient AWS credentials (`AWS_PROFILE`, access key pairs
## Tools
Tools enable LLMs to interact with external systems. This library uses TypeBox schemas for type-safe tool definitions with automatic validation using TypeBox's built-in validator and value conversion utilities. TypeBox schemas can be serialized and deserialized as plain JSON, making them ideal for distributed systems.
Tools enable LLMs to interact with external systems. A tool's `parameters` is a JSON Schema object: built with `@earendil-works/pi-schema`'s builders (which also type the arguments through its `Static`), with TypeBox, or written by hand. `validateToolCall()` and `validateToolArguments()` validate with pi-schema, coercing loosely typed arguments first (numeric strings, `"true"`, a single value for an array, `null` for an omitted optional property). Schemas are plain JSON, so they serialize with the rest of a context.
### Defining Tools
```typescript
import { Type, type Tool, StringEnum } from '@earendil-works/pi-ai';
import { type Tool, StringEnum } from '@earendil-works/pi-ai';
import { arr, obj, str } from '@earendil-works/pi-schema';
// Define tool parameters with TypeBox
// Define tool parameters with pi-schema's builders
const weatherTool: Tool = {
name: 'get_weather',
description: 'Get current weather for a location',
parameters: Type.Object({
location: Type.String({ description: 'City name or coordinates' }),
parameters: obj({
location: str({ description: 'City name or coordinates' }),
units: StringEnum(['celsius', 'fahrenheit'], { default: 'celsius' })
})
};
// Note: For Google API compatibility, use StringEnum helper instead of Type.Enum
// Type.Enum generates anyOf/const patterns that Google doesn't support
// Note: StringEnum gives { type: "string", enum: [...] }, which every provider accepts,
// including Google's, which rejects anyOf/const patterns
const bookMeetingTool: Tool = {
name: 'book_meeting',
description: 'Schedule a meeting',
parameters: Type.Object({
title: Type.String({ minLength: 1 }),
startTime: Type.String({ format: 'date-time' }),
endTime: Type.String({ format: 'date-time' }),
attendees: Type.Array(Type.String({ format: 'email' }), { minItems: 1 })
parameters: obj({
title: str({ minLength: 1 }),
startTime: str({ format: 'date-time' }),
endTime: str({ format: 'date-time' }),
attendees: arr(str({ format: 'email' }), { minItems: 1 })
})
};
```
@ -578,10 +580,7 @@ Tools can opt in to provider-side constrained sampling. For JSON-schema tools, `
const strictTool: Tool = {
name: 'edit_file',
description: 'Edit a file',
parameters: Type.Object({
path: Type.String(),
content: Type.String()
}, { additionalProperties: false }),
parameters: obj({ path: str(), content: str() }), // obj() is closed: additionalProperties: false
constrainedSampling: { type: 'json_schema', strict: 'prefer' }
};
```
@ -592,9 +591,7 @@ Strict JSON-schema constrained sampling is supported for OpenAI, Anthropic, supp
const patchTool: Tool = {
name: 'apply_patch',
description: 'Apply a patch',
parameters: Type.Object({
input: Type.String()
}, { additionalProperties: false }),
parameters: obj({ input: str() }),
constrainedSampling: {
type: 'grammar',
variants: {
@ -698,7 +695,7 @@ for await (const event of s) {
### Validating Tool Arguments
When implementing your own tool execution loop, use `validateToolCall` to validate arguments before passing them to your tools:
When implementing your own tool execution loop, use `validateToolCall` to validate arguments before passing them to your tools. It coerces loosely typed arguments with pi-schema (`compile(schema, { coerce: true }).parse()`), returns the coerced arguments, and throws an error listing pi-schema's errors (`path: message`) followed by the arguments it received:
```typescript
import { validateToolCall, type Tool } from '@earendil-works/pi-ai';
@ -1726,8 +1723,8 @@ models.setProvider(openaiProvider());
Rules:
- `@earendil-works/pi-ai/models` exports the model runtime (`createModels`, `createProvider`, model helpers, and their types) without TypeBox, built-in catalogs, or SDK implementations. Other types can still use `import type` from the root.
- `@earendil-works/pi-ai` is the core entrypoint and does not import built-in catalogs, real provider factories, or SDK implementations, but it eagerly imports TypeBox and schema validation. Unbundled Node scripts do not tree-shake its unused exports; prefer `./models`, `./providers/faux`, and specific `./utils/*` subpaths when those are all you need.
- `@earendil-works/pi-ai/models` exports the model runtime (`createModels`, `createProvider`, model helpers, and their types) without schema validation, built-in catalogs, or SDK implementations. Other types can still use `import type` from the root.
- `@earendil-works/pi-ai` is the core entrypoint and does not import built-in catalogs, real provider factories, or SDK implementations, but it eagerly imports schema validation (pi-schema). Unbundled Node scripts do not tree-shake its unused exports; prefer `./models`, `./providers/faux`, and specific `./utils/*` subpaths when those are all you need.
- `@earendil-works/pi-ai/providers/<provider>` imports that provider's catalog and lazy API wrapper only.
- `@earendil-works/pi-ai/providers/all` imports every built-in provider factory and all catalogs. Use it only when you want the full built-in set.
- With code splitting, provider SDKs stay in lazy chunks and load on first request.
@ -1928,12 +1925,12 @@ 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`)
#### 3. Model Catalog (`catalog/providers/<id>.ts`)
- Add logic to fetch and parse models from the provider's source (e.g., models.dev API)
- Add a provider definition and register it in `catalog/providers/index.ts`; see `catalog/README.md` for the build order and helpers
- Map chat/tool-capable provider data to `Model`, image-generation data to `ImageModel`, and models.dev `type: "decision"` entries to `ClassifierModel`; 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
- Keep model ids unique within each provider and model type; emit separate entries when an upstream model supports multiple operations
- Handle provider-specific quirks (pricing format, capability flags, model ID transformations)
- Handle provider-specific quirks (pricing format, capability flags, model ID transformations) in the provider definition
#### 4. Provider Factory (`src/providers/<id>.ts`)

View file

@ -1 +0,0 @@
export * from "./dist/bedrock-provider.js";

View file

@ -1 +0,0 @@
export * from "./dist/bedrock-provider.js";

View file

@ -0,0 +1,47 @@
# Model catalog
`node catalog/generate.ts` (`npm run generate-models`) fetches upstream catalogs and writes the built-in model catalog:
- `src/providers/data/<provider>.json`: model values, grouped by API (gitignored)
- `src/providers/<provider>.models.ts` and `src/models.generated.ts`: typed wrappers (committed)
- with `--json-output <dir>`: the public catalog JSON served by pi.dev
## Layout
| Path | Contents |
| --- | --- |
| `sources/` | Upstream fetchers. `models-dev.ts` normalizes the [models.dev v2](https://models.dev/experimental/v2.0/api.json) catalog; OpenRouter, Vercel AI Gateway, NVIDIA NIM, and Radius have their own modules. All sources are fetched before anything is built. |
| `providers/<id>.ts` | One file per built-in provider: where its models come from, its API and base URL, compat defaults, corrections, and models upstream does not list. Register new files in `providers/index.ts`. |
| `families/` | Facts about a model family that hold on every host, such as Claude effort levels or GPT-6 thinking levels. They apply to every provider's models. |
| `define.ts` | `defineProvider()` and the helpers provider files use. |
| `build.ts` | Runs the provider definitions in a fixed order. |
## Build order
For each provider:
1. `models(ctx)` returns its chat models: upstream entries mapped to pi models, data corrections (limits, prices), and authored models. `ctx.providerModels(id)` returns another provider's models from this step, e.g. Azure derives from OpenAI.
2. `compat` defaults for each API are merged under each model's own compat. Then the shared rules run: models.dev effort levels for APIs that send them natively, and the rules in `families/`.
3. `finalize(model, ctx)` applies provider-specific behavior. It runs after the shared rules, so its values win.
4. Image input limits get the default resize profile, then `finalizeAll(models, ctx)` runs cross-model logic such as Anthropic's allowed fallback models.
## Adding a provider
Most providers are a models.dev provider served on one API:
```ts
import { defineProvider, modelsDevModels } from "../define.ts";
export default defineProvider({
id: "example",
models: (ctx) =>
modelsDevModels(ctx, "example", { api: "openai-completions", baseUrl: "https://api.example.com/v1" }),
compat: { "openai-completions": { supportsStore: false, supportsDeveloperRole: false } },
});
```
`modelsDevModels()` keeps tool-capable chat models and maps names, input modalities, costs (including context tiers), and limits. Use its `include` and `map` options for provider-specific filtering and fields. Prefer fixing wrong upstream data in models.dev; keep local corrections small and say why they exist.
## Checking changes
`npm run diff:model-catalog -- [provider ...]` from the repository root generates the catalog at `HEAD` and in the worktree and prints the JSON differences.

View file

@ -0,0 +1,164 @@
import type {
AnyModel,
Api,
ClassifierApi,
ClassifierModel,
ImageApi,
ImageModel,
KnownApi,
Model,
OpenAICompletionsCompat,
} from "../src/types.ts";
import type { ApiCompatDefaults, ChatModel, ProviderContext, ProviderDefinition } from "./define.ts";
import { mergeThinking } from "./define.ts";
import { applyClaudeFamilyRules } from "./families/anthropic.ts";
import { applyDeepSeekCompatRules, applyDeepSeekFamilyRules } from "./families/deepseek.ts";
import { applyOpenAiFamilyRules } from "./families/openai.ts";
import { getEffortThinkingLevelMap } from "./reasoning.ts";
import type { CatalogSources } from "./sources/index.ts";
import type { ModelsDevModel } from "./sources/models-dev.ts";
export interface ProviderCatalog {
chat: (Model<Api> & { type: "chat" })[];
image: ImageModel<ImageApi>[];
classifier: ClassifierModel<ClassifierApi>[];
}
// Keep the generated default no less restrictive than coding-agent's historical
// image preprocessing. Provider limits can narrow this profile, but unknown
// providers retain the cache-safe 2000px / 4.5 MiB behavior.
const DEFAULT_IMAGE_RESIZE = {
maxWidth: 2000,
maxHeight: 2000,
maxBytes: 4.5 * 1024 * 1024,
jpegQuality: 80,
} as const;
function modelKey(model: Pick<AnyModel, "provider" | "id">): string {
return `${model.provider}:${model.id}`;
}
function applyCompatDefaults(model: ChatModel, defaults: ApiCompatDefaults | undefined): void {
const entry = defaults?.[model.api as KnownApi] as
| Model<Api>["compat"]
| ((model: Model<Api>) => Model<Api>["compat"])
| undefined;
if (!entry) return;
const compat = typeof entry === "function" ? entry(model) : entry;
model.compat = { ...compat, ...model.compat } as Model<Api>["compat"];
}
/** Whether the model's API sends models.dev effort values to the host as-is. */
function sendsNativeEffort(model: ChatModel): boolean {
if (model.api === "anthropic-messages") {
return (model as Model<"anthropic-messages">).compat?.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: OpenAICompletionsCompat = (model as Model<"openai-completions">).compat ?? {};
return (compat.thinkingFormat ?? "openai") === "openai" && (compat.supportsReasoningEffort ?? true);
}
function applyModelsDevEffort(model: ChatModel, source: ModelsDevModel | undefined): void {
if (!source?.reasoningOptions || !sendsNativeEffort(model)) return;
const map = getEffortThinkingLevelMap(source.reasoningOptions);
if (map) mergeThinking(model, map);
}
function applyImageResizeDefaults(model: AnyModel): void {
if (!model.input.includes("image")) return;
const images = model.inputLimits?.images;
model.inputLimits = {
...model.inputLimits,
images: { ...images, resize: { ...DEFAULT_IMAGE_RESIZE, ...images?.resize } },
};
}
/** Build every provider's catalog from the fetched sources. */
export function buildCatalog(
definitions: readonly ProviderDefinition[],
sources: CatalogSources,
strict: boolean,
): Record<string, ProviderCatalog> {
const definitionsById = new Map(definitions.map((definition) => [definition.id, definition]));
const modelsDevSources = new Map<string, ModelsDevModel>();
const stageOne = new Map<string, ChatModel[]>();
const building = new Set<string>();
function context(providerId: string): ProviderContext {
return {
providerId,
sources,
strict,
providerModels: (id) => structuredClone(buildModels(id)),
modelsDevSource: (model) => modelsDevSources.get(modelKey(model)),
recordModelsDevSource: (model, source) => {
modelsDevSources.set(modelKey(model), source);
},
};
}
function buildModels(providerId: string): ChatModel[] {
const built = stageOne.get(providerId);
if (built) return built;
const definition = definitionsById.get(providerId);
if (!definition) throw new Error(`Unknown provider: ${providerId}`);
if (building.has(providerId)) throw new Error(`Provider ${providerId} depends on itself`);
building.add(providerId);
const models = definition.models?.(context(providerId)) ?? [];
building.delete(providerId);
for (const model of models) {
if (model.provider !== providerId) {
throw new Error(`${providerId} produced ${modelKey(model)} for another provider`);
}
}
stageOne.set(providerId, models);
return models;
}
for (const definition of definitions) buildModels(definition.id);
const catalogs: Record<string, ProviderCatalog> = {};
for (const definition of definitions) {
const ctx = context(definition.id);
const chat: ChatModel[] = [];
const seen = new Set<string>();
for (const model of stageOne.get(definition.id) ?? []) {
// The first source listing a model wins.
if (seen.has(model.id)) continue;
seen.add(model.id);
chat.push(model);
}
for (const model of chat) {
applyCompatDefaults(model, definition.compat);
applyDeepSeekCompatRules(model);
applyModelsDevEffort(model, ctx.modelsDevSource(model));
applyOpenAiFamilyRules(model);
applyClaudeFamilyRules(model);
applyDeepSeekFamilyRules(model);
}
for (const model of chat) definition.finalize?.(model, ctx);
for (const model of chat) {
if (model.compat && Object.keys(model.compat).length === 0) delete model.compat;
applyImageResizeDefaults(model);
}
definition.finalizeAll?.(chat, ctx);
const image = definition.images?.(ctx) ?? [];
for (const model of image) applyImageResizeDefaults(model);
const classifier = definition.classifiers?.(ctx) ?? [];
catalogs[definition.id] = {
chat: chat.map((model) => ({ ...model, type: "chat" as const })),
image,
classifier,
};
}
return catalogs;
}

View file

@ -0,0 +1,142 @@
import type {
Api,
ClassifierApi,
ClassifierModel,
ImageApi,
ImageModel,
KnownApi,
Model,
ModelInputLimits,
ThinkingLevelMap,
} from "../src/types.ts";
import type { CatalogSources } from "./sources/index.ts";
import { type ModelsDevModel, modelsDevChatModels, modelsDevCost } from "./sources/models-dev.ts";
export type ChatModel = Model<Api>;
export type ModelCompat<TApi extends Api> = NonNullable<Model<TApi>["compat"]>;
/**
* Compat defaults per API. They sit under each model's own compat, so model
* values win. A function receives the model to vary defaults by model.
*/
export type ApiCompatDefaults = {
[TApi in KnownApi]?: ModelCompat<TApi> | ((model: Model<TApi>) => ModelCompat<TApi>);
};
/** Passed to every provider hook. */
export interface ProviderContext {
/** The provider being built. */
readonly providerId: string;
readonly sources: CatalogSources;
/** Fail instead of degrading when upstream data is unexpected. */
readonly strict: boolean;
/** Copies of another provider's chat models before shared rules run. */
providerModels(providerId: string): ChatModel[];
/** The models.dev entry a model was built from, if it was recorded. */
modelsDevSource(model: ChatModel): ModelsDevModel | undefined;
/** Record the models.dev entry so shared rules can use its reasoning metadata. */
recordModelsDevSource(model: ChatModel, source: ModelsDevModel): void;
}
/**
* One built-in provider's catalog. The build runs, in order:
* 1. `models()`: upstream models plus authored ones, with data corrections.
* 2. `compat` defaults and the shared model-family rules in `families/`.
* 3. `finalize()`: provider-specific behavior that must win over shared rules.
* 4. Shared normalization (image input limits), then `finalizeAll()`.
*/
export interface ProviderDefinition {
id: string;
models?(ctx: ProviderContext): ChatModel[];
images?(ctx: ProviderContext): ImageModel<ImageApi>[];
classifiers?(ctx: ProviderContext): ClassifierModel<ClassifierApi>[];
compat?: ApiCompatDefaults;
finalize?(model: ChatModel, ctx: ProviderContext): void;
finalizeAll?(models: ChatModel[], ctx: ProviderContext): void;
}
export function defineProvider(definition: ProviderDefinition): ProviderDefinition {
return definition;
}
/** Shallow-merge compat flags into a model. */
export function mergeCompat<TApi extends Api>(model: Model<TApi>, compat: ModelCompat<TApi>): void {
model.compat = { ...model.compat, ...compat } as ModelCompat<TApi>;
}
/** Shallow-merge thinking level mappings into a model. Later values win. */
export function mergeThinking(model: ChatModel, map: ThinkingLevelMap): void {
model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map };
}
/** Provider input limits for image-capable models. Limits the model already carries take precedence. */
export function withInputLimits(model: ChatModel, limits: ModelInputLimits): void {
if (!model.input.includes("image")) return;
model.inputLimits = {
...limits,
...model.inputLimits,
images: { ...limits.images, ...model.inputLimits?.images },
};
}
/** Add models only when upstream has not listed them (yet). */
export function addMissing(models: ChatModel[], additions: readonly ChatModel[]): ChatModel[] {
for (const addition of additions) {
if (!models.some((model) => model.id === addition.id)) models.push(addition);
}
return models;
}
export interface ModelsDevOptions<TApi extends Api> {
api: TApi;
baseUrl: string;
compat?: ModelCompat<TApi>;
headers?: Record<string, string>;
/** Only models models.dev lists on this API protocol (e.g. "responses"). */
protocol?: string;
/** Skip models models.dev marks deprecated. */
skipDeprecated?: boolean;
/** Expose models.dev effort levels through the shared rules. Defaults to true. */
recordReasoning?: boolean;
/** Fallback limits when models.dev has none. Defaults to 4096 each. */
defaultContextWindow?: number;
defaultMaxTokens?: number;
/** Extra filter. Models without tool support are always skipped. */
include?(source: ModelsDevModel): boolean;
/** Adjust or replace the mapped model. Return undefined to skip it. */
map?(model: Model<TApi>, source: ModelsDevModel): ChatModel | undefined;
}
/** Map one models.dev provider's tool-capable chat models onto pi models. */
export function modelsDevModels<TApi extends Api>(
ctx: ProviderContext,
sourceProvider: string,
options: ModelsDevOptions<TApi>,
): ChatModel[] {
const models: ChatModel[] = [];
for (const source of modelsDevChatModels(ctx.sources.modelsDev, sourceProvider)) {
if (!source.toolCall) continue;
if (options.protocol && !source.apis.some((api) => api.protocol === options.protocol)) continue;
if (options.skipDeprecated && source.status === "deprecated") continue;
if (options.include && !options.include(source)) continue;
const base: Model<TApi> = {
id: source.id,
name: source.name || source.id,
api: options.api,
provider: ctx.providerId,
baseUrl: options.baseUrl,
reasoning: source.reasoning,
input: source.imageInput ? ["text", "image"] : ["text"],
cost: modelsDevCost(source.cost),
contextWindow: source.contextWindow || options.defaultContextWindow || 4096,
maxTokens: source.maxTokens || options.defaultMaxTokens || 4096,
...(options.headers ? { headers: { ...options.headers } } : {}),
...(options.compat ? { compat: { ...options.compat } } : {}),
};
const model = options.map ? options.map(base, source) : base;
if (!model) continue;
if (options.recordReasoning !== false) ctx.recordModelsDevSource(model, source);
models.push(model);
}
return models;
}

View file

@ -0,0 +1,134 @@
// Anthropic Claude models: facts that hold on every host that serves them.
// Ids appear dashed (claude-opus-4-8), dotted (claude-opus-4.8), prefixed
// (anthropic/claude-opus-4.8), and dated (claude-opus-5-20260723).
import type { Model, ThinkingLevelMap } from "../../src/types.ts";
import { type ChatModel, mergeCompat, mergeThinking } from "../define.ts";
function stripPrefix(modelId: string): string {
return modelId.toLowerCase().replace(/^~?anthropic\//, "");
}
/** Models that accept per-message `output_config` effort changes. */
export function supportsClaudeMidConvoEffort(modelId: string): boolean {
const id = stripPrefix(modelId);
return (
/^claude-opus-(?:5|5[.-]5)(?:-\d{8})?$/.test(id) ||
/^claude-(?:sonnet|haiku)-5[.-]5(?:-\d{8})?$/.test(id) ||
/^claude-(?:fable|mythos)-5(?:[.-]1)(?:-\d{8})?$/.test(id)
);
}
// Claude models that accept `thinking: {type: "disabled"}` alongside per-message effort.
// Opus 5.5, Sonnet 5.5, Fable 5.1, and Mythos 5.1 reject it.
// https://platform.claude.com/docs/en/build-with-claude/thinking#configuring-thinking
const DISABLED_THINKING_MODELS = new Set(["claude-opus-5", "claude-haiku-5-5", "claude-haiku-5.5"]);
/** Models that accept system messages and tool changes after the conversation starts. */
export function supportsClaudeMidConvoSystemMessages(modelId: string): boolean {
return (
/^claude-opus-(?:4[.-]8|5(?:[.-]5)?)(?:-\d{8})?$/.test(modelId) ||
/^claude-(?:sonnet|haiku)-5[.-]5(?:-\d{8})?$/.test(modelId) ||
/^claude-(?:fable|mythos)-5(?:[.-]1)?(?:-\d{8})?$/.test(modelId)
);
}
/** Enable per-message effort changes. Models that cannot disable thinking with it lose "off". */
export function enableClaudeMidConvoEffort(model: Model<"anthropic-messages">): void {
mergeCompat(model, { supportsMidConvoEffort: true });
if (!DISABLED_THINKING_MODELS.has(stripPrefix(model.id).replace(/-\d{8}$/, ""))) {
mergeThinking(model, { off: null });
}
}
function idIncludesAny(modelId: string, fragments: readonly string[]): boolean {
return fragments.some((fragment) => modelId.includes(fragment));
}
const ADAPTIVE_THINKING = [
"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",
"haiku-5",
"haiku.5",
"fable-5",
"mythos-5",
];
/**
* OpenRouter's `~anthropic/claude-*-latest` aliases follow the newest model of each family,
* all of which use adaptive thinking.
*/
function isClaudeLatestAlias(modelId: string): boolean {
return /^~anthropic\/claude-[a-z]+-latest$/.test(modelId);
}
const TEMPERATURE_UNSUPPORTED = [
"opus-4-7",
"opus-4.7",
"opus-4-8",
"opus-4.8",
"opus-5",
"opus.5",
"sonnet-5-5",
"sonnet-5.5",
"haiku-5-5",
"haiku-5.5",
];
const MAX_EFFORT_ONLY = ["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"];
const XHIGH_AND_MAX_EFFORT = [
"opus-4-7",
"opus-4.7",
"opus-4-8",
"opus-4.8",
"opus-5",
"opus.5",
"sonnet-5",
"sonnet.5",
"haiku-5",
"haiku.5",
];
/**
* Adaptive thinking effort levels (per Anthropic adaptive thinking docs):
* "max" is available on all adaptive-thinking Claude models; "xhigh" only on
* Opus 4.7/4.8/5, Sonnet 5, Haiku 5.5, and Fable 5.
*/
export function applyClaudeFamilyRules(model: ChatModel): void {
if (idIncludesAny(model.id, MAX_EFFORT_ONLY)) mergeThinking(model, { max: "max" });
if (idIncludesAny(model.id, XHIGH_AND_MAX_EFFORT)) mergeThinking(model, { xhigh: "xhigh", max: "max" });
if (model.id.includes("fable-5")) mergeThinking(model, { off: null, xhigh: "xhigh", max: "max" });
if (model.api !== "anthropic-messages") return;
const messagesModel = model as Model<"anthropic-messages">;
if (isClaudeLatestAlias(model.id) || idIncludesAny(model.id, ADAPTIVE_THINKING)) {
mergeCompat(messagesModel, { forceAdaptiveThinking: true });
}
if (idIncludesAny(model.id.toLowerCase(), TEMPERATURE_UNSUPPORTED)) {
mergeCompat(messagesModel, { supportsTemperature: false });
}
}
/**
* models.dev may list Opus 5.5, Sonnet 5.5, and Haiku 5.5 before their effort
* metadata is complete. Haiku 5.5 accepts disabled thinking; Opus 5.5 and
* Sonnet 5.5 reject it.
*/
export function claude55ThinkingLevels(modelId: string): ThinkingLevelMap {
return {
...(modelId.includes("haiku") ? {} : { off: null }),
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: "max",
};
}

View file

@ -0,0 +1,67 @@
// DeepSeek V4 models on OpenAI-compatible hosts.
import type { Model, OpenAICompletionsCompat } from "../../src/types.ts";
import { type ChatModel, mergeCompat, mergeThinking } from "../define.ts";
export const DEEPSEEK_COMPAT: OpenAICompletionsCompat = {
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "deepseek",
};
export const DEEPSEEK_V4_THINKING_LEVEL_MAP = {
minimal: null,
low: null,
medium: null,
high: "high",
max: "max",
} as const;
export const DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP = {
...DEEPSEEK_V4_THINKING_LEVEL_MAP,
low: "low",
} as const;
// Azure Foundry rejects DeepSeek's own max effort.
const AZURE_DEEPSEEK_V4_THINKING_LEVEL_MAP = {
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: null,
max: null,
} as const;
// Qwen Token Plan serves DeepSeek V4 through Qwen's own thinking format.
const QWEN_FORMAT_PROVIDERS = new Set(["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"]);
// These hosts accept native reasoning efforts instead of DeepSeek's thinking toggle.
const NATIVE_EFFORT_PROVIDERS = new Set(["openrouter", "opencode"]);
function isDeepSeekV4(model: ChatModel): model is Model<"openai-completions"> {
return model.api === "openai-completions" && model.id.includes("deepseek-v4");
}
/** DeepSeek V4 needs its reasoning content replayed on every host. */
export function applyDeepSeekCompatRules(model: ChatModel): void {
if (!isDeepSeekV4(model) || QWEN_FORMAT_PROVIDERS.has(model.provider)) return;
mergeCompat(
model,
NATIVE_EFFORT_PROVIDERS.has(model.provider)
? { requiresReasoningContentOnAssistantMessages: DEEPSEEK_COMPAT.requiresReasoningContentOnAssistantMessages }
: DEEPSEEK_COMPAT,
);
}
/** DeepSeek V4 effort levels when the host did not report its own. */
export function applyDeepSeekFamilyRules(model: ChatModel): void {
if (!isDeepSeekV4(model) || model.thinkingLevelMap !== undefined) return;
if (model.provider === "openrouter") {
mergeThinking(model, { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, xhigh: "xhigh", max: null });
} else if (model.provider === "azure") {
mergeThinking(model, AZURE_DEEPSEEK_V4_THINKING_LEVEL_MAP);
} else if (
(model.provider === "deepseek" || model.provider === "opencode" || model.provider === "opencode-go") &&
model.id.includes("deepseek-v4-flash")
) {
mergeThinking(model, DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP);
} else {
mergeThinking(model, DEEPSEEK_V4_THINKING_LEVEL_MAP);
}
}

View file

@ -0,0 +1,22 @@
// Google Gemini and Gemma models.
import type { ThinkingLevelMap } from "../../src/types.ts";
import { getEffortThinkingLevelMap } from "../reasoning.ts";
import type { ModelsDevReasoningOption } from "../sources/models-dev.ts";
function isGemma4Model(modelId: string): boolean {
return /gemma-?4/.test(modelId.toLowerCase());
}
/** Gemini effort levels from models.dev, with a Gemma 4 fallback. */
export function googleThinkingLevels(
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;
}

View file

@ -0,0 +1,38 @@
// Moonshot Kimi models, shared by Moonshot, Kimi Coding, and gateways.
import type { ModelCost, OpenAICompletionsCompat } from "../../src/types.ts";
/**
* Kimi K3 and its point releases (kimi-k3.1-pro, kimi-k3.1-pro-highspeed) share
* K3's request format. https://api.moonshot.ai/v1/models reports effort levels
* and dynamic tool loading for all of them.
*/
export function isKimiK3FamilyId(modelId: string): boolean {
return /^kimi-k3(?:\.\d+)?(?:-|$)/.test(modelId);
}
export const KIMI_K3_MAX_TOKENS = 131072;
export const KIMI_K3_COST: ModelCost = {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 0,
};
// Kimi Chat Completions settings shared by Moonshot and Kimi Coding.
export const KIMI_OPENAI_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
thinkingFormat: "deepseek",
};
// Kimi K3-style models take reasoning_effort and need earlier reasoning sent back.
export const KIMI_K3_OPENAI_COMPAT: OpenAICompletionsCompat = {
...KIMI_OPENAI_COMPAT,
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "openai",
supportsReasoningEffort: true,
};

View file

@ -0,0 +1,141 @@
// OpenAI GPT models: facts that hold on every host that serves them.
import type { Api, Model, ModelCost } from "../../src/types.ts";
import { type ChatModel, mergeCompat, mergeThinking } from "../define.ts";
import { roundCost } from "../sources/models-dev.ts";
export const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272000;
/** OpenAI bills prompts above 272K input tokens at 2x input and 1.5x output rates. */
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),
},
],
};
}
// Keep current OpenAI prices authoritative until models.dev and passthrough
// catalogs catch up.
// https://developers.openai.com/api/docs/pricing
export const OPENAI_STANDARD_COSTS: Record<string, ModelCost> = {
"gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
"gpt-5.6-sol": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 },
"gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 },
"gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
"gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 },
"gpt-6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
"gpt-6.1-sol": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 },
"gpt-6.1-terra": { input: 1, output: 5, cacheRead: 0.05, cacheWrite: 1.25 },
};
/** The standard price of an OpenAI model with long-context pricing. */
export function openAiStandardCost(id: string): ModelCost {
return withOpenAiLongContextPricing(OPENAI_STANDARD_COSTS[id]);
}
// GPT-5.5 and older put tools loaded through tool search or additional_tools in a namespace named
// after the tool and reject replayed calls without it, so history from other models fails there
// (#11415). Like Codex, dynamic tool loading therefore starts with GPT-5.6, which does not do this.
/** Models that accept tool search and `additional_tools`. */
export const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-6.1-sol",
"gpt-6.1-terra",
]);
/** Models that accept mid-conversation developer messages. */
export const OPENAI_MID_CONVO_MODEL_IDS = new Set([
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-pro",
"gpt-5.5",
...OPENAI_TOOL_SEARCH_MODEL_IDS,
]);
const RESPONSES_APIS = new Set<Api>(["openai-responses", "azure-openai-responses", "openai-codex-responses"]);
function isResponsesApi(model: ChatModel): boolean {
return RESPONSES_APIS.has(model.api);
}
export function supportsOpenAiXhigh(modelId: string): boolean {
return (
modelId.includes("gpt-5.2") ||
modelId.includes("gpt-5.3") ||
modelId.includes("gpt-5.4") ||
modelId.includes("gpt-5.5") ||
modelId.includes("gpt-5.6") ||
modelId.includes("gpt-6")
);
}
const GPT_6_MODEL_IDS = new Set(["gpt-6-astra", "gpt-6-sol", "gpt-6-luna", "gpt-6.1-sol", "gpt-6.1-terra"]);
/** Thinking levels of GPT models on any host. */
export function applyOpenAiFamilyRules(model: ChatModel): void {
if ((model.api === "openai-responses" || model.api === "azure-openai-responses") && model.id.startsWith("gpt-5")) {
mergeThinking(model, { off: null });
}
if (GPT_6_MODEL_IDS.has(model.id) && isResponsesApi(model)) {
mergeThinking(model, {
// GPT-6 Astra and GPT-6.1 Sol reject reasoning.effort "none"; GPT-6.1 Terra accepts it. All reject
// "minimal" (verified on Codex, October 2026).
off: model.id === "gpt-6-astra" || model.id === "gpt-6.1-sol" ? null : "none",
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: "max",
});
}
if (supportsOpenAiXhigh(model.id)) {
mergeThinking(model, { xhigh: "xhigh" });
}
if (
(model.id.includes("gpt-5.6") || model.id.includes("gpt-6")) &&
(isResponsesApi(model) || model.api === "openai-completions")
) {
mergeThinking(model, { max: "max" });
}
if (model.id.endsWith("gpt-5.5-pro")) {
mergeThinking(model, { off: null, minimal: null, low: null });
}
}
/**
* OpenAI custom grammar tools on Responses endpoints. OpenAI rejects
* `type: "custom"` tools for pre-GPT-5 models (gpt-4.x, gpt-4o, o-series).
*/
export function enableOpenAiGrammarTools(model: ChatModel): void {
if (!isResponsesApi(model)) return;
const match = /^gpt-(\d+)/.exec(model.id);
if (!match || Number(match[1]) < 5) return;
mergeCompat(model as Model<"openai-responses">, { supportsOpenAIGrammarTools: true });
}
/**
* Newer OpenAI Responses models accept developer messages after the
* conversation has started. Proxies that pass them through also pass
* `additional_tools` items through unchanged, for the models that accept them.
*/
export function enableOpenAiMidConvoSystemMessages(model: ChatModel, options: { proxy: boolean }): void {
if (model.api !== "openai-responses" && model.api !== "openai-codex-responses") return;
if (!OPENAI_MID_CONVO_MODEL_IDS.has(model.id)) return;
mergeCompat(model as Model<"openai-responses">, {
supportsMidConvoSystemMessages: true,
...(options.proxy && OPENAI_TOOL_SEARCH_MODEL_IDS.has(model.id) ? { supportsAdditionalTools: true } : {}),
});
}

View file

@ -0,0 +1,308 @@
#!/usr/bin/env node
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
createModelDataManifest,
groupProviderModelData,
MODEL_DATA_MANIFEST_FILE,
type ModelDataStructure,
readModelDataProviderIds,
validateGeneratedModelData,
validateModelDataDirectory,
} from "../scripts/model-data.ts";
import type {
AnyModel,
Api,
ClassifierApi,
ClassifierModel,
ImageApi,
ImageModel,
Model,
ModelType,
} from "../src/types.ts";
import { buildCatalog, type ProviderCatalog } from "./build.ts";
import { PROVIDERS } from "./providers/index.ts";
import { loadCatalogSources } from "./sources/index.ts";
/** A catalog entry with its model type spelled out. */
type CatalogEntry = AnyModel & { type: ModelType };
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
interface GeneratorOptions {
strict: boolean;
dataOnly: boolean;
jsonOnly: boolean;
jsonOutputDir: string | undefined;
pretty: boolean;
}
function readGeneratorOptions(args: string[]): GeneratorOptions {
let strict = false;
let dataOnly = false;
let jsonOnly = false;
let jsonOutputDir: string | undefined;
let pretty = false;
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg === "--strict") {
strict = true;
continue;
}
if (arg === "--data-only") {
dataOnly = true;
continue;
}
if (arg === "--json-only") {
jsonOnly = true;
continue;
}
if (arg === "--pretty") {
pretty = true;
continue;
}
if (arg === "--json-output") {
const value = args[++index];
if (!value) throw new Error("--json-output requires a directory");
jsonOutputDir = resolve(value);
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
if (jsonOnly && !jsonOutputDir) throw new Error("--json-only requires --json-output");
if (dataOnly && (jsonOnly || jsonOutputDir))
throw new Error("--data-only cannot be combined with JSON catalog output");
return { strict, dataOnly, jsonOnly, jsonOutputDir, pretty };
}
/** Key models by id, keeping the first entry per id, sorted by id. */
function sortedById<T extends { id: string }>(models: readonly T[]): [string, T][] {
const byId = new Map<string, T>();
for (const model of models) if (!byId.has(model.id)) byId.set(model.id, model);
return [...byId].sort(([left], [right]) => left.localeCompare(right));
}
async function generateModels(generatorOptions: GeneratorOptions): Promise<void> {
const sources = await loadCatalogSources(generatorOptions.strict);
const catalogs = buildCatalog(PROVIDERS, sources, generatorOptions.strict);
// Providers without any models are left out, as if they did not exist.
const providers: Record<string, ProviderCatalog> = Object.fromEntries(
Object.entries(catalogs).filter(
([, catalog]) => catalog.chat.length + catalog.image.length + catalog.classifier.length > 0,
),
);
const sortedProviderIds = Object.keys(providers).sort();
const jsonChatProviders: Record<string, Record<string, Model<Api> & { type: "chat" }>> = {};
const jsonImageProviders: Record<string, Record<string, ImageModel<ImageApi>>> = {};
const jsonClassifierProviders: Record<string, Record<string, ClassifierModel<ClassifierApi>>> = {};
const jsonAllProviders: Record<string, CatalogEntry[]> = {};
for (const providerId of sortedProviderIds) {
jsonChatProviders[providerId] = Object.fromEntries(sortedById(providers[providerId].chat));
jsonImageProviders[providerId] = Object.fromEntries(sortedById(providers[providerId].image));
jsonClassifierProviders[providerId] = Object.fromEntries(sortedById(providers[providerId].classifier));
jsonAllProviders[providerId] = [
...Object.values(jsonChatProviders[providerId]),
...Object.values(jsonImageProviders[providerId]),
...Object.values(jsonClassifierProviders[providerId]),
];
}
const serializeJson = (value: unknown) =>
`${JSON.stringify(value, null, generatorOptions.pretty ? 2 : undefined)}\n`;
const writeJson = (path: string, value: unknown) => writeFileSync(path, serializeJson(value));
const generatedDataProviderIds = generatorOptions.dataOnly
? readModelDataProviderIds(packageRoot)
: sortedProviderIds;
const missingProviderIds = generatedDataProviderIds.filter((providerId) => !jsonAllProviders[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.
const generatedDataProviders: Record<string, Record<string, Record<string, CatalogEntry>>> = {};
const modelDataStructure: ModelDataStructure = {};
for (const providerId of generatedDataProviderIds) {
const { groups, structure } = groupProviderModelData(providerId, jsonAllProviders[providerId]);
generatedDataProviders[providerId] = groups;
modelDataStructure[providerId] = structure;
}
const generatedAt = new Date().toISOString();
if (!generatorOptions.jsonOnly) {
// Stage and validate all provider values before replacing the current generated data.
const providersDir = join(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 (!generatorOptions.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(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 generatedHeader = `// This file is auto-generated by catalog/generate.ts
// Do not edit manually - run 'npm run generate-models' to update
`;
const catalogConstName = (providerId: string) =>
`${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_MODELS`;
const imageCatalogConstName = (providerId: string) =>
`${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_IMAGE_MODELS`;
const classifierCatalogConstName = (providerId: string) =>
`${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_CLASSIFIER_MODELS`;
const generatedShardFiles = new Set<string>();
for (const providerId of sortedProviderIds) {
let output = generatedHeader;
output += `import values from "./data/${providerId}.json" with { type: "json" };\n`;
output += `import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";\n\n`;
output += `export const ${catalogConstName(providerId)}: ChatModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
output += `\tflattenChatModelCatalog(${JSON.stringify(providerId)}, values);\n\n`;
output += `export const ${imageCatalogConstName(providerId)}: ImageModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
output += `\tflattenImageModelCatalog(${JSON.stringify(providerId)}, values);\n\n`;
output += `export const ${classifierCatalogConstName(providerId)}: ClassifierModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
output += `\tflattenClassifierModelCatalog(${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 = generatedHeader;
for (const providerId of sortedProviderIds) {
output += `import { ${classifierCatalogConstName(providerId)}, ${imageCatalogConstName(providerId)}, ${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\nexport const IMAGE_MODELS: {\n`;
for (const providerId of sortedProviderIds) {
output += `\treadonly ${JSON.stringify(providerId)}: typeof ${imageCatalogConstName(providerId)};\n`;
}
output += `} = {\n`;
for (const providerId of sortedProviderIds) {
output += `\t${JSON.stringify(providerId)}: ${imageCatalogConstName(providerId)},\n`;
}
output += `};\n\nexport const CLASSIFIER_MODELS: {\n`;
for (const providerId of sortedProviderIds) {
output += `\treadonly ${JSON.stringify(providerId)}: typeof ${classifierCatalogConstName(providerId)};\n`;
}
output += `} = {\n`;
for (const providerId of sortedProviderIds) {
output += `\t${JSON.stringify(providerId)}: ${classifierCatalogConstName(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(packageRoot);
} catch (error) {
rmSync(dataDir, { recursive: true, force: true });
if (hadPreviousData && existsSync(previousDataDir)) renameSync(previousDataDir, dataDir);
throw error;
}
restoreGeneratedCatalog = undefined;
console.log(
generatorOptions.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 (generatorOptions.jsonOutputDir) {
// `models.json` and `providers/{id}.json` retain the legacy keyed chat catalog.
// The `.all` variants are arrays so the same upstream id can appear once per type.
const providerOutputDir = join(generatorOptions.jsonOutputDir, "providers");
rmSync(generatorOptions.jsonOutputDir, { recursive: true, force: true });
mkdirSync(providerOutputDir, { recursive: true });
writeJson(join(generatorOptions.jsonOutputDir, "models.json"), jsonChatProviders);
writeJson(join(generatorOptions.jsonOutputDir, "models.all.json"), jsonAllProviders);
writeJson(join(generatorOptions.jsonOutputDir, "providers.json"), sortedProviderIds);
for (const providerId of sortedProviderIds) {
writeJson(join(providerOutputDir, `${providerId}.json`), jsonChatProviders[providerId]);
writeJson(join(providerOutputDir, `${providerId}.all.json`), jsonAllProviders[providerId]);
}
console.log(`Generated JSON model catalog under ${generatorOptions.jsonOutputDir}`);
}
// Print statistics
const chatModels = Object.values(providers).flatMap((catalog) => catalog.chat);
const totalModels = chatModels.length;
const reasoningModels = chatModels.filter((model) => model.reasoning).length;
console.log(`\nModel Statistics:`);
console.log(` Total tool-capable models: ${totalModels}`);
console.log(` Reasoning-capable models: ${reasoningModels}`);
for (const [provider, models] of Object.entries(providers)) {
console.log(
` ${provider}: ${models.chat.length} chat models, ${models.image.length} image models, ${models.classifier.length} classifier models`,
);
}
}
generateModels(readGeneratorOptions(process.argv.slice(2))).catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -0,0 +1,32 @@
import { defineProvider, modelsDevModels, withInputLimits } from "../define.ts";
// Opus 5 is only callable through cross-region inference profiles.
const INFERENCE_PROFILE_ONLY_MODEL_IDS = new Set(["anthropic.claude-opus-5"]);
function bedrockBaseUrl(modelId: string): string {
return modelId.startsWith("eu.")
? "https://bedrock-runtime.eu-central-1.amazonaws.com"
: "https://bedrock-runtime.us-east-1.amazonaws.com";
}
export default defineProvider({
id: "amazon-bedrock",
models: (ctx) =>
modelsDevModels(ctx, "amazon-bedrock", {
api: "bedrock-converse-stream",
baseUrl: "",
include: (source) =>
!INFERENCE_PROFILE_ONLY_MODEL_IDS.has(source.id) &&
// Jamba does not support tool use in streaming mode.
!source.id.startsWith("ai21.jamba") &&
// Mistral 7B Instruct does not support system messages.
!source.id.startsWith("mistral.mistral-7b-instruct-v0"),
// Includes models.dev pricing tiers, e.g. the long-context tier for OpenAI models (#10326).
map: (model, source) => ({
...model,
baseUrl: bedrockBaseUrl(model.id),
...(source.structuredOutput ? { compat: { supportsStrictMode: true } } : {}),
}),
}),
finalize: (model) => withInputLimits(model, { images: { maxPerMessage: 20 } }),
});

View file

@ -0,0 +1,52 @@
import type { Model, OpenAICompletionsCompat } from "../../src/types.ts";
import { defineProvider, mergeThinking } from "../define.ts";
const COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsLongCacheRetention: false,
};
function model(id: string, name: string, reasoning: boolean, cost: Model<"openai-completions">["cost"]) {
return {
id,
name,
api: "openai-completions",
baseUrl: "https://api.ant-ling.com/v1",
provider: "ant-ling",
reasoning,
input: ["text"],
cost,
contextWindow: 262144,
maxTokens: 65536,
compat: reasoning ? { ...COMPAT, thinkingFormat: "ant-ling" } : COMPAT,
} satisfies Model<"openai-completions">;
}
export default defineProvider({
id: "ant-ling",
models: () => [
model("Ling-2.6-flash", "Ling 2.6 Flash", false, { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0 }),
model("Ling-2.6-1T", "Ling 2.6 1T", false, { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 }),
model("Ring-2.6-1T", "Ring 2.6 1T", true, { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 }),
],
compat: {
"openai-completions": {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "ant-ling",
supportsStrictMode: true,
supportsLongCacheRetention: false,
},
},
finalize: (model) => {
// Ring reasons by default. Only high/xhigh have documented explicit effort controls.
if (model.reasoning) {
mergeThinking(model, { off: null, minimal: null, low: null, medium: null, high: "high", xhigh: "xhigh" });
}
},
});

View file

@ -0,0 +1,66 @@
import type { Model } from "../../src/types.ts";
import { defineProvider, mergeCompat, mergeThinking, modelsDevModels, withInputLimits } from "../define.ts";
import {
claude55ThinkingLevels,
enableClaudeMidConvoEffort,
supportsClaudeMidConvoEffort,
supportsClaudeMidConvoSystemMessages,
} from "../families/anthropic.ts";
// Models the API may fall back to, with the price the caller is billed.
const ALLOWED_FALLBACK_MODELS: Record<string, string[]> = {
"claude-fable-5": ["claude-opus-4-8", "claude-opus-5"],
"claude-opus-5": ["claude-opus-4-8"],
};
const MODELS_WITH_1M_CONTEXT = new Set(["claude-opus-4-6", "claude-sonnet-4-6"]);
const MODELS_55 = new Set(["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5"]);
export default defineProvider({
id: "anthropic",
models: (ctx) =>
modelsDevModels(ctx, "anthropic", {
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
map: (model) => {
if (MODELS_55.has(model.id)) mergeThinking(model, claude55ThinkingLevels(model.id));
if (MODELS_WITH_1M_CONTEXT.has(model.id)) model.contextWindow = 1000000;
return model;
},
}),
finalize: (model) => {
if (model.api !== "anthropic-messages") return;
const messagesModel = model as Model<"anthropic-messages">;
if (supportsClaudeMidConvoEffort(model.id)) enableClaudeMidConvoEffort(messagesModel);
if (supportsClaudeMidConvoSystemMessages(model.id)) {
mergeCompat(messagesModel, { supportsMidConvoSystemMessages: true, supportsMidConvoToolChanges: true });
}
mergeCompat(messagesModel, { supportsStrictTools: true });
// 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
model.promptCache = { short: 300, long: 3600 };
withInputLimits(model, {
maxRequestBytes: 32 * 1024 * 1024,
images: { maxPerRequest: model.contextWindow === 200000 ? 100 : 600 },
});
},
finalizeAll: (models) => {
const modelsById = new Map(models.map((model) => [model.id, model as Model<"anthropic-messages">]));
for (const [modelId, fallbackModelIds] of Object.entries(ALLOWED_FALLBACK_MODELS)) {
const model = modelsById.get(modelId);
if (!model) continue;
const compatibleFallbackModelIds = model.compat?.supportsMidConvoEffort
? fallbackModelIds.filter(supportsClaudeMidConvoEffort)
: fallbackModelIds;
const allowedFallbackModels = compatibleFallbackModelIds.flatMap((fallbackModelId) => {
const fallbackModel = modelsById.get(fallbackModelId);
return fallbackModel
? [{ provider: fallbackModel.provider, model: fallbackModel.id, cost: fallbackModel.cost }]
: [];
});
if (allowedFallbackModels.length > 0) mergeCompat(model, { allowedFallbackModels });
}
},
});

View file

@ -0,0 +1,61 @@
import type { Model, ModelCost } from "../../src/types.ts";
import { type ChatModel, defineProvider, mergeCompat } from "../define.ts";
import { enableOpenAiGrammarTools } from "../families/openai.ts";
// Azure Foundry deploys these with larger context windows than OpenAI's own short-tier defaults.
// See models-sold-directly-by-azure docs.
const 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,
};
// Azure resells DeepSeek at its own rates. US data zone, checked 2026-09-16.
// https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/
const DEEPSEEK_V4_PRO_COST: ModelCost = { input: 1.925, output: 3.828, cacheRead: 0.165, cacheWrite: 0 };
/** OpenAI models as Azure deploys them. Azure bills without OpenAI's long-context tier. */
function openAiModels(models: ChatModel[]): ChatModel[] {
return models
.filter((model) => model.api === "openai-responses")
.map((model) => ({
...model,
api: "azure-openai-responses",
provider: "azure",
baseUrl: "",
cost: {
input: model.cost.input,
output: model.cost.output,
cacheRead: model.cost.cacheRead,
cacheWrite: model.cost.cacheWrite,
},
contextWindow: CONTEXT_WINDOW_OVERRIDES[model.id] ?? model.contextWindow,
}));
}
export default defineProvider({
id: "azure",
models: (ctx) => [
...openAiModels(ctx.providerModels("openai")),
...ctx
.providerModels("deepseek")
.filter((model) => model.id === "deepseek-v4-pro")
.map((model) => ({ ...model, provider: "azure", baseUrl: "", cost: DEEPSEEK_V4_PRO_COST })),
],
compat: { "openai-completions": { supportsStrictMode: true } },
finalize: (model) => {
enableOpenAiGrammarTools(model);
if (model.api === "openai-completions") {
// Azure 400s on DeepSeek's `thinking` field and on every prompt cache parameter, discards a
// `developer` system message unbilled once reasoning_effort is set, and honours mid-convo ones (#9645).
mergeCompat(model as Model<"openai-completions">, {
supportsDeveloperRole: false,
supportsMidConvoSystemMessages: true,
thinkingFormat: "openai",
supportsLongCacheRetention: false,
});
}
},
});

View file

@ -0,0 +1,89 @@
import type { OpenAICompletionsCompat, ThinkingLevelMap } from "../../src/types.ts";
import { defineProvider, modelsDevModels } from "../define.ts";
import { getEffortThinkingLevelMap } from "../reasoning.ts";
const 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 REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = {
...BASE_COMPAT,
supportsReasoningEffort: true,
thinkingFormat: "openai",
};
const TOGGLE_REASONING_COMPAT: OpenAICompletionsCompat = {
...BASE_COMPAT,
thinkingFormat: "baseten",
chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } },
};
const TOGGLE_REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = {
...REASONING_EFFORT_COMPAT,
thinkingFormat: "baseten",
chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } },
};
const TOGGLE_LEVELS: ThinkingLevelMap = {
off: "off",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: null,
};
const GLM_52_LEVELS: ThinkingLevelMap = {
off: "none",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: "max",
};
const GLM_52_MODEL_IDS = new Set(["zai-org/GLM-5.2", "zai-org/GLM-5.2-Fast"]);
export default defineProvider({
id: "baseten",
models: (ctx) =>
modelsDevModels(ctx, "baseten", {
api: "openai-completions",
baseUrl: "https://inference.baseten.co/v1",
skipDeprecated: true,
recordReasoning: false,
map: (model, source) => {
const options = source.reasoningOptions ?? [];
const isGlm52 = GLM_52_MODEL_IDS.has(model.id);
const supportsToggle = isGlm52 || options.some((option) => option.type === "toggle");
const supportsEffort = isGlm52 || options.some((option) => option.type === "effort");
const compat =
supportsToggle && supportsEffort
? TOGGLE_REASONING_EFFORT_COMPAT
: supportsToggle
? TOGGLE_REASONING_COMPAT
: supportsEffort
? REASONING_EFFORT_COMPAT
: BASE_COMPAT;
const thinkingLevelMap = isGlm52
? GLM_52_LEVELS
: supportsToggle
? TOGGLE_LEVELS
: getEffortThinkingLevelMap(options);
return {
...model,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
// Baseten's GLM-5.2 endpoints are text-only despite models.dev reporting image input.
input: !isGlm52 && source.imageInput ? ["text", "image"] : ["text"],
compat,
};
},
}),
compat: { "openai-completions": { supportsStrictMode: true } },
});

View file

@ -0,0 +1,8 @@
import { defineProvider, modelsDevModels } from "../define.ts";
export default defineProvider({
id: "cerebras",
models: (ctx) =>
modelsDevModels(ctx, "cerebras", { api: "openai-completions", baseUrl: "https://api.cerebras.ai/v1" }),
compat: { "openai-completions": { supportsStore: false, supportsDeveloperRole: false } },
});

View file

@ -0,0 +1,84 @@
import {
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL,
} from "../../src/api/cloudflare.ts";
import type { Model } from "../../src/types.ts";
import { type ChatModel, defineProvider, mergeCompat, modelsDevModels } from "../define.ts";
import { enableOpenAiGrammarTools, OPENAI_STANDARD_COSTS, withOpenAiLongContextPricing } from "../families/openai.ts";
/** models.dev lists gateway models as `<upstream>/<id>`; route each upstream to its passthrough. */
function routeGatewayModel(model: ChatModel): ChatModel | undefined {
const slash = model.id.indexOf("/");
if (slash === -1) return undefined;
const upstream = model.id.slice(0, slash);
const nativeId = model.id.slice(slash + 1);
if (upstream === "openai") {
// Cloudflare AI Gateway passes OpenAI usage through at OpenAI list prices.
const standardCost = OPENAI_STANDARD_COSTS[nativeId];
return {
...model,
id: nativeId,
api: "openai-responses",
baseUrl: CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL,
...(standardCost ? { cost: withOpenAiLongContextPricing(standardCost) } : {}),
};
}
if (upstream === "anthropic") {
// The /anthropic passthrough forwards the model ID to Anthropic unchanged.
// models.dev lists dotted versions (claude-opus-5.5), but Anthropic only
// accepts dashed IDs (claude-opus-5-5).
return {
...model,
id: nativeId.replaceAll(".", "-"),
api: "anthropic-messages",
baseUrl: CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
// Gateway passthroughs forward session affinity headers to upstreams that
// use them for cache/routing affinity.
compat: { sendSessionAffinityHeaders: true },
};
}
if (upstream === "workers-ai") {
return { ...model, compat: { sendSessionAffinityHeaders: true } };
}
return undefined;
}
export default defineProvider({
id: "cloudflare-ai-gateway",
models: (ctx) => {
const models = modelsDevModels(ctx, "cloudflare-ai-gateway", {
api: "openai-completions",
baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
map: routeGatewayModel,
});
// 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.
const ids = new Set(models.map((model) => model.id));
const mirrored = modelsDevModels(ctx, "cloudflare-workers-ai", {
api: "openai-completions",
baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
compat: { sendSessionAffinityHeaders: true },
include: (source) => !ids.has(`workers-ai/${source.id}`),
map: (model) => ({ ...model, id: `workers-ai/${model.id}` }),
});
return [...models, ...mirrored];
},
compat: {
"openai-completions": {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsLongCacheRetention: false,
},
},
finalize: (model) => {
if (model.api === "openai-responses") {
mergeCompat(model as Model<"openai-responses">, { supportsStrictMode: true });
}
enableOpenAiGrammarTools(model);
},
});

View file

@ -0,0 +1,61 @@
import { CLOUDFLARE_WORKERS_AI_BASE_URL, CLOUDFLARE_WORKERS_AI_REST_BASE_URL } from "../../src/api/cloudflare.ts";
import type { ClassifierModel } from "../../src/types.ts";
import { defineProvider, modelsDevModels } from "../define.ts";
function classifier(
id: string,
name: string,
input: number,
contextWindow: number,
imageInput: "images" | "assets",
): ClassifierModel<"cloudflare-workers-ai-system-one"> {
return {
type: "classifier",
id,
name,
api: "cloudflare-workers-ai-system-one",
provider: "cloudflare-workers-ai",
baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL,
input: ["text", "image"],
cost: { input, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow,
compat: { imageInput },
};
}
// Workers AI has no unauthenticated catalog, so its System One models are listed here from
// Cloudflare's model pages.
const CLASSIFIER_MODELS = [
// Cloudflare-hosted Clef decision models. They take up to four PNG, JPEG, or WebP images as data URLs.
// Clef Omni also takes audio and video, which classifier contexts do not carry.
// Pricing and context: https://developers.cloudflare.com/workers-ai/models/clef/,
// https://developers.cloudflare.com/workers-ai/models/clef-flash/,
// and https://developers.cloudflare.com/workers-ai/models/clef-omni/
classifier("@cf/cloudflare/clef", "Clef", 0.24, 65536, "images"),
classifier("@cf/cloudflare/clef-flash", "Clef Flash", 0.038, 24576, "images"),
classifier("@cf/cloudflare/clef-omni", "Clef Omni", 0.15, 64000, "images"),
// TypeSafe's Jev models take PNG and JPEG images as System One assets.
// https://developers.cloudflare.com/ai/models/typesafe/jev/ (jev-latest)
// and https://developers.cloudflare.com/ai/models/typesafe/jev-1.14.0/
classifier("typesafe/jev", "Jev", 0.042, 32000, "assets"),
classifier("typesafe/jev-1.14.0", "Jev 1.14", 0.042, 32000, "assets"),
];
export default defineProvider({
id: "cloudflare-workers-ai",
models: (ctx) =>
modelsDevModels(ctx, "cloudflare-workers-ai", {
api: "openai-completions",
baseUrl: CLOUDFLARE_WORKERS_AI_BASE_URL,
compat: { sendSessionAffinityHeaders: true },
}),
classifiers: () => CLASSIFIER_MODELS,
compat: {
"openai-completions": {
supportsStore: false,
supportsDeveloperRole: false,
supportsStrictMode: true,
supportsLongCacheRetention: false,
},
},
});

View file

@ -0,0 +1,57 @@
import type { Model } from "../../src/types.ts";
import { defineProvider, mergeCompat } from "../define.ts";
import { DEEPSEEK_COMPAT, DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP } from "../families/deepseek.ts";
// models.dev's DeepSeek entries lag the API; pricing from
// https://api-docs.deepseek.com/quick_start/pricing. DeepSeek also offers
// time-based off-peak rates, which the cost schema cannot represent yet.
const MODELS: Model<"openai-completions">[] = [
{
id: "deepseek-flash",
name: "DeepSeek V4.1 Flash",
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
provider: "deepseek",
reasoning: true,
thinkingLevelMap: DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP,
input: ["text", "image"],
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: "https://api.deepseek.com",
provider: "deepseek",
reasoning: true,
input: ["text"],
cost: { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 384000,
compat: DEEPSEEK_COMPAT,
},
];
export default defineProvider({
id: "deepseek",
models: () => structuredClone(MODELS),
compat: {
"openai-completions": {
supportsStore: false,
supportsDeveloperRole: false,
maxTokensField: "max_tokens",
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "deepseek",
supportsStrictMode: true,
},
},
finalize: (model) => {
// DeepSeek V4 Pro accepts plain system text in place mid-conversation.
if (model.id === "deepseek-v4-pro") {
mergeCompat(model as Model<"openai-completions">, { supportsMidConvoSystemMessages: true });
}
},
});

View file

@ -0,0 +1,100 @@
import type { AnthropicMessagesCompat, Model, OpenAICompletionsCompat } from "../../src/types.ts";
import { defineProvider, mergeCompat, mergeThinking, modelsDevModels } from "../define.ts";
import { getEffortThinkingLevelMap } from "../reasoning.ts";
// Fireworks Anthropic-compatible API. Prompt caching uses automatic prefix
// matching and 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.
// https://docs.fireworks.ai/tools-sdks/anthropic-compatibility
const ANTHROPIC_COMPAT: AnthropicMessagesCompat = {
allowEmptySignature: true,
sendSessionAffinityHeaders: true,
supportsEagerToolInputStreaming: false,
supportsCacheControlOnTools: false,
supportsLongCacheRetention: false,
};
const OPENAI_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
sendSessionAffinityHeaders: true,
supportsLongCacheRetention: false,
};
const KIMI_K3_COMPAT: OpenAICompletionsCompat = {
...OPENAI_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 ADAPTIVE_THINKING_FALLBACK_MODELS = 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 default defineProvider({
id: "fireworks",
models: (ctx) =>
modelsDevModels(ctx, "fireworks-ai", {
api: "anthropic-messages",
// SDK appends /v1/messages.
baseUrl: "https://api.fireworks.ai/inference",
map: (model, source) => {
if (model.id.includes("glm-") || model.id.includes("kimi-k3")) {
return {
...model,
api: "openai-completions",
baseUrl: "https://api.fireworks.ai/inference/v1",
compat: model.id.includes("kimi-k3") ? KIMI_K3_COMPAT : OPENAI_COMPAT,
};
}
// Use adaptive thinking for cataloged effort controls, with verified
// fallbacks where models.dev is incomplete. New models need no allowlist entry.
const adaptive =
source.reasoningOptions?.some((option) => option.type === "effort") ||
ADAPTIVE_THINKING_FALLBACK_MODELS.has(model.id);
return { ...model, compat: { ...ANTHROPIC_COMPAT, ...(adaptive ? { forceAdaptiveThinking: true } : {}) } };
},
}),
compat: { "openai-completions": { supportsStrictMode: true } },
finalize: (model, ctx) => {
if (model.api === "anthropic-messages" && (model as Model<"anthropic-messages">).compat?.forceAdaptiveThinking) {
// Qwen Max currently advertises only a toggle. Prefer upstream effort
// metadata once available instead of replacing it with this fallback.
if (model.id === "accounts/fireworks/models/qwen3p8-max" && !model.thinkingLevelMap) {
model.thinkingLevelMap = getEffortThinkingLevelMap([
{ type: "effort", values: ["low", "medium", "xhigh"] },
]);
}
if (
ctx.modelsDevSource(model)?.reasoningOptions?.some((option) => option.type === "toggle") ||
// The 2.4T alias omits the verified toggle in models.dev.
model.id === "accounts/fireworks/models/qwen3p8-2p4t-a95b"
) {
mergeThinking(model, { off: "none" });
}
if (model.id === "accounts/fireworks/models/deepseek-v4-pro-0813") mergeThinking(model, { low: "low" });
}
if (model.id.includes("glm-5p2")) {
// 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.
mergeThinking(model, { off: "none", minimal: null, low: null, medium: null, max: "max" });
}
if (model.id.includes("kimi-k3")) {
// Fireworks maps medium to high on both APIs; do not expose it as a distinct level.
mergeThinking(model, { medium: null });
// Kimi K3 accepts tool-bearing system messages; Fireworks passes them through.
if (model.api === "openai-completions") {
mergeCompat(model as Model<"openai-completions">, {
supportsMidConvoSystemMessages: true,
supportsMidConvoToolAdditions: true,
});
}
}
},
});

View file

@ -0,0 +1,122 @@
import type { Api, Model, ThinkingLevelMap } from "../../src/types.ts";
import { addMissing, type ChatModel, defineProvider, mergeCompat, mergeThinking, modelsDevModels } from "../define.ts";
import { claude55ThinkingLevels, supportsClaudeMidConvoSystemMessages } from "../families/anthropic.ts";
import {
enableOpenAiGrammarTools,
enableOpenAiMidConvoSystemMessages,
openAiStandardCost,
} from "../families/openai.ts";
const BASE_URL = "https://api.individual.githubcopilot.com";
const 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 EXTENDED_CONTEXT_MODEL_IDS = new Set([
"claude-fable-5",
"claude-opus-4.8",
"claude-opus-5",
"claude-opus-5.5",
"claude-sonnet-4.6",
"claude-sonnet-5",
"gpt-5.3-codex",
"gpt-6-astra",
"gpt-6-luna",
"gpt-6-sol",
]);
const EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_MODEL_IDS = new Set([
"claude-haiku-4.5",
"claude-sonnet-4",
"claude-sonnet-4.5",
]);
// 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 THINKING_LEVEL_OVERRIDES: Record<string, ThinkingLevelMap> = {
"claude-opus-4.8": { minimal: "low" },
"claude-opus-5": { minimal: "low" },
"claude-sonnet-4.6": { minimal: "low", max: "max" },
};
/** Claude 4.x and 5.x use Messages; GPT, Grok, OSWE, and MAI-Code are only served on /responses. */
function copilotApi(modelId: string): Api {
if (/^claude-(haiku|sonnet|opus|fable)-[45]([.-]|$)/.test(modelId)) return "anthropic-messages";
if (/^(gpt-|grok-|oswe|mai-)/.test(modelId)) return "openai-responses";
return "openai-completions";
}
// The authenticated Copilot catalog advertised these models on 2026-09-22,
// but models.dev did not include them yet.
const GPT_6_LUNA: Model<"openai-responses"> = {
id: "gpt-6-luna",
name: "GPT-6 Luna",
api: "openai-responses",
provider: "github-copilot",
baseUrl: BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: openAiStandardCost("gpt-6-luna"),
contextWindow: 1000000,
maxTokens: 128000,
headers: { ...STATIC_HEADERS },
};
export default defineProvider({
id: "github-copilot",
models: (ctx) =>
addMissing(
modelsDevModels(ctx, "github-copilot", {
api: "openai-completions",
baseUrl: BASE_URL,
headers: STATIC_HEADERS,
skipDeprecated: true,
defaultContextWindow: 128000,
defaultMaxTokens: 8192,
map: (model): ChatModel => {
const api = copilotApi(model.id);
const result: ChatModel = { ...model, api };
if (api === "openai-completions") {
result.compat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
};
}
if (model.id === "claude-opus-5.5") mergeThinking(result, claude55ThinkingLevels(model.id));
return result;
},
}),
[GPT_6_LUNA],
).map((model) => (EXTENDED_CONTEXT_MODEL_IDS.has(model.id) ? { ...model, contextWindow: 1000000 } : model)),
compat: { "openai-completions": { supportsStrictMode: true } },
finalize: (model) => {
if (model.id.startsWith("gpt-5")) mergeThinking(model, { minimal: "low" });
const override = THINKING_LEVEL_OVERRIDES[model.id];
if (override) mergeThinking(model, override);
if (model.api === "anthropic-messages") {
const messagesModel = model as Model<"anthropic-messages">;
// Copilot forwards mid-conversation system messages but rejects
// `tool_addition`/`tool_removal` blocks, so tool changes stay top-level.
if (supportsClaudeMidConvoSystemMessages(model.id)) {
mergeCompat(messagesModel, { supportsMidConvoSystemMessages: true });
}
if (EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_MODEL_IDS.has(model.id)) {
mergeCompat(messagesModel, { supportsEagerToolInputStreaming: false });
}
}
// Copilot forwards Kimi K3's system text but silently drops its tool-bearing message.
if (model.api === "openai-completions" && model.id === "kimi-k3") {
mergeCompat(model as Model<"openai-completions">, { supportsMidConvoSystemMessages: true });
}
// Copilot passes mid-conversation developer messages and `additional_tools`
// through to OpenAI unchanged; tool search is not verified through it.
enableOpenAiMidConvoSystemMessages(model, { proxy: true });
enableOpenAiGrammarTools(model);
},
});

View file

@ -0,0 +1,43 @@
import { defineProvider, modelsDevModels } from "../define.ts";
import { googleThinkingLevels } from "../families/google.ts";
import { modelsDevChatModels } from "../sources/models-dev.ts";
import { GOOGLE_ALIAS_TARGETS } from "./google.ts";
export default defineProvider({
id: "google-vertex",
models: (ctx) => {
const sources = new Map(
modelsDevChatModels(ctx.sources.modelsDev, "google-vertex").map((model) => [model.id, model]),
);
return modelsDevModels(ctx, "google-vertex", {
api: "google-vertex",
baseUrl: "https://{location}-aiplatform.googleapis.com",
recordReasoning: false,
// The models.dev catalog also includes Claude, OpenAI, and other MaaS models
// that do not use the @google/genai Gemini streaming path.
include: (source) => source.id.startsWith("gemini-") && source.id !== "gemini-3.1-flash-lite-preview",
map: (model, aliasSource) => {
const target = GOOGLE_ALIAS_TARGETS[model.id];
const source = (target && sources.get(target)) || aliasSource;
const thinkingLevelMap = googleThinkingLevels(model.id, source.reasoningOptions);
return {
...model,
reasoning: source.reasoning,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
input: source.imageInput ? ["text", "image"] : ["text"],
cost: {
input: source.cost.input || 0,
output: source.cost.output || 0,
// models.dev reports Vertex cache_read/cache_write values for Gemini 2.5 Flash that
// do not match the official Gemini API standard pricing table. pi only accounts
// cachedContentTokenCount as cacheRead.
cacheRead: model.id === "gemini-2.5-flash" ? 0.03 : source.cost.cacheRead || 0,
cacheWrite: 0,
},
contextWindow: source.contextWindow || 4096,
maxTokens: source.maxTokens || 4096,
};
},
});
},
});

View file

@ -0,0 +1,36 @@
import { defineProvider, modelsDevModels, withInputLimits } from "../define.ts";
import { googleThinkingLevels } from "../families/google.ts";
import { modelsDevChatModels, modelsDevCost } from "../sources/models-dev.ts";
// Rolling aliases take their metadata from the model they currently point to.
export const GOOGLE_ALIAS_TARGETS: Record<string, string> = {
"gemini-flash-latest": "gemini-3.5-flash",
"gemini-flash-lite-latest": "gemini-3.1-flash-lite",
};
export default defineProvider({
id: "google",
models: (ctx) => {
const sources = new Map(modelsDevChatModels(ctx.sources.modelsDev, "google").map((model) => [model.id, model]));
return modelsDevModels(ctx, "google", {
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
recordReasoning: false,
map: (model, aliasSource) => {
const target = GOOGLE_ALIAS_TARGETS[model.id];
const source = (target && sources.get(target)) || aliasSource;
const thinkingLevelMap = googleThinkingLevels(model.id, source.reasoningOptions);
return {
...model,
reasoning: source.reasoning,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
input: source.imageInput ? ["text", "image"] : ["text"],
cost: modelsDevCost(source.cost),
contextWindow: source.contextWindow || 4096,
maxTokens: source.maxTokens || 4096,
};
},
});
},
finalize: (model) => withInputLimits(model, { maxRequestBytes: 20 * 1024 * 1024, images: { maxPerRequest: 3600 } }),
});

View file

@ -0,0 +1,13 @@
import { defineProvider, mergeThinking, modelsDevModels } from "../define.ts";
export default defineProvider({
id: "groq",
models: (ctx) =>
modelsDevModels(ctx, "groq", { api: "openai-completions", baseUrl: "https://api.groq.com/openai/v1" }),
compat: { "openai-completions": { supportsStrictMode: true } },
finalize: (model) => {
if (model.id === "qwen/qwen3.6-27b") {
mergeThinking(model, { minimal: null, low: null, medium: null, high: "default" });
}
},
});

View file

@ -0,0 +1,12 @@
import { defineProvider, modelsDevModels } from "../define.ts";
export default defineProvider({
id: "huggingface",
models: (ctx) =>
modelsDevModels(ctx, "huggingface", {
api: "openai-completions",
baseUrl: "https://router.huggingface.co/v1",
compat: { supportsDeveloperRole: false },
}),
compat: { "openai-completions": { supportsStrictMode: true } },
});

View file

@ -0,0 +1,90 @@
// Every built-in provider catalog. Add new providers here.
import type { ProviderDefinition } from "../define.ts";
import amazonBedrock from "./amazon-bedrock.ts";
import antLing from "./ant-ling.ts";
import anthropic from "./anthropic.ts";
import azure from "./azure.ts";
import baseten from "./baseten.ts";
import cerebras from "./cerebras.ts";
import cloudflareAiGateway from "./cloudflare-ai-gateway.ts";
import cloudflareWorkersAi from "./cloudflare-workers-ai.ts";
import deepseek from "./deepseek.ts";
import fireworks from "./fireworks.ts";
import githubCopilot from "./github-copilot.ts";
import google from "./google.ts";
import googleVertex from "./google-vertex.ts";
import groq from "./groq.ts";
import huggingface from "./huggingface.ts";
import kimiCoding from "./kimi-coding.ts";
import meta from "./meta.ts";
import minimax from "./minimax.ts";
import minimaxCn from "./minimax-cn.ts";
import mistral from "./mistral.ts";
import moonshotai from "./moonshotai.ts";
import moonshotaiCn from "./moonshotai-cn.ts";
import nvidia from "./nvidia.ts";
import openai from "./openai.ts";
import openaiCodex from "./openai-codex.ts";
import opencode from "./opencode.ts";
import opencodeGo from "./opencode-go.ts";
import openrouter from "./openrouter.ts";
import qwenTokenPlan from "./qwen-token-plan.ts";
import qwenTokenPlanCn from "./qwen-token-plan-cn.ts";
import qwenTokenPlanIndividual from "./qwen-token-plan-individual.ts";
import radius from "./radius.ts";
import together from "./together.ts";
import typesafe from "./typesafe.ts";
import vercelAiGateway from "./vercel-ai-gateway.ts";
import xai from "./xai.ts";
import xiaomi from "./xiaomi.ts";
import xiaomiTokenPlanAms from "./xiaomi-token-plan-ams.ts";
import xiaomiTokenPlanCn from "./xiaomi-token-plan-cn.ts";
import xiaomiTokenPlanSgp from "./xiaomi-token-plan-sgp.ts";
import zai from "./zai.ts";
import zaiCodingCn from "./zai-coding-cn.ts";
export const PROVIDERS: readonly ProviderDefinition[] = [
amazonBedrock,
antLing,
anthropic,
azure,
baseten,
cerebras,
cloudflareAiGateway,
cloudflareWorkersAi,
deepseek,
fireworks,
githubCopilot,
googleVertex,
google,
groq,
huggingface,
kimiCoding,
meta,
minimaxCn,
minimax,
mistral,
moonshotaiCn,
moonshotai,
nvidia,
openaiCodex,
openai,
opencodeGo,
opencode,
openrouter,
qwenTokenPlanCn,
qwenTokenPlanIndividual,
qwenTokenPlan,
radius,
together,
typesafe,
vercelAiGateway,
xai,
xiaomiTokenPlanAms,
xiaomiTokenPlanCn,
xiaomiTokenPlanSgp,
xiaomi,
zaiCodingCn,
zai,
];

View file

@ -0,0 +1,55 @@
import type { Model, ModelCost } from "../../src/types.ts";
import { defineProvider, modelsDevModels } from "../define.ts";
import { KIMI_K3_COST, KIMI_K3_OPENAI_COMPAT, KIMI_OPENAI_COMPAT } from "../families/kimi.ts";
import { getEffortThinkingLevelMap } from "../reasoning.ts";
// 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 IMPLIED_COSTS: Record<string, ModelCost> = {
k3: KIMI_K3_COST,
"k3-256k": KIMI_K3_COST,
"k3.1-pro": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 0 },
"k3.1-pro-highspeed": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 0 },
"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 Coding uses Chat Completions, the same protocol as the official Kimi Code client.
export default defineProvider({
id: "kimi-coding",
models: (ctx) =>
modelsDevModels(ctx, "kimi-code-plan-global", {
api: "openai-completions",
baseUrl: "https://api.kimi.com/coding/v1",
recordReasoning: false,
map: (model, source): Model<"openai-completions"> => {
// K3-style models report effort levels and dynamic tool loading (tools declared in
// later system messages) in https://api.kimi.com/coding/v1/models; models.dev mirrors
// the effort levels. kimi-for-coding-highspeed reports neither and rejects
// tool-bearing system messages with a 400.
const isK3Style = source.reasoningOptions?.some((option) => option.type === "effort") === true;
const impliedCost = IMPLIED_COSTS[model.id];
// K3-style models send reasoning_effort, so expose the effort values models.dev lists.
const effortLevels =
isK3Style && source.reasoningOptions ? getEffortThinkingLevelMap(source.reasoningOptions) : undefined;
return {
...model,
compat: {
...(isK3Style ? KIMI_K3_OPENAI_COMPAT : KIMI_OPENAI_COMPAT),
// All models accept system text after the conversation starts.
supportsMidConvoSystemMessages: true,
...(isK3Style ? { supportsMidConvoToolAdditions: true } : {}),
},
cost: {
input: source.cost.input || impliedCost?.input || 0,
output: source.cost.output || impliedCost?.output || 0,
cacheRead: source.cost.cacheRead || impliedCost?.cacheRead || 0,
cacheWrite: source.cost.cacheWrite || impliedCost?.cacheWrite || 0,
},
// Kimi Code reports every model as always-thinking.
thinkingLevelMap: { ...effortLevels, off: null },
};
},
}),
compat: { "openai-completions": { supportsStrictMode: true } },
});

View file

@ -0,0 +1,6 @@
import { defineProvider, modelsDevModels } from "../define.ts";
export default defineProvider({
id: "meta",
models: (ctx) => modelsDevModels(ctx, "meta", { api: "openai-responses", baseUrl: "https://api.meta.ai/v1" }),
});

View file

@ -0,0 +1,7 @@
import { defineProvider } from "../define.ts";
import { minimaxModels } from "./minimax.ts";
export default defineProvider({
id: "minimax-cn",
models: (ctx) => minimaxModels(ctx, "minimax-cn", "https://api.minimaxi.com/anthropic"),
});

View file

@ -0,0 +1,26 @@
import { defineProvider, modelsDevModels, type ProviderContext } from "../define.ts";
import { getEffortThinkingLevelMap } from "../reasoning.ts";
// MiniMax asked to hide older models from its direct providers (#2596).
const HIDDEN_MODEL_IDS = new Set(["MiniMax-M2", "MiniMax-M2.1", "MiniMax-M2.5", "MiniMax-M2.5-highspeed"]);
/** MiniMax's Anthropic-compatible API. The SDK appends /v1/messages. */
export function minimaxModels(ctx: ProviderContext, source: string, baseUrl: string) {
return modelsDevModels(ctx, source, {
api: "anthropic-messages",
baseUrl,
recordReasoning: false,
include: (model) => !HIDDEN_MODEL_IDS.has(model.id),
map: (model, source) => {
// Models with effort levels (e.g. MiniMax-M3.1-Flash) take adaptive thinking with
// output_config.effort and reject thinking.type="disabled" (no "none" effort, so off is null).
const thinkingLevelMap = getEffortThinkingLevelMap(source.reasoningOptions ?? []);
return thinkingLevelMap ? { ...model, thinkingLevelMap, compat: { forceAdaptiveThinking: true } } : model;
},
});
}
export default defineProvider({
id: "minimax",
models: (ctx) => minimaxModels(ctx, "minimax", "https://api.minimax.io/anthropic"),
});

View file

@ -0,0 +1,46 @@
import { addMissing, defineProvider, modelsDevModels } from "../define.ts";
import { getEffortThinkingLevelMap } from "../reasoning.ts";
import { roundCost } from "../sources/models-dev.ts";
export default defineProvider({
id: "mistral",
models: (ctx) =>
addMissing(
modelsDevModels(ctx, "mistral", {
api: "mistral-conversations",
baseUrl: "https://api.mistral.ai",
recordReasoning: false,
map: (model, source) => {
// Models with effort values use `reasoning_effort` with these levels.
// Reasoning models without them (Magistral) use `prompt_mode`.
const thinkingLevelMap = getEffortThinkingLevelMap(source.reasoningOptions ?? []);
return {
...model,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
cost: {
input: source.cost.input || 0,
output: source.cost.output || 0,
// Mistral bills cached input at 10% when models.dev does not say otherwise.
cacheRead: source.cost.cacheRead ?? (source.cost.input ? roundCost(source.cost.input * 0.1) : 0),
cacheWrite: source.cost.cacheWrite || 0,
},
};
},
}),
[
{
id: "mistral-medium-3.5",
name: "Mistral Medium 3.5",
api: "mistral-conversations",
provider: "mistral",
baseUrl: "https://api.mistral.ai",
reasoning: true,
thinkingLevelMap: getEffortThinkingLevelMap([{ type: "effort", values: ["none", "high"] }]),
input: ["text", "image"],
cost: { input: 1.5, output: 7.5, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 262144,
},
],
),
});

View file

@ -0,0 +1,9 @@
import { defineProvider } from "../define.ts";
import { finalizeMoonshotModel, MOONSHOT_COMPLETIONS_COMPAT, moonshotModels } from "./moonshotai.ts";
export default defineProvider({
id: "moonshotai-cn",
models: (ctx) => moonshotModels(ctx, "moonshotai-cn", "https://api.moonshot.cn/v1"),
compat: { "openai-completions": MOONSHOT_COMPLETIONS_COMPAT },
finalize: finalizeMoonshotModel,
});

View file

@ -0,0 +1,71 @@
import type { Model } from "../../src/types.ts";
import {
type ChatModel,
defineProvider,
mergeCompat,
mergeThinking,
modelsDevModels,
type ProviderContext,
} from "../define.ts";
import { isKimiK3FamilyId, KIMI_K3_COST, KIMI_K3_OPENAI_COMPAT, KIMI_OPENAI_COMPAT } from "../families/kimi.ts";
const ALWAYS_THINKING_MODEL_IDS = new Set(["kimi-k2.7-code", "kimi-k2.7-code-highspeed"]);
// Kimi K2.6/K2.7 accept system text after the conversation starts but reject
// tool-bearing system messages.
const TEXT_ONLY_MID_CONVO_MODEL_IDS = new Set(["kimi-k2.6", ...ALWAYS_THINKING_MODEL_IDS]);
export function moonshotModels(ctx: ProviderContext, source: string, baseUrl: string) {
return modelsDevModels(ctx, source, {
api: "openai-completions",
baseUrl,
map: (model, source): Model<"openai-completions"> => {
const isKimiK3 = isKimiK3FamilyId(model.id);
return {
...model,
reasoning: isKimiK3 || source.reasoning,
// Moonshot does not bill cache writes; models.dev lists the input rate as cache_write for the K3 family.
cost:
model.id === "kimi-k3"
? { ...KIMI_K3_COST }
: {
input: source.cost.input || 0,
output: source.cost.output || 0,
cacheRead: source.cost.cacheRead || 0,
cacheWrite: isKimiK3 ? 0 : source.cost.cacheWrite || 0,
},
compat: { ...(isKimiK3 ? KIMI_K3_OPENAI_COMPAT : KIMI_OPENAI_COMPAT) },
};
},
});
}
export const MOONSHOT_COMPLETIONS_COMPAT = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
} as const;
export function finalizeMoonshotModel(model: ChatModel): void {
if (ALWAYS_THINKING_MODEL_IDS.has(model.id)) {
// 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.
mergeThinking(model, { off: null });
}
const isKimiK3 = isKimiK3FamilyId(model.id);
if (isKimiK3 || TEXT_ONLY_MID_CONVO_MODEL_IDS.has(model.id)) {
mergeCompat(model as Model<"openai-completions">, {
supportsMidConvoSystemMessages: true,
// The Kimi K3 family also accepts tool-bearing system messages.
...(isKimiK3 ? { supportsMidConvoToolAdditions: true } : {}),
});
}
}
export default defineProvider({
id: "moonshotai",
models: (ctx) => moonshotModels(ctx, "moonshotai", "https://api.moonshot.ai/v1"),
compat: { "openai-completions": MOONSHOT_COMPLETIONS_COMPAT },
finalize: finalizeMoonshotModel,
});

View file

@ -0,0 +1,64 @@
import { defineProvider, modelsDevModels } from "../define.ts";
import { NVIDIA_BASE_URL, normalizeNvidiaModelId } from "../sources/index.ts";
// Listed by NIM but unusable through pi (no tool calls, broken streaming, or retired).
const 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",
]);
export default defineProvider({
id: "nvidia",
models: (ctx) => {
const liveIds = ctx.sources.nvidiaModelIds;
const liveId = (id: string) => liveIds.get(id) ?? liveIds.get(normalizeNvidiaModelId(id));
return modelsDevModels(ctx, "nvidia", {
api: "openai-completions",
baseUrl: NVIDIA_BASE_URL,
headers: { "NVCF-POLL-SECONDS": "3600" },
compat: {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
supportsLongCacheRetention: false,
},
// Only models the live NIM API serves, under the id it uses.
include: (source) => {
if (!source.textInput || !source.textOutput) return false;
const id = liveId(source.id);
return id !== undefined && !UNSUPPORTED_MODEL_IDS.has(id);
},
map: (model) => {
const id = liveId(model.id) ?? model.id;
return { ...model, id, name: model.name === model.id ? id : model.name };
},
});
},
compat: {
"openai-completions": {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsLongCacheRetention: false,
},
},
});

View file

@ -0,0 +1,68 @@
import type { Model } from "../../src/types.ts";
import { defineProvider, mergeCompat, mergeThinking } from "../define.ts";
import {
enableOpenAiGrammarTools,
enableOpenAiMidConvoSystemMessages,
OPENAI_TOOL_SEARCH_MODEL_IDS,
openAiStandardCost,
supportsOpenAiXhigh,
} from "../families/openai.ts";
// ChatGPT OAuth models. models.dev does not list them; keep a small, explicit
// list without aliases. Only list models the Codex backend accepts for ChatGPT
// accounts: GPT-5.5 and GPT-5.3 Codex Spark are rejected with "not supported
// when using Codex with a ChatGPT account".
const MODELS = [
// GPT-6.1 uses the 500k default returned by /backend-api/codex/models.
{ id: "gpt-6.1-sol", name: "GPT-6.1 Sol", contextWindow: 500000 },
{ id: "gpt-6.1-terra", name: "GPT-6.1 Terra", contextWindow: 500000 },
// GPT-5.6 and GPT-6 use Codex's 272k default catalog limit.
{ id: "gpt-6-astra", name: "GPT-6 Astra", contextWindow: 272000 },
{ id: "gpt-6-sol", name: "GPT-6 Sol", contextWindow: 272000 },
{ id: "gpt-6-luna", name: "GPT-6 Luna", contextWindow: 272000 },
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", contextWindow: 272000 },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", contextWindow: 272000 },
{ id: "gpt-5.6-terra", name: "GPT-5.6 Terra", contextWindow: 272000 },
];
// Codex CLI keeps additional_tools behind its own model list.
const ADDITIONAL_TOOLS_MODEL_IDS = new Set([
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-6.1-sol",
"gpt-6.1-terra",
]);
export default defineProvider({
id: "openai-codex",
models: () =>
MODELS.map(
({ id, name, contextWindow }): Model<"openai-codex-responses"> => ({
id,
name,
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text", "image"],
cost: openAiStandardCost(id),
contextWindow,
maxTokens: 128000,
}),
),
finalize: (model) => {
if (supportsOpenAiXhigh(model.id)) mergeThinking(model, { minimal: "low" });
enableOpenAiGrammarTools(model);
if (OPENAI_TOOL_SEARCH_MODEL_IDS.has(model.id)) {
mergeCompat(model as Model<"openai-codex-responses">, {
...(ADDITIONAL_TOOLS_MODEL_IDS.has(model.id) ? { supportsAdditionalTools: true } : {}),
supportsToolSearch: true,
});
}
enableOpenAiMidConvoSystemMessages(model, { proxy: false });
},
});

View file

@ -0,0 +1,141 @@
import type { ClassifierModel, Model } from "../../src/types.ts";
import {
addMissing,
type ChatModel,
defineProvider,
mergeCompat,
mergeThinking,
modelsDevModels,
withInputLimits,
} from "../define.ts";
import {
enableOpenAiGrammarTools,
enableOpenAiMidConvoSystemMessages,
OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
OPENAI_STANDARD_COSTS,
OPENAI_TOOL_SEARCH_MODEL_IDS,
withOpenAiLongContextPricing,
} from "../families/openai.ts";
const BASE_URL = "https://api.openai.com/v1";
// models.dev lists this alias, but it is not accepted by OpenAI APIs.
const 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 SHORT_CONTEXT_CAPPED_MODEL_IDS = new Set([
"gpt-5.4",
"gpt-5.5",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-6.1-sol",
"gpt-6.1-terra",
]);
const LONG_CONTEXT_PRICING_MODEL_IDS = new Set([...SHORT_CONTEXT_CAPPED_MODEL_IDS, "gpt-5.4-pro", "gpt-5.5-pro"]);
const NONE_REASONING_MODEL_IDS = new Set([
"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",
"gpt-6-sol",
"gpt-6-luna",
]);
// OpenAI documents configuration_update for the GPT-6 family.
// https://developers.openai.com/api/docs/guides/reasoning
const MID_CONVO_EFFORT_MODEL_IDS = new Set(["gpt-6-astra", "gpt-6-sol", "gpt-6-luna", "gpt-6.1-sol", "gpt-6.1-terra"]);
// Not listed by models.dev.
const GPT_5_CHAT_LATEST: Model<"openai-responses"> = {
id: "gpt-5-chat-latest",
name: "GPT-5 Chat Latest",
api: "openai-responses",
baseUrl: BASE_URL,
provider: "openai",
reasoning: false,
input: ["text", "image"],
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
};
// OpenAI Decisions API (public beta): gpt-6-luna is its only model. It bills input tokens only,
// with the same long-context multiplier as chat requests. Only API keys work: Sign in with ChatGPT
// tokens are rejected on /v1/decisions, and the Codex backend has no Decisions route.
// The endpoint rejects inputs above 922K tokens (the model's documented maximum input), but
// requests running longer than about five seconds, currently above roughly 600K input tokens,
// fail with a gateway timeout.
// https://developers.openai.com/api/docs/guides/decisions
const CLASSIFIER_MODELS: ClassifierModel<"openai-decisions">[] = [
{
type: "classifier",
id: "gpt-6-luna",
name: "GPT-6 Luna",
api: "openai-decisions",
provider: "openai",
baseUrl: BASE_URL,
input: ["text", "image"],
cost: withOpenAiLongContextPricing({ input: 0.1, output: 0, cacheRead: 0, cacheWrite: 0 }),
contextWindow: 922000,
},
];
function correctUpstream(model: ChatModel): ChatModel {
if (SHORT_CONTEXT_CAPPED_MODEL_IDS.has(model.id)) {
model.contextWindow = OPENAI_LONG_CONTEXT_INPUT_THRESHOLD;
model.maxTokens = 128000;
}
if (LONG_CONTEXT_PRICING_MODEL_IDS.has(model.id)) {
model.cost = withOpenAiLongContextPricing(OPENAI_STANDARD_COSTS[model.id] ?? model.cost);
}
// models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit);
// the actual max output is 128000. Also propagates to the derived Azure clone.
if (model.id === "gpt-5-pro") model.maxTokens = 128000;
return model;
}
export default defineProvider({
id: "openai",
models: (ctx) =>
addMissing(
modelsDevModels(ctx, "openai", {
api: "openai-responses",
baseUrl: BASE_URL,
// Audio models are only served on Chat Completions.
protocol: "responses",
include: (source) => !UNSUPPORTED_MODEL_IDS.has(source.id),
map: correctUpstream,
}),
[GPT_5_CHAT_LATEST],
),
classifiers: () => CLASSIFIER_MODELS,
finalize: (model) => {
if (model.api !== "openai-responses") return;
const responsesModel = model as Model<"openai-responses">;
if (NONE_REASONING_MODEL_IDS.has(model.id)) mergeThinking(model, { off: "none" });
if (model.id === "gpt-5.5") mergeThinking(model, { minimal: null });
mergeCompat(responsesModel, { supportsStrictMode: true });
enableOpenAiGrammarTools(model);
if (OPENAI_TOOL_SEARCH_MODEL_IDS.has(model.id)) {
mergeCompat(responsesModel, { supportsAdditionalTools: true, supportsToolSearch: true });
}
enableOpenAiMidConvoSystemMessages(model, { proxy: false });
// 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
if (model.cost.cacheWrite > 0) mergeCompat(responsesModel, { supportsExplicitPromptCacheMode: true });
if (MID_CONVO_EFFORT_MODEL_IDS.has(model.id)) mergeCompat(responsesModel, { supportsMidConvoEffort: true });
withInputLimits(model, { maxRequestBytes: 512 * 1024 * 1024, images: { maxPerRequest: 1500 } });
},
});

View file

@ -0,0 +1,9 @@
import { defineProvider } from "../define.ts";
import { finalizeOpencodeModel, OPENCODE_COMPLETIONS_COMPAT, opencodeModels } from "./opencode.ts";
export default defineProvider({
id: "opencode-go",
models: opencodeModels,
compat: { "openai-completions": OPENCODE_COMPLETIONS_COMPAT },
finalize: finalizeOpencodeModel,
});

View file

@ -0,0 +1,213 @@
import type { Api, ClassifierModel, Model, OpenAICompletionsCompat, OpenAIResponsesCompat } from "../../src/types.ts";
import {
type ChatModel,
defineProvider,
mergeCompat,
mergeThinking,
modelsDevModels,
type ProviderContext,
} from "../define.ts";
import {
enableClaudeMidConvoEffort,
supportsClaudeMidConvoEffort,
supportsClaudeMidConvoSystemMessages,
} from "../families/anthropic.ts";
import { googleThinkingLevels } from "../families/google.ts";
import { isKimiK3FamilyId } from "../families/kimi.ts";
import { enableOpenAiGrammarTools, enableOpenAiMidConvoSystemMessages } from "../families/openai.ts";
import { getEffortThinkingLevelMap } from "../reasoning.ts";
import type { ModelsDevModel } from "../sources/models-dev.ts";
// OpenAI-compatible endpoints reject the long prompt-cache retention parameter for these models.
const LONG_CACHE_RETENTION_UNSUPPORTED_MODELS = new Set([
"opencode:deepseek-v4-flash",
"opencode:deepseek-v4.1-flash",
"opencode:deepseek-v4-pro",
"opencode:kimi-k2.5",
"opencode:kimi-k2.6",
"opencode:minimax-m2.7",
"opencode-go:kimi-k2.6",
]);
// OpenCode serves each model on one protocol-specific endpoint. Native protocols
// come before generic Chat Completions if models.dev ever lists several.
const OPENCODE_PROTOCOLS = ["responses", "messages", "generate-content", "chat-completions"] as const;
interface OpencodeApi {
api: Api;
baseUrl: string;
compat?: OpenAICompletionsCompat | OpenAIResponsesCompat;
}
/**
* The API and base URL models.dev lists for a model. Models served only on a
* protocol pi does not support fail strict generation and are skipped otherwise.
*/
function opencodeApi(ctx: ProviderContext, source: ModelsDevModel): OpencodeApi | undefined {
const entry = OPENCODE_PROTOCOLS.map((protocol) =>
source.apis.find((candidate) => candidate.protocol === protocol),
).find((candidate) => candidate !== undefined);
if (!entry) {
const protocols = source.apis.map((candidate) => candidate.protocol).join(", ") || "none";
const message = `models.dev lists no supported API for ${ctx.providerId}/${source.id} (${protocols})`;
if (ctx.strict) throw new Error(message);
console.warn(`Skipping model: ${message}`);
return undefined;
}
switch (entry.protocol) {
case "responses":
return {
api: "openai-responses",
baseUrl: entry.baseUrl,
compat: { sessionAffinityFormat: "openai-nosession" },
};
case "messages":
// The Anthropic SDK appends /v1/messages itself.
return { api: "anthropic-messages", baseUrl: entry.baseUrl.replace(/\/v1\/?$/, "") };
case "generate-content":
return { api: "google-generative-ai", baseUrl: entry.baseUrl };
case "chat-completions":
return {
api: "openai-completions",
baseUrl: entry.baseUrl,
...(entry.aisdk === "@ai-sdk/alibaba" ? { compat: { cacheControlFormat: "anthropic" } } : {}),
};
}
}
/** OpenCode Zen and Go models, on the endpoints models.dev lists for them. */
export function opencodeModels(ctx: ProviderContext): ChatModel[] {
const provider = ctx.providerId;
return modelsDevModels(ctx, provider, {
api: "openai-completions",
// Replaced by the base URL of the API models.dev lists for each model.
baseUrl: "",
skipDeprecated: true,
// Codex Spark is not usable through OpenCode.
include: (source) => source.id !== "gpt-5.3-codex-spark",
map: (model, source) => {
const served = opencodeApi(ctx, source);
if (!served) return undefined;
let { api, baseUrl, compat } = served;
if (provider === "opencode" && model.id === "grok-build-0.1") {
compat = { ...compat, supportsReasoningEffort: false };
}
if (model.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 };
}
// models.dev reports these as @ai-sdk/anthropic, but OpenCode Go serves
// them through the OpenAI-compatible /openai/v1/chat/completions path.
if (
provider === "opencode-go" &&
api === "anthropic-messages" &&
(model.id === "qwen3.5-plus" || model.id === "qwen3.6-plus")
) {
api = "openai-completions";
baseUrl = baseUrl.replace(/\/anthropic$/, "/openai/v1");
// Qwen/DashScope uses enable_thinking at the top level.
compat = { ...compat, thinkingFormat: "qwen" };
}
if (api === "openai-completions") {
compat = {
...compat,
maxTokensField: "max_tokens",
...(LONG_CACHE_RETENTION_UNSUPPORTED_MODELS.has(`${provider}:${model.id}`)
? { supportsLongCacheRetention: false }
: {}),
};
}
const thinkingLevelMap =
api === "google-generative-ai"
? googleThinkingLevels(model.id, source.reasoningOptions)
: provider === "opencode-go" && model.id === "deepseek-v4.1-flash"
? getEffortThinkingLevelMap(source.reasoningOptions ?? [])
: undefined;
const result: ChatModel = { ...model, api, baseUrl, ...(thinkingLevelMap ? { thinkingLevelMap } : {}) };
if (compat) result.compat = compat;
// OpenCode variants list Claude Sonnet 4/4.5 with 1M context, actual limit is 200K.
if (model.id === "claude-sonnet-4-5" || model.id === "claude-sonnet-4") result.contextWindow = 200000;
if (["claude-opus-4-6", "claude-sonnet-4-6", "claude-opus-4.6", "claude-sonnet-4.6"].includes(model.id)) {
result.contextWindow = 1000000;
}
if (model.id === "gpt-5.4") {
result.contextWindow = 272000;
result.maxTokens = 128000;
}
return result;
},
});
}
export const OPENCODE_COMPLETIONS_COMPAT = {
supportsStore: false,
supportsDeveloperRole: false,
supportsStrictMode: true,
} as const;
/** Behavior shared by OpenCode Zen and Go. */
export function finalizeOpencodeModel(model: ChatModel): void {
if (model.provider === "opencode-go" && model.id === "glm-5.2") {
mergeThinking(model, { off: null, minimal: null, low: null, medium: null, high: "high", max: "max" });
}
if (model.provider === "opencode-go" && model.id === "kimi-k2.6") {
// OpenCode Go exposes Kimi K2.6 thinking as on/off, not distinct effort tiers.
mergeThinking(model, { minimal: null, low: null, medium: null });
}
if (model.provider === "opencode" && model.id === "grok-build-0.1") {
// OpenCode Zen Grok Build reasons by default but rejects explicit reasoningEffort.
mergeThinking(model, { off: null, minimal: null, low: null, medium: null });
}
if (model.api === "anthropic-messages") {
const messagesModel = model as Model<"anthropic-messages">;
// OpenCode Zen forwards mid-conversation system messages but rejects
// `tool_addition`/`tool_removal` blocks, so tool changes stay top-level.
if (model.provider === "opencode" && supportsClaudeMidConvoSystemMessages(model.id)) {
mergeCompat(messagesModel, { supportsMidConvoSystemMessages: true });
}
// Zen and Go forward per-message effort and `thinking.block_binding`. Without
// `drop_block`, Go rejects replayed thinking after the system prompt or tools change.
if (supportsClaudeMidConvoEffort(model.id)) enableClaudeMidConvoEffort(messagesModel);
// OpenCode Qwen 3.8 Flash emits and accepts thinking blocks with empty signatures.
if (model.id === "qwen3.8-flash") mergeCompat(messagesModel, { allowEmptySignature: true });
}
// The Kimi K3 family accepts tool-bearing system messages; OpenCode passes them through.
if (model.api === "openai-completions" && isKimiK3FamilyId(model.id)) {
mergeCompat(model as Model<"openai-completions">, {
supportsMidConvoSystemMessages: true,
supportsMidConvoToolAdditions: true,
});
}
// OpenCode passes mid-conversation developer messages and `additional_tools`
// through to OpenAI unchanged; tool search is not verified through it.
enableOpenAiMidConvoSystemMessages(model, { proxy: true });
}
// OpenCode Zen serves Jev through its TypeSafe-compatible System One endpoint.
// Neither its /inference/openai/v1/models listing nor models.dev carries metadata for it.
// https://opencode.ai/docs/zen
function jev(id: string, name: string, input: number): ClassifierModel<"typesafe-system-one"> {
return {
type: "classifier",
id,
name,
api: "typesafe-system-one",
provider: "opencode",
baseUrl: "https://opencode.ai/inference/systemone/v1",
input: ["text"],
cost: { input, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32000,
};
}
export default defineProvider({
id: "opencode",
models: opencodeModels,
classifiers: () => [jev("jev-1.13", "Jev 1.13", 0.042), jev("jev-1.13-free", "Jev 1.13 Free", 0)],
compat: { "openai-completions": OPENCODE_COMPLETIONS_COMPAT },
finalize: (model) => {
finalizeOpencodeModel(model);
enableOpenAiGrammarTools(model);
},
});

View file

@ -0,0 +1,108 @@
import type { Model, OpenAICompletionsCompat } from "../../src/types.ts";
import { addMissing, type ChatModel, defineProvider, mergeCompat, mergeThinking } from "../define.ts";
import { enableClaudeMidConvoEffort, supportsClaudeMidConvoEffort } from "../families/anthropic.ts";
import { isKimiK3FamilyId, KIMI_K3_MAX_TOKENS } from "../families/kimi.ts";
import { OPENAI_MID_CONVO_MODEL_IDS } from "../families/openai.ts";
const BASE_URL = "https://openrouter.ai/api/v1";
// OpenRouter rejects `configuration_update` system messages on Opus 5 ("Mid-conversation
// reasoning effort (configuration_update) is not supported on anthropic/claude-opus-5-20260723")
// while accepting them on Fable 5.1.
const MID_CONVO_EFFORT_UNSUPPORTED_MODEL_IDS = new Set(["anthropic/claude-opus-5"]);
/** Keep selected model metadata stable until upstream settles. */
function correctUpstream(model: ChatModel): ChatModel {
// Keep Kimi K3's canonical output limit when gateway metadata is missing or incorrect.
if (
model.id === "~moonshotai/kimi-latest" ||
(model.id.startsWith("moonshotai/") && isKimiK3FamilyId(model.id.slice("moonshotai/".length)))
) {
model.maxTokens = KIMI_K3_MAX_TOKENS;
}
if (model.id === "moonshotai/kimi-k2.5") {
model.cost = { ...model.cost, input: 0.41, output: 2.06, cacheRead: 0.07 };
model.maxTokens = 4096;
}
if (model.id.startsWith("moonshotai/kimi-k2.6")) {
mergeCompat(model as Model<"openai-completions">, {
supportsDeveloperRole: false,
requiresReasoningContentOnAssistantMessages: true,
});
}
if (model.id === "z-ai/glm-5") model.cost = { ...model.cost, input: 0.6, output: 1.9, cacheRead: 0.119 };
return model;
}
// Router aliases. Their price depends on the model they route to, so it is unknown here.
const ROUTER_MODELS: Model<"openai-completions">[] = [
{
id: "auto",
name: "Auto",
api: "openai-completions",
provider: "openrouter",
baseUrl: BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
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.
{
id: "openrouter/fusion",
name: "OpenRouter: Fusion",
api: "openai-completions",
provider: "openrouter",
baseUrl: BASE_URL,
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 30000,
},
];
export default defineProvider({
id: "openrouter",
models: (ctx) =>
addMissing(structuredClone(ctx.sources.openRouter.chat).map(correctUpstream), structuredClone(ROUTER_MODELS)),
images: (ctx) => structuredClone(ctx.sources.openRouter.images),
classifiers: (ctx) => structuredClone(ctx.sources.openRouter.classifiers),
compat: {
"openai-completions": (model): OpenAICompletionsCompat => ({
// OpenRouter passes developer messages through to Anthropic and OpenAI models only.
...(/^(anthropic|openai)\//.test(model.id) ? {} : { supportsDeveloperRole: false }),
thinkingFormat: "openrouter",
supportsStrictMode: true,
...(/^~?anthropic\//.test(model.id) ? { cacheControlFormat: "anthropic" } : {}),
sendSessionAffinityHeaders: true,
}),
},
finalize: (model) => {
if (
model.api === "anthropic-messages" &&
supportsClaudeMidConvoEffort(model.id) &&
!MID_CONVO_EFFORT_UNSUPPORTED_MODEL_IDS.has(model.id)
) {
enableClaudeMidConvoEffort(model as Model<"anthropic-messages">);
}
if (model.id.startsWith("inception/mercury-2")) {
// 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 defaulting to {reasoning:{effort:"none"}}.
// Pi's low/medium/high pass through verbatim; OpenRouter normalizes to Mercury's vocabulary.
mergeThinking(model, { off: null });
}
if (model.id === "z-ai/glm-5.2") mergeThinking(model, { xhigh: "xhigh" });
// OpenAI models behind OpenRouter accept plain system text in place.
if (
model.api === "openai-completions" &&
model.id.startsWith("openai/") &&
OPENAI_MID_CONVO_MODEL_IDS.has(model.id.slice("openai/".length))
) {
mergeCompat(model as Model<"openai-completions">, { supportsMidConvoSystemMessages: true });
}
},
});

View file

@ -0,0 +1,13 @@
import { defineProvider } from "../define.ts";
import { QWEN_COMPLETIONS_COMPAT, qwenTokenPlanModels } from "./qwen-token-plan.ts";
export default defineProvider({
id: "qwen-token-plan-cn",
models: (ctx) =>
qwenTokenPlanModels(
ctx,
"alibaba-token-plan-cn",
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
),
compat: { "openai-completions": QWEN_COMPLETIONS_COMPAT },
});

View file

@ -0,0 +1,34 @@
import { assertExactModelIds } from "../../scripts/model-data.ts";
import { defineProvider } from "../define.ts";
import { INTERNATIONAL_BASE_URL, QWEN_COMPLETIONS_COMPAT, qwenTokenPlanModels } from "./qwen-token-plan.ts";
// QwenCloud Token Plan Individual text-model allowlist, verified 2026-09-03.
// It shares the international endpoint with a narrower catalog.
// https://docs.qwencloud.com/token-plan/personal/token-plan-personal-overview
const 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",
]);
export default defineProvider({
id: "qwen-token-plan-individual",
models: (ctx) => {
const models = qwenTokenPlanModels(ctx, "alibaba-token-plan", INTERNATIONAL_BASE_URL, MODEL_IDS);
if (ctx.strict) {
assertExactModelIds(
ctx.providerId,
MODEL_IDS,
models.map((model) => model.id),
);
}
return models;
},
compat: { "openai-completions": QWEN_COMPLETIONS_COMPAT },
});

View file

@ -0,0 +1,53 @@
import type { Model, OpenAICompletionsCompat } from "../../src/types.ts";
import { defineProvider, modelsDevModels, type ProviderContext } from "../define.ts";
import { getEffortThinkingLevelMap } from "../reasoning.ts";
// Alibaba Cloud Model Studio Token Plan. International and China use separate
// endpoints and API keys (sk-sp- prefix). models.dev lists them as
// "alibaba-token-plan[-cn]".
export const INTERNATIONAL_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
const COMPAT: OpenAICompletionsCompat = {
thinkingFormat: "qwen",
supportsDeveloperRole: false,
supportsStore: false,
supportsReasoningEffort: true,
};
const FALLBACK_THINKING_LEVELS = {
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: "max",
} as const;
const 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 EXCLUDED_MODEL_IDS = new Set(["qwen3.8-max-preview"]);
export function qwenTokenPlanModels(ctx: ProviderContext, source: string, baseUrl: string, modelIds?: Set<string>) {
return modelsDevModels(ctx, source, {
api: "openai-completions",
baseUrl,
recordReasoning: false,
include: (model) => !EXCLUDED_MODEL_IDS.has(model.id) && (!modelIds || modelIds.has(model.id)),
map: (model, source): Model<"openai-completions"> => {
const thinkingLevelMap =
getEffortThinkingLevelMap(source.reasoningOptions ?? []) ??
(REASONING_EFFORT_FALLBACK_MODEL_IDS.has(model.id) ? FALLBACK_THINKING_LEVELS : undefined);
return {
...model,
compat: thinkingLevelMap ? COMPAT : { ...COMPAT, supportsReasoningEffort: false },
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
};
},
});
}
export const QWEN_COMPLETIONS_COMPAT = { supportsStrictMode: true } as const;
export default defineProvider({
id: "qwen-token-plan",
models: (ctx) => qwenTokenPlanModels(ctx, "alibaba-token-plan", INTERNATIONAL_BASE_URL),
compat: { "openai-completions": QWEN_COMPLETIONS_COMPAT },
});

View file

@ -0,0 +1,7 @@
import { defineProvider } from "../define.ts";
// The unauthenticated public catalog; authenticated clients overlay it at runtime.
export default defineProvider({
id: "radius",
models: (ctx) => structuredClone(ctx.sources.radius),
});

View file

@ -0,0 +1,58 @@
import type { OpenAICompletionsCompat, ThinkingLevelMap } from "../../src/types.ts";
import { defineProvider, modelsDevModels } from "../define.ts";
const BASE_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
supportsLongCacheRetention: false,
};
const TOGGLE_REASONING_COMPAT: OpenAICompletionsCompat = { ...BASE_COMPAT, thinkingFormat: "together" };
const REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = {
...BASE_COMPAT,
supportsReasoningEffort: true,
thinkingFormat: "openai",
};
const TOGGLE_REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = {
...TOGGLE_REASONING_COMPAT,
supportsReasoningEffort: true,
};
const REASONING_EFFORT_MODELS = new Set(["openai/gpt-oss-20b", "openai/gpt-oss-120b"]);
const TOGGLE_REASONING_EFFORT_MODELS = new Set(["deepseek-ai/DeepSeek-V4-Pro-0813"]);
const REASONING_EFFORT_LEVELS: ThinkingLevelMap = { off: null, minimal: null };
const DEEPSEEK_V4_LEVELS: ThinkingLevelMap = { minimal: null, low: null, medium: null, high: "high", xhigh: null };
const TOGGLE_REASONING_LEVELS: ThinkingLevelMap = { minimal: null, low: null, medium: null };
export default defineProvider({
id: "together",
models: (ctx) =>
modelsDevModels(ctx, "togetherai", {
api: "openai-completions",
baseUrl: "https://api.together.ai/v1",
skipDeprecated: true,
map: (model) => {
if (!model.reasoning) return { ...model, compat: BASE_COMPAT };
if (REASONING_EFFORT_MODELS.has(model.id)) {
return { ...model, compat: REASONING_EFFORT_COMPAT, thinkingLevelMap: { ...REASONING_EFFORT_LEVELS } };
}
if (TOGGLE_REASONING_EFFORT_MODELS.has(model.id)) {
return { ...model, compat: TOGGLE_REASONING_EFFORT_COMPAT, thinkingLevelMap: { ...DEEPSEEK_V4_LEVELS } };
}
return { ...model, compat: TOGGLE_REASONING_COMPAT, thinkingLevelMap: { ...TOGGLE_REASONING_LEVELS } };
},
}),
compat: {
"openai-completions": {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "together",
supportsLongCacheRetention: false,
},
},
});

View file

@ -0,0 +1,32 @@
import type { ClassifierModel } from "../../src/types.ts";
import { defineProvider } from "../define.ts";
export default defineProvider({
id: "typesafe",
classifiers: (ctx): ClassifierModel<"typesafe-system-one">[] => {
const metadata = ctx.sources.modelsDevDecisions["typesafe/jev-latest"];
if (!metadata || metadata.type !== "decision") {
if (ctx.strict) throw new Error("models.dev did not return decision model typesafe/jev-latest");
return [];
}
return [
{
type: "classifier",
id: "jev-latest",
name: metadata.name,
api: "typesafe-system-one",
provider: "typesafe",
baseUrl: "https://api.typesafe.ai/v1/",
// models.dev lists text input only, but the API accepts image assets as an experimental
// feature behind an opt-in header, which the typesafe-system-one API sends.
// https://github.com/typesafe-ai/typesafe-sdk-js (v0.7.0 changelog)
input: ["text", "image"],
// The canonical models.dev entry has no direct-provider pricing. System One reports token usage,
// so classify() results carry token counts but price them at zero.
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: metadata.limit?.context || 64000,
compat: { imageInput: "assets", sendImageOptInHeader: true },
},
];
},
});

View file

@ -0,0 +1,14 @@
import { defineProvider } from "../define.ts";
import { isKimiK3FamilyId, KIMI_K3_MAX_TOKENS } from "../families/kimi.ts";
export default defineProvider({
id: "vercel-ai-gateway",
models: (ctx) =>
structuredClone(ctx.sources.aiGateway.chat).map((model) =>
// Keep Kimi K3's canonical output limit when gateway metadata is missing or incorrect.
model.id.startsWith("moonshotai/") && isKimiK3FamilyId(model.id.slice("moonshotai/".length))
? { ...model, maxTokens: KIMI_K3_MAX_TOKENS }
: model,
),
classifiers: (ctx) => structuredClone(ctx.sources.aiGateway.classifiers),
});

View file

@ -0,0 +1,29 @@
import { defineProvider, mergeThinking, modelsDevModels } from "../define.ts";
// Retired or unsuitable for pi's built-in catalog.
const EXCLUDED_MODEL_IDS = new Set([
"grok-3",
"grok-3-fast",
"grok-4.20-0309-non-reasoning",
"grok-4.20-0309-reasoning",
"grok-build-0.1",
"grok-code-fast-1",
]);
export default defineProvider({
id: "xai",
models: (ctx) =>
modelsDevModels(ctx, "xai", {
api: "openai-responses",
baseUrl: "https://api.x.ai/v1",
compat: { supportsLongCacheRetention: false },
include: (source) => !EXCLUDED_MODEL_IDS.has(source.id),
}),
finalize: (model) => {
// Models without verified effort options must not send the undocumented
// "none"/"minimal" efforts.
if (model.api === "openai-responses" && model.thinkingLevelMap === undefined) {
mergeThinking(model, { off: null, minimal: null });
}
},
});

View file

@ -0,0 +1,8 @@
import { defineProvider } from "../define.ts";
import { XIAOMI_COMPLETIONS_COMPAT, xiaomiModels } from "./xiaomi.ts";
export default defineProvider({
id: "xiaomi-token-plan-ams",
models: (ctx) => xiaomiModels(ctx, "https://token-plan-ams.xiaomimimo.com/v1"),
compat: { "openai-completions": XIAOMI_COMPLETIONS_COMPAT },
});

View file

@ -0,0 +1,8 @@
import { defineProvider } from "../define.ts";
import { XIAOMI_COMPLETIONS_COMPAT, xiaomiModels } from "./xiaomi.ts";
export default defineProvider({
id: "xiaomi-token-plan-cn",
models: (ctx) => xiaomiModels(ctx, "https://token-plan-cn.xiaomimimo.com/v1"),
compat: { "openai-completions": XIAOMI_COMPLETIONS_COMPAT },
});

View file

@ -0,0 +1,8 @@
import { defineProvider } from "../define.ts";
import { XIAOMI_COMPLETIONS_COMPAT, xiaomiModels } from "./xiaomi.ts";
export default defineProvider({
id: "xiaomi-token-plan-sgp",
models: (ctx) => xiaomiModels(ctx, "https://token-plan-sgp.xiaomimimo.com/v1"),
compat: { "openai-completions": XIAOMI_COMPLETIONS_COMPAT },
});

View file

@ -0,0 +1,46 @@
import type { Model } from "../../src/types.ts";
import { defineProvider, modelsDevModels, type ProviderContext } from "../define.ts";
/**
* Xiaomi MiMo. The `xiaomi` provider targets the API billing endpoint; the
* `xiaomi-token-plan-*` providers cover prepaid Token Plan endpoints per region.
* MiMo Chat Completions only documents thinking.type enabled/disabled and
* rejects some reasoning_effort values (e.g. "minimal"). Xiaomi documents all
* effort levels as identical, so expose only off and high.
*/
export function xiaomiModels(ctx: ProviderContext, baseUrl: string) {
return modelsDevModels(ctx, ctx.providerId, {
api: "openai-completions",
baseUrl,
skipDeprecated: true,
recordReasoning: false,
compat: {
supportsReasoningEffort: false,
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "deepseek",
},
map: (model): Model<"openai-completions"> =>
model.reasoning
? {
...model,
thinkingLevelMap: {
off: "off",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: null,
},
}
: model,
});
}
export const XIAOMI_COMPLETIONS_COMPAT = { supportsStrictMode: true } as const;
export default defineProvider({
id: "xiaomi",
models: (ctx) => xiaomiModels(ctx, "https://api.xiaomimimo.com/v1"),
compat: { "openai-completions": XIAOMI_COMPLETIONS_COMPAT },
});

View file

@ -0,0 +1,8 @@
import { defineProvider } from "../define.ts";
import { ZAI_COMPLETIONS_COMPAT, zaiCodingPlanModels } from "./zai.ts";
export default defineProvider({
id: "zai-coding-cn",
models: (ctx) => zaiCodingPlanModels(ctx, "zhipuai-coding-plan", "https://open.bigmodel.cn/api/coding/paas/v4"),
compat: { "openai-completions": ZAI_COMPLETIONS_COMPAT },
});

View file

@ -0,0 +1,50 @@
import type { Model } from "../../src/types.ts";
import { defineProvider, modelsDevModels, type ProviderContext } from "../define.ts";
import { getEffortThinkingLevelMap } from "../reasoning.ts";
import { modelsDevChatModels, modelsDevCost } from "../sources/models-dev.ts";
// Tool-call streaming (`tool_stream`) is rejected by the GLM-4.5 generation.
const TOOL_STREAM_UNSUPPORTED_MODEL_IDS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]);
/** The Z.AI coding plans. The international and China plans list the same models. */
export function zaiCodingPlanModels(ctx: ProviderContext, source: string, baseUrl: string) {
// Coding-plan entries carry plan prices; show the pay-as-you-go API rates.
const apiModels = new Map(modelsDevChatModels(ctx.sources.modelsDev, "zai").map((model) => [model.id, model]));
return modelsDevModels(ctx, source, {
api: "openai-completions",
baseUrl,
map: (model, source): Model<"openai-completions"> => {
const thinkingLevelMap = getEffortThinkingLevelMap(source.reasoningOptions ?? []);
if (thinkingLevelMap && (model.id === "glm-5.2" || model.id === "glm-5.2-highspeed")) {
thinkingLevelMap.off = "none";
}
const apiModel = apiModels.get(model.id);
return {
...model,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
cost: modelsDevCost(apiModel?.source.cost ? apiModel.cost : source.cost),
compat: {
supportsDeveloperRole: false,
thinkingFormat: "zai",
...(thinkingLevelMap ? { supportsReasoningEffort: true } : {}),
...(!TOOL_STREAM_UNSUPPORTED_MODEL_IDS.has(model.id) ? { zaiToolStream: true } : {}),
},
};
},
});
}
export const ZAI_COMPLETIONS_COMPAT = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "zai",
supportsStrictMode: true,
} as const;
export default defineProvider({
id: "zai",
models: (ctx) => zaiCodingPlanModels(ctx, "zai-coding-plan", "https://api.z.ai/api/coding/paas/v4"),
compat: { "openai-completions": ZAI_COMPLETIONS_COMPAT },
});

View file

@ -1,12 +1,5 @@
import type { ThinkingLevel, ThinkingLevelMap } from "../src/types.ts";
export type ModelsDevReasoningOption =
| { type: "toggle" }
| {
type: "effort";
values: Array<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "default" | null>;
}
| { type: "budget_tokens"; min?: number; max?: number };
import type { ModelsDevReasoningOption } from "./sources/models-dev.ts";
const THINKING_LEVELS: readonly ThinkingLevel[] = ["minimal", "low", "medium", "high", "xhigh", "max"];

View file

@ -0,0 +1,95 @@
import {
DEFAULT_RADIUS_GATEWAY,
getRadiusModelsFromConfig,
loadRadiusGatewayConfig,
} from "../../src/providers/radius-config.ts";
import type { Model } from "../../src/types.ts";
import {
MODELS_DEV_DECISIONS_URL,
MODELS_DEV_URL,
type ModelsDevDecisionMetadata,
type ModelsDevV2Catalog,
} from "./models-dev.ts";
import { fetchOpenRouterCatalog, type OpenRouterCatalog } from "./openrouter.ts";
import { type AiGatewayCatalog, fetchAiGatewayCatalog } from "./vercel-ai-gateway.ts";
export const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
/** Everything fetched from upstream before the catalog is built. */
export interface CatalogSources {
modelsDev: ModelsDevV2Catalog;
modelsDevDecisions: Record<string, ModelsDevDecisionMetadata>;
openRouter: OpenRouterCatalog;
aiGateway: AiGatewayCatalog;
/** Live NVIDIA NIM ids, keyed by both the raw and the normalized id. */
nvidiaModelIds: Map<string, string>;
radius: Model<"pi-messages">[];
}
async function fetchJson<T>(url: string, label: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`${label} returned ${response.status}`);
return (await response.json()) as T;
}
export function normalizeNvidiaModelId(modelId: string): string {
return modelId.toLowerCase().replaceAll("_", ".");
}
async function fetchNvidiaModelIds(): Promise<Map<string, string>> {
const data = await fetchJson<{ data?: { id: string }[] }>(`${NVIDIA_BASE_URL}/models`, "NVIDIA NIM API");
const modelIds = new Map<string, string>();
for (const model of data.data ?? []) {
modelIds.set(model.id, model.id);
modelIds.set(normalizeNvidiaModelId(model.id), model.id);
}
return modelIds;
}
async function fetchRadiusModels(): Promise<Model<"pi-messages">[]> {
const config = await loadRadiusGatewayConfig(DEFAULT_RADIUS_GATEWAY);
const models = getRadiusModelsFromConfig("radius", config);
if (models.length === 0) throw new Error("Radius API returned no models");
return models;
}
/**
* Fetch every upstream source. In strict mode any failure aborts generation;
* otherwise a failed source contributes nothing.
*/
export async function loadCatalogSources(strict: boolean): Promise<CatalogSources> {
async function load<T>(label: string, fetcher: () => Promise<T>, fallback: T): Promise<T> {
try {
console.log(`Fetching ${label}...`);
return await fetcher();
} catch (error) {
console.error(`Failed to fetch ${label}:`, error);
if (strict) throw error;
return fallback;
}
}
const modelsDev = await load(
"models.dev catalog",
() => fetchJson<ModelsDevV2Catalog>(MODELS_DEV_URL, "models.dev API"),
{},
);
const modelsDevDecisions = await load(
"models.dev decision models",
() => fetchJson<Record<string, ModelsDevDecisionMetadata>>(MODELS_DEV_DECISIONS_URL, "models.dev decision API"),
{},
);
const nvidiaModelIds = modelsDev.nvidia?.models
? await load("NVIDIA NIM models", fetchNvidiaModelIds, new Map<string, string>())
: new Map<string, string>();
const openRouter = await load("OpenRouter models", fetchOpenRouterCatalog, {
chat: [],
images: [],
classifiers: [],
});
if (strict && openRouter.images.length === 0) throw new Error("OpenRouter API returned no usable image models");
const aiGateway = await load("Vercel AI Gateway models", fetchAiGatewayCatalog, { chat: [], classifiers: [] });
const radius = await load("Radius models", fetchRadiusModels, []);
return { modelsDev, modelsDevDecisions, openRouter, aiGateway, nvidiaModelIds, radius };
}

View file

@ -0,0 +1,242 @@
import type { ModelCost } from "../../src/types.ts";
// models.dev v2 catalog. The schema is published as experimental, so everything
// the catalog reads goes through `normalizeModelsDevModel()` below.
// https://github.com/anomalyco/models.dev/blob/dev/packages/core/src/schema-v2.ts
export const MODELS_DEV_URL = "https://models.dev/experimental/v2.0/api.json";
// v2 has no provider-agnostic decision metadata endpoint; v1 still serves it.
export const MODELS_DEV_DECISIONS_URL = "https://models.dev/models.json?type=decision";
export type ModelsDevReasoningOption =
| { type: "toggle" }
| {
type: "effort";
values: Array<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "default" | null>;
}
| { type: "budget_tokens"; min?: number; max?: number };
type ModelsDevEffort = Extract<ModelsDevReasoningOption, { type: "effort" }>["values"][number];
interface V2CachePrices {
read?: number;
write?: number | Record<string, number>;
explicit?: { read?: number; write?: number | Record<string, number> };
}
interface V2CostOverride {
when: Record<string, unknown>;
price?: number;
cache?: V2CachePrices;
}
interface V2ModalityCost {
input?: { unit?: string; price: number; cache?: V2CachePrices; overrides?: V2CostOverride[] };
output?: { unit?: string; price: number; overrides?: V2CostOverride[] };
}
interface V2ApiEntry {
base_url: string;
path?: string;
sdk?: { aisdk?: string };
compatibility?: Record<string, unknown>;
capabilities?: Record<string, unknown>;
}
/** The subset of a models.dev v2 model the catalog reads. */
export interface ModelsDevV2Model {
id: string;
type: string;
name: string;
status?: string;
modalities: { input: string[]; output: string[] };
capabilities: {
tools: { supported: boolean };
reasoning: {
supported: boolean;
toggle?: boolean;
effort?: ModelsDevEffort[];
budget?: { min?: number; max?: number };
};
structured_output?: boolean;
};
limit?: { context?: number; input?: number; output?: number };
cost?: { text?: V2ModalityCost };
api: Record<string, V2ApiEntry>;
}
export type ModelsDevV2Catalog = Record<string, { models?: Record<string, ModelsDevV2Model> }>;
/** Prices as models.dev lists them. Missing prices stay undefined. */
export interface ModelsDevCost {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
/** Context-size tiers. Unlisted rates fall back to the base rates. */
tiers?: Array<{ above: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number }>;
}
/** One API a model is served on, in the order models.dev lists them. */
export interface ModelsDevApi {
protocol: string;
baseUrl: string;
aisdk?: string;
}
/** A models.dev model normalized for the catalog. */
export interface ModelsDevModel {
id: string;
name: string;
type: string;
status?: string;
toolCall: boolean;
structuredOutput: boolean;
reasoning: boolean;
/** Undefined when the model does not reason. */
reasoningOptions?: ModelsDevReasoningOption[];
imageInput: boolean;
textInput: boolean;
textOutput: boolean;
contextWindow?: number;
maxTokens?: number;
cost: ModelsDevCost;
apis: ModelsDevApi[];
/** The full v2 entry, for fields not normalized above. */
source: ModelsDevV2Model;
}
/** Provider-agnostic decision model metadata from models.dev. */
export interface ModelsDevDecisionMetadata {
id: string;
type?: string;
name: string;
limit?: { context?: number };
}
function firstPrice(write: number | Record<string, number> | undefined): number | undefined {
return typeof write === "object" ? Object.values(write)[0] : write;
}
// models.dev lists one cache write price per TTL; the first is the host's default.
function cacheWritePrice(cache: V2CachePrices | undefined): number | undefined {
return firstPrice(cache?.write) ?? firstPrice(cache?.explicit?.write);
}
function contextThreshold(when: Record<string, unknown>): number | undefined {
const keys = Object.keys(when);
if (keys.length !== 1 || keys[0] !== "context") return undefined;
const context = when.context as { gt?: unknown; gte?: unknown };
const size = context.gt ?? context.gte;
return typeof size === "number" ? size : undefined;
}
function normalizeCost(cost: ModelsDevV2Model["cost"]): ModelsDevCost {
const input = cost?.text?.input;
const output = cost?.text?.output;
const perToken = (unit: string | undefined) => unit === undefined || unit === "token";
if (!input || !output || !perToken(input.unit) || !perToken(output.unit)) return {};
// Only context-size overrides map onto pi's cost tiers. Region, service-tier,
// and time-based prices have no pi equivalent yet.
const tiers = new Map<number, NonNullable<ModelsDevCost["tiers"]>[number]>();
const tier = (above: number) => {
let entry = tiers.get(above);
if (!entry) {
entry = { above };
tiers.set(above, entry);
}
return entry;
};
for (const override of input.overrides ?? []) {
const above = contextThreshold(override.when);
if (above === undefined) continue;
const entry = tier(above);
if (override.price !== undefined) entry.input = override.price;
if (override.cache?.read !== undefined) entry.cacheRead = override.cache.read;
const cacheWrite = cacheWritePrice(override.cache);
if (cacheWrite !== undefined) entry.cacheWrite = cacheWrite;
}
for (const override of output.overrides ?? []) {
const above = contextThreshold(override.when);
if (above === undefined) continue;
if (override.price !== undefined) tier(above).output = override.price;
}
return {
input: input.price,
output: output.price,
cacheRead: input.cache?.read,
cacheWrite: cacheWritePrice(input.cache),
...(tiers.size > 0 ? { tiers: [...tiers.values()] } : {}),
};
}
function reasoningOptions(reasoning: ModelsDevV2Model["capabilities"]["reasoning"]): ModelsDevReasoningOption[] {
const options: ModelsDevReasoningOption[] = [];
if (reasoning.toggle) options.push({ type: "toggle" });
if (reasoning.effort !== undefined) options.push({ type: "effort", values: reasoning.effort });
if (reasoning.budget !== undefined) options.push({ type: "budget_tokens", ...reasoning.budget });
return options;
}
export function normalizeModelsDevModel(model: ModelsDevV2Model): ModelsDevModel {
const input = model.modalities?.input ?? [];
const reasoning = model.capabilities?.reasoning;
return {
id: model.id,
name: model.name,
type: model.type,
status: model.status,
toolCall: model.capabilities?.tools?.supported === true,
structuredOutput: model.capabilities?.structured_output === true,
reasoning: reasoning?.supported === true,
reasoningOptions: reasoning?.supported ? reasoningOptions(reasoning) : undefined,
imageInput: input.some((modality) => modality === "image" || modality.startsWith("image/")),
textInput: input.includes("text"),
textOutput: (model.modalities?.output ?? []).includes("text"),
contextWindow: model.limit?.context,
maxTokens: model.limit?.output,
cost: normalizeCost(model.cost),
apis: Object.entries(model.api ?? {}).map(([protocol, entry]) => ({
protocol,
baseUrl: entry.base_url,
aisdk: entry.sdk?.aisdk,
})),
source: model,
};
}
export function roundCost(value: number): number {
return Number(value.toFixed(6));
}
/** Converts models.dev prices to pi's cost shape. Missing rates are free. */
export function modelsDevCost(cost: ModelsDevCost): ModelCost {
const base = {
input: cost.input || 0,
output: cost.output || 0,
cacheRead: cost.cacheRead || 0,
cacheWrite: cost.cacheWrite || 0,
};
const tiers = cost.tiers?.map((tier) => ({
inputTokensAbove: tier.above,
input: tier.input ?? base.input,
output: tier.output ?? base.output,
cacheRead: tier.cacheRead ?? base.cacheRead,
cacheWrite: tier.cacheWrite ?? base.cacheWrite,
}));
return tiers && tiers.length > 0 ? { ...base, tiers } : base;
}
/**
* Chat models of one models.dev provider. Specialized types (image, speech,
* realtime, decision, ...) only appear in v2 and are left out here.
*/
export function modelsDevChatModels(catalog: ModelsDevV2Catalog, provider: string): ModelsDevModel[] {
return (
Object.values(catalog[provider]?.models ?? {})
// Unlike pi models, models.dev v2 always states the type.
.filter(({ type }) => type === "chat")
.map(normalizeModelsDevModel)
);
}

View file

@ -1,6 +1,29 @@
import { type OpenRouterModel, openRouterCost } from "../src/providers/openrouter-api.ts";
import type { ClassifierModel, ImageModel, Model } from "../src/types.ts";
import { getOpenRouterThinkingLevelMap, type OpenRouterReasoningMetadata } from "./openrouter-reasoning-options.ts";
import { type OpenRouterModel, openRouterCost } from "../../src/providers/openrouter-api.ts";
import type { ClassifierModel, ImageModel, Model, ThinkingLevel, ThinkingLevelMap } from "../../src/types.ts";
import { getEffortThinkingLevelMap } from "../reasoning.ts";
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
export interface OpenRouterReasoningMetadata {
mandatory?: boolean;
default_enabled?: boolean;
supported_efforts?: Array<ThinkingLevel | "none">;
default_effort?: ThinkingLevel | "none";
}
/** Convert OpenRouter's reasoning metadata into Pi model capabilities. */
export function getOpenRouterThinkingLevelMap(
reasoning: OpenRouterReasoningMetadata | undefined,
): ThinkingLevelMap | undefined {
if (!reasoning) return undefined;
if (!reasoning.supported_efforts?.length) return reasoning.mandatory === true ? { off: null } : undefined;
// OpenRouter's supported_efforts uses the same effort values as models.dev reasoning_options,
// so both sources can share the same Pi thinking-level conversion.
const map = getEffortThinkingLevelMap([{ type: "effort", values: reasoning.supported_efforts }]);
if (!map) return reasoning.mandatory === true ? { off: null } : undefined;
return { ...map, off: reasoning.mandatory === true ? null : "none" };
}
export interface OpenRouterModelListItem extends OpenRouterModel {
name: string;
@ -45,7 +68,8 @@ export function buildOpenRouterCatalog(
}
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);
const useAnthropicMessages = /^anthropic\//.test(model.id) && !model.id.endsWith(":batch");
// Includes the `~anthropic/claude-*-latest` aliases, which OpenRouter's Messages endpoint accepts.
const useAnthropicMessages = /^~?anthropic\//.test(model.id) && !model.id.endsWith(":batch");
chat.push({
type: "chat",
id: model.id,
@ -105,3 +129,20 @@ export function buildOpenRouterCatalog(
return { chat, images, classifiers };
}
async function fetchOpenRouterList(query: string): Promise<OpenRouterModelListItem[]> {
const response = await fetch(`${OPENROUTER_MODELS_URL}${query}`);
if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`);
const data = (await response.json()) as { data?: OpenRouterModelListItem[] };
return data.data ?? [];
}
/** Fetch the tool-capable chat, image, and decision listings. */
export async function fetchOpenRouterCatalog(): Promise<OpenRouterCatalog> {
const [listed, imageListed, decisionListed] = await Promise.all([
fetchOpenRouterList(""),
fetchOpenRouterList("?output_modalities=image"),
fetchOpenRouterList("?output_modalities=decisions"),
]);
return buildOpenRouterCatalog(listed, imageListed, decisionListed);
}

View file

@ -0,0 +1,140 @@
import type { ClassifierModel, Model, ModelCost, ModelCostRates, ModelCostTier } from "../../src/types.ts";
const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1/models";
const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh";
// TypeSafe-compatible System One endpoint for evaluation models.
// https://vercel.com/docs/ai-gateway/sdks-and-apis/typesafe
const AI_GATEWAY_TYPESAFE_BASE_URL = "https://ai-gateway.vercel.sh/typesafe/v1";
/** A prompt-length bracket. `min` is inclusive and `max` exclusive, both in prompt tokens. */
export interface AiGatewayPriceTier {
cost?: string | number;
min?: number;
max?: number;
}
/** Vercel AI Gateway pricing in $/token. */
export interface AiGatewayPricing {
input?: string | number;
output?: string | number;
input_cache_read?: string | number;
input_cache_write?: string | number;
input_tiers?: AiGatewayPriceTier[];
output_tiers?: AiGatewayPriceTier[];
input_cache_read_tiers?: AiGatewayPriceTier[];
input_cache_write_tiers?: AiGatewayPriceTier[];
}
const RATE_FIELDS = [
{ rate: "input", tiers: "input_tiers" },
{ rate: "output", tiers: "output_tiers" },
{ rate: "cacheRead", tiers: "input_cache_read_tiers" },
{ rate: "cacheWrite", tiers: "input_cache_write_tiers" },
] as const;
function perMillion(value: string | number | undefined): number {
const parsed = typeof value === "number" ? value : parseFloat(value ?? "0");
return Number.isFinite(parsed) ? Number((parsed * 1_000_000).toFixed(6)) : 0;
}
/**
* Convert AI Gateway pricing to $/million tokens. Each rate lists its own prompt-length brackets;
* every bracket start above zero becomes a request-wide tier with the rates in effect there.
*/
export function getAiGatewayCost(pricing: AiGatewayPricing | undefined): ModelCost {
const base: ModelCostRates = {
input: perMillion(pricing?.input),
output: perMillion(pricing?.output),
cacheRead: perMillion(pricing?.input_cache_read),
cacheWrite: perMillion(pricing?.input_cache_write),
};
const starts = new Set<number>();
for (const field of RATE_FIELDS) {
for (const bracket of pricing?.[field.tiers] ?? []) {
if (bracket.min !== undefined && bracket.min > 0) starts.add(bracket.min);
}
}
const tiers = [...starts]
.sort((a, b) => a - b)
.map((start): ModelCostTier => {
const tier: ModelCostTier = { inputTokensAbove: start - 1, ...base };
for (const field of RATE_FIELDS) {
const bracket = pricing?.[field.tiers]?.find(
(candidate) => (candidate.min ?? 0) <= start && (candidate.max === undefined || start < candidate.max),
);
if (bracket?.cost !== undefined) tier[field.rate] = perMillion(bracket.cost);
}
return tier;
});
return tiers.length > 0 ? { ...base, tiers } : base;
}
interface AiGatewayModel {
id: string;
name?: string;
type?: string;
context_window?: number;
max_tokens?: number;
tags?: string[];
pricing?: AiGatewayPricing;
}
export interface AiGatewayCatalog {
chat: Model<"anthropic-messages">[];
classifiers: ClassifierModel<"typesafe-system-one">[];
}
/** Fetch tool-capable chat models and evaluation (classifier) models. */
export async function fetchAiGatewayCatalog(): Promise<AiGatewayCatalog> {
const response = await fetch(AI_GATEWAY_MODELS_URL);
if (!response.ok) throw new Error(`Vercel AI Gateway API returned ${response.status}`);
const data = (await response.json()) as { data?: unknown };
const items = Array.isArray(data.data) ? (data.data as AiGatewayModel[]) : [];
const chat: AiGatewayCatalog["chat"] = [];
const classifiers: AiGatewayCatalog["classifiers"] = [];
for (const model of items) {
// Evaluation models such as TypeSafe's Jev are served through the
// TypeSafe-compatible System One endpoint.
if (model.type === "evaluation") {
classifiers.push({
type: "classifier",
id: model.id,
name: model.name || model.id,
api: "typesafe-system-one",
provider: "vercel-ai-gateway",
baseUrl: AI_GATEWAY_TYPESAFE_BASE_URL,
input: ["text"],
cost: {
input: perMillion(model.pricing?.input),
output: perMillion(model.pricing?.output),
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: model.context_window || 4096,
});
continue;
}
const tags = Array.isArray(model.tags) ? model.tags : [];
// Only include models that support tools
if (!tags.includes("tool-use")) continue;
chat.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: getAiGatewayCost(model.pricing),
contextWindow: model.context_window || 4096,
maxTokens: model.max_tokens || 4096,
});
}
return { chat, classifiers };
}

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-ai",
"version": "1.4.0",
"version": "1.5.0",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",
@ -56,27 +56,27 @@
"README.md"
],
"scripts": {
"clean": "shx rm -rf dist",
"generate-models": "node scripts/generate-models.ts --strict",
"hydrate-model-data": "node scripts/generate-models.ts --strict --data-only",
"generate-model-catalog": "node scripts/generate-models.ts --strict --json-only --json-output ../../.artifacts/model-catalog",
"clean": "node ../../scripts/package-files.mjs clean dist",
"generate-models": "node catalog/generate.ts --strict",
"hydrate-model-data": "node catalog/generate.ts --strict --data-only",
"generate-model-catalog": "node catalog/generate.ts --strict --json-only --json-output ../../.artifacts/model-catalog",
"check:model-data": "node scripts/check-model-data.ts",
"build": "npm run generate-models && npm run build:offline",
"build:offline": "npm run check:model-data && tsc -p tsconfig.build.json && shx rm -rf dist/providers/data && shx cp -r src/providers/data dist/providers/data",
"build:offline": "npm run check:model-data && tsc -p tsconfig.build.json && node ../../scripts/package-files.mjs clean dist/providers/data && node ../../scripts/package-files.mjs copy src/providers/data dist/providers",
"test": "vitest --run",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@anthropic-ai/sdk": "0.129.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-telemetry": "1.4.0",
"@earendil-works/pi-schema": "1.5.0",
"@earendil-works/pi-telemetry": "1.5.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",
"https-proxy-agent": "9.1.0",
"openai": "7.19.0",
"partial-json": "0.1.7",
"typebox": "1.3.27"
"partial-json": "0.1.7"
},
"keywords": [
"ai",
@ -101,6 +101,7 @@
"devDependencies": {
"@types/node": "22.19.19",
"canvas": "3.2.3",
"typebox": "1.3.27",
"vitest": "4.1.12"
}
}

View file

@ -1,67 +0,0 @@
import type { ModelCost, ModelCostRates, ModelCostTier } from "../src/types.ts";
/** A prompt-length bracket. `min` is inclusive and `max` exclusive, both in prompt tokens. */
export interface AiGatewayPriceTier {
cost?: string | number;
min?: number;
max?: number;
}
/** Vercel AI Gateway pricing in $/token. */
export interface AiGatewayPricing {
input?: string | number;
output?: string | number;
input_cache_read?: string | number;
input_cache_write?: string | number;
input_tiers?: AiGatewayPriceTier[];
output_tiers?: AiGatewayPriceTier[];
input_cache_read_tiers?: AiGatewayPriceTier[];
input_cache_write_tiers?: AiGatewayPriceTier[];
}
const RATE_FIELDS = [
{ rate: "input", tiers: "input_tiers" },
{ rate: "output", tiers: "output_tiers" },
{ rate: "cacheRead", tiers: "input_cache_read_tiers" },
{ rate: "cacheWrite", tiers: "input_cache_write_tiers" },
] as const;
function perMillion(value: string | number | undefined): number {
const parsed = typeof value === "number" ? value : parseFloat(value ?? "0");
return Number.isFinite(parsed) ? Number((parsed * 1_000_000).toFixed(6)) : 0;
}
/**
* Convert AI Gateway pricing to $/million tokens. Each rate lists its own prompt-length brackets;
* every bracket start above zero becomes a request-wide tier with the rates in effect there.
*/
export function getAiGatewayCost(pricing: AiGatewayPricing | undefined): ModelCost {
const base: ModelCostRates = {
input: perMillion(pricing?.input),
output: perMillion(pricing?.output),
cacheRead: perMillion(pricing?.input_cache_read),
cacheWrite: perMillion(pricing?.input_cache_write),
};
const starts = new Set<number>();
for (const field of RATE_FIELDS) {
for (const bracket of pricing?.[field.tiers] ?? []) {
if (bracket.min !== undefined && bracket.min > 0) starts.add(bracket.min);
}
}
const tiers = [...starts]
.sort((a, b) => a - b)
.map((start): ModelCostTier => {
const tier: ModelCostTier = { inputTokensAbove: start - 1, ...base };
for (const field of RATE_FIELDS) {
const bracket = pricing?.[field.tiers]?.find(
(candidate) => (candidate.min ?? 0) <= start && (candidate.max === undefined || start < candidate.max),
);
if (bracket?.cost !== undefined) tier[field.rate] = perMillion(bracket.cost);
}
return tier;
});
return tiers.length > 0 ? { ...base, tiers } : base;
}

File diff suppressed because it is too large Load diff

View file

@ -1,23 +0,0 @@
import type { ThinkingLevel, ThinkingLevelMap } from "../src/types.ts";
import { getEffortThinkingLevelMap } from "./models-dev-reasoning-options.ts";
export interface OpenRouterReasoningMetadata {
mandatory?: boolean;
default_enabled?: boolean;
supported_efforts?: Array<ThinkingLevel | "none">;
default_effort?: ThinkingLevel | "none";
}
/** Convert OpenRouter's reasoning metadata into Pi model capabilities. */
export function getOpenRouterThinkingLevelMap(
reasoning: OpenRouterReasoningMetadata | undefined,
): ThinkingLevelMap | undefined {
if (!reasoning) return undefined;
if (!reasoning.supported_efforts?.length) return reasoning.mandatory === true ? { off: null } : undefined;
// OpenRouter's supported_efforts uses the same effort values as models.dev reasoning_options,
// so both sources can share the same Pi thinking-level conversion.
const map = getEffortThinkingLevelMap([{ type: "effort", values: reasoning.supported_efforts }]);
if (!map) return reasoning.mandatory === true ? { off: null } : undefined;
return { ...map, off: reasoning.mandatory === true ? null : "none" };
}

View file

@ -59,6 +59,8 @@ import {
import {
getJsonSchemaToolParameters,
isStrictToolLimitErrorMessage,
prefersJsonSchemaStrictSampling,
resolveJsonSchemaStrictSampling,
type UnsupportedStrictSchemaKeywordCheck,
} from "./constrained-sampling.ts";
@ -674,11 +676,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
!(error instanceof Anthropic.APIError) ||
error.status !== 400 ||
error.type !== "invalid_request_error" ||
![
"The compiled grammar is too large",
"Too many strict tools (",
"Schemas contains too many parameters with union types (",
].some((message) => error.message.includes(message))
!isStrictToolLimitErrorMessage(error.message)
) {
throw error;
}
@ -1185,10 +1183,9 @@ function buildParams(
disablePreferredStrictTools,
);
if (
definitions.some((definition, index) => {
const config = tools[index].constrainedSampling;
return definition.strict === true && config && config.type === "json_schema" && config.strict === "prefer";
})
definitions.some(
(definition, index) => definition.strict === true && prefersJsonSchemaStrictSampling(tools[index]),
)
) {
hasPreferredStrictTools = true;
}
@ -1630,17 +1627,25 @@ function convertTools(
if (!tools) return [];
return tools.map((tool, index) => {
const config = tool.constrainedSampling;
const strict =
disablePreferredStrictTools && config && config.type === "json_schema" && config.strict === "prefer"
disablePreferredStrictTools && prefersJsonSchemaStrictSampling(tool)
? undefined
: resolveJsonSchemaStrictSampling(tool, supportsStrictTools, isAnthropicStrictUnsupportedKeyword);
const parameters = getJsonSchemaToolParameters(tool, strict);
const schema = parameters as { properties?: unknown; required?: string[] };
const schema = parameters as {
properties?: unknown;
required?: string[];
$defs?: unknown;
definitions?: unknown;
};
// Keep shared definitions so property $refs still resolve. Other root keywords stay out:
// Anthropic rejects root anyOf/oneOf/allOf with a 400.
const legacyInputSchema = {
type: "object" as const,
properties: schema.properties ?? {},
required: schema.required ?? [],
...(schema.$defs === undefined ? {} : { $defs: schema.$defs }),
...(schema.definitions === undefined ? {} : { definitions: schema.definitions }),
};
const inputSchema =
strict === true

View file

@ -13,6 +13,7 @@ import {
type ContentBlockStopEvent,
ConversationRole,
ConverseStreamCommand,
type ConverseStreamCommandOutput,
type ConverseStreamMetadataEvent,
ImageFormat,
type Message,
@ -64,7 +65,12 @@ import {
type TranscriptContext,
withoutInitialSystemMessage,
} from "../utils/transcript.ts";
import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
import {
getJsonSchemaToolParameters,
isStrictToolLimitErrorMessage,
prefersJsonSchemaStrictSampling,
resolveJsonSchemaStrictSampling,
} from "./constrained-sampling.ts";
import {
adjustMaxTokensForThinking,
buildBaseOptions,
@ -269,29 +275,55 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> =
const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined);
const initialSystemMessage = getInitialSystemMessage(normalizedContext.messages);
const initialSystemPrompt = initialSystemMessage ? getSystemMessageText(initialSystemMessage) : undefined;
let commandInput = {
modelId: model.id,
messages: convertMessages(normalizedContext, model, cacheRetention, options.env),
system: buildSystemPrompt(initialSystemPrompt, model, cacheRetention, options.env),
inferenceConfig: {
...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }),
...(options.temperature !== undefined && { temperature: options.temperature }),
},
toolConfig: convertToolConfig(
let disablePreferredStrictTools = false;
let response: ConverseStreamCommandOutput;
for (;;) {
const tools = convertToolConfig(
getCurrentTools(normalizedContext.messages),
options.toolChoice,
supportsStrictMode,
),
additionalModelRequestFields: buildAdditionalModelRequestFields(model, options),
...(options.requestMetadata !== undefined && { requestMetadata: options.requestMetadata }),
};
const nextCommandInput = await options?.onPayload?.(commandInput, model);
if (nextCommandInput !== undefined) {
commandInput = nextCommandInput as typeof commandInput;
disablePreferredStrictTools,
);
let commandInput = {
modelId: model.id,
messages: convertMessages(normalizedContext, model, cacheRetention, options.env),
system: buildSystemPrompt(initialSystemPrompt, model, cacheRetention, options.env),
inferenceConfig: {
...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }),
...(options.temperature !== undefined && { temperature: options.temperature }),
},
toolConfig: tools.toolConfig,
additionalModelRequestFields: buildAdditionalModelRequestFields(model, options),
...(options.requestMetadata !== undefined && { requestMetadata: options.requestMetadata }),
};
const nextCommandInput = await options?.onPayload?.(commandInput, model);
if (nextCommandInput !== undefined) {
commandInput = nextCommandInput as typeof commandInput;
}
try {
response = await client.send(new ConverseStreamCommand(commandInput), { abortSignal: options.signal });
break;
} catch (error) {
// Bedrock checks the tool schemas before it starts the stream, so a retry never repeats streamed output.
if (
options.signal?.aborted ||
disablePreferredStrictTools ||
!tools.hasPreferredStrictTools ||
!(error instanceof BedrockRuntimeServiceException) ||
error.name !== "ValidationException" ||
!isStrictToolLimitErrorMessage(error.message)
) {
throw error;
}
disablePreferredStrictTools = true;
appendAssistantMessageDiagnostic(output, {
type: "bedrock_strict_tools_fallback",
timestamp: Date.now(),
error: { message: error.message },
details: { reason: "strict_tool_limits_exceeded" },
});
}
}
const command = new ConverseStreamCommand(commandInput);
const response = await client.send(command, { abortSignal: options.signal });
responseRequestId = normalizeDiagnosticValue(response.$metadata.requestId);
if (!observedRawResponse && response.$metadata.httpStatusCode !== undefined) {
const responseHeaders: Record<string, string> = {};
@ -1155,12 +1187,18 @@ function convertToolConfig(
tools: Tool[] | undefined,
toolChoice: BedrockOptions["toolChoice"],
supportsStrictMode: boolean,
): ToolConfiguration | undefined {
if (!tools?.length) return undefined;
if (toolChoice === "none") return undefined;
disablePreferredStrictTools: boolean,
): { toolConfig: ToolConfiguration | undefined; hasPreferredStrictTools: boolean } {
if (!tools?.length || toolChoice === "none") return { toolConfig: undefined, hasPreferredStrictTools: false };
let hasPreferredStrictTools = false;
const bedrockTools: BedrockTool[] = tools.map((tool) => {
const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode);
const preferred = prefersJsonSchemaStrictSampling(tool);
const strict =
disablePreferredStrictTools && preferred
? undefined
: resolveJsonSchemaStrictSampling(tool, supportsStrictMode);
if (strict === true && preferred) hasPreferredStrictTools = true;
return {
toolSpec: {
name: tool.name,
@ -1185,7 +1223,7 @@ function convertToolConfig(
}
}
return { tools: bedrockTools, toolChoice: bedrockToolChoice };
return { toolConfig: { tools: bedrockTools, toolChoice: bedrockToolChoice }, hasPreferredStrictTools };
}
function mapStopReason(reason: string | undefined): { stopReason: StopReason; errorMessage?: string } {

View file

@ -139,6 +139,27 @@ export function makeStrictJsonSchema(
return cloned;
}
/** Returns true for a tool that asks for strict JSON-schema sampling only when possible (`strict: "prefer"`). */
export function prefersJsonSchemaStrictSampling(tool: Tool): boolean {
const config = tool.constrainedSampling;
return !!config && config.type === "json_schema" && config.strict === "prefer";
}
/**
* Claude rejects (on the Anthropic API and on Bedrock) a request whose strict tools exceed its limits with one of these
* messages. Observed limits: 20 strict tools, and 16 optional or union-typed parameters across all strict tools.
*/
const STRICT_TOOL_LIMIT_ERROR_MESSAGES = [
"The compiled grammar is too large",
"Too many strict tools (",
"Schemas contains too many parameters with union types (",
];
/** Returns true when a provider error message says the request's strict tools exceed the provider's limits. */
export function isStrictToolLimitErrorMessage(message: string): boolean {
return STRICT_TOOL_LIMIT_ERROR_MESSAGES.some((limitMessage) => message.includes(limitMessage));
}
export function getJsonSchemaToolParameters(tool: Tool, strict: boolean | undefined): Tool["parameters"] {
return (strict === true ? makeStrictJsonSchema(tool.parameters) : tool.parameters) as Tool["parameters"];
}

View file

@ -195,6 +195,15 @@ function createEventConverter(model: Model<"pi-messages">) {
const toolArguments = new Map<number, ToolCallArgumentsStream>();
return (event: PiMessagesEvent): AssistantMessageEvent => {
if ("contentIndex" in event) {
// The index comes from the wire and keys the content array: `__proto__` would reach Array.prototype, and a huge
// index would make a sparse array of billions of holes. A start adds the next block; other events need one.
const index: unknown = event.contentIndex;
const limit = event.type.endsWith("_start") ? partial.content.length : partial.content.length - 1;
if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index > limit) {
throw new Error(`Invalid contentIndex ${JSON.stringify(index)} in pi-messages ${event.type} event`);
}
}
switch (event.type) {
case "done":
Object.assign(partial, {

View file

@ -6,6 +6,7 @@ import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts"
import { sleep } from "../../utils/sleep.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { trustedHttpUrl } from "./trusted-url.ts";
const decode = (s: string) => atob(s);
const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=");
@ -239,22 +240,15 @@ async function startDeviceFlow(domain: string, signal: AbortSignal): Promise<Dev
throw new Error("Invalid device code response fields");
}
// The verification URI is opened in the user's browser and to prevent `open` from
// opening an executable or similar, we force it to be a URL.
let parsedUri: URL;
try {
parsedUri = new URL(verificationUri);
} catch {
throw new Error("Untrusted verification_uri in device code response");
}
if (parsedUri.protocol !== "https:" && parsedUri.protocol !== "http:") {
const trustedVerificationUri = trustedHttpUrl(verificationUri);
if (!trustedVerificationUri) {
throw new Error("Untrusted verification_uri in device code response");
}
return {
device_code: deviceCode,
user_code: userCode,
verification_uri: parsedUri.href,
verification_uri: trustedVerificationUri,
interval,
expires_in: expiresIn,
};

View file

@ -10,6 +10,7 @@ import { getProviderEnvValue } from "../../utils/provider-env.ts";
import { sleep } from "../../utils/sleep.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { trustedHttpUrl } from "./trusted-url.ts";
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
@ -55,18 +56,6 @@ async function readJson(response: Response): Promise<Record<string, unknown> | n
}
}
/** The verification URI is opened in the user's browser; only http(s) URLs are trusted. */
function trustedHttpUrl(value: unknown): string | null {
if (typeof value !== "string" || !value) return null;
try {
const url = new URL(value);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
return url.href;
} catch {
return null;
}
}
async function startDeviceAuthorization(oauthHost: string, signal: AbortSignal): Promise<DeviceAuthorization> {
const response = await fetch(`${oauthHost}/api/oauth/device_authorization`, {
method: "POST",
@ -86,16 +75,9 @@ async function startDeviceAuthorization(oauthHost: string, signal: AbortSignal):
const json = await readJson(response);
const deviceCode = json?.device_code;
const userCode = json?.user_code;
const verificationUri = json?.verification_uri;
const verificationUriComplete = json?.verification_uri_complete;
if (
typeof deviceCode !== "string" ||
typeof userCode !== "string" ||
typeof verificationUri !== "string" ||
typeof verificationUriComplete !== "string" ||
!trustedHttpUrl(verificationUriComplete) ||
!trustedHttpUrl(verificationUri)
) {
const verificationUri = trustedHttpUrl(json?.verification_uri);
const verificationUriComplete = trustedHttpUrl(json?.verification_uri_complete);
if (typeof deviceCode !== "string" || typeof userCode !== "string" || !verificationUri || !verificationUriComplete) {
throw new Error(`Invalid Kimi Code device authorization response: ${JSON.stringify(json)}`);
}

View file

@ -15,6 +15,7 @@
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { trustedHttpUrl } from "./trusted-url.ts";
// Muse Code CLI client id.
const CLIENT_ID = "1031625952748946";
@ -54,18 +55,6 @@ function errorDetail(json: Record<string, unknown> | null): string {
return "";
}
/** The verification URI is opened in the user's browser; only http(s) URLs are trusted. */
function trustedHttpUrl(value: unknown): string | null {
if (typeof value !== "string" || !value) return null;
try {
const url = new URL(value);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
return url.href;
} catch {
return null;
}
}
function positiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}

View file

@ -14,6 +14,7 @@ import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../typ
import { startOAuthCallbackServer } from "./callback-server.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { generatePKCE } from "./pkce.ts";
import { trustedHttpUrl } from "./trusted-url.ts";
const CALLBACK_HOST = "127.0.0.1";
const CALLBACK_PORT = 1456;
@ -51,10 +52,11 @@ async function loadRadiusOAuthDiscovery(gateway: string, signal: AbortSignal): P
}
const discovery = (await response.json()) as Partial<RadiusOAuthDiscovery>;
if (typeof discovery.authorizationEndpoint !== "string") {
const authorizationEndpoint = trustedHttpUrl(discovery.authorizationEndpoint);
if (!authorizationEndpoint) {
throw new Error(`Invalid Radius OAuth config from ${gateway}`);
}
return { authorizationEndpoint: discovery.authorizationEndpoint };
return { authorizationEndpoint };
}
class OAuthResponseError extends Error {
@ -210,11 +212,15 @@ async function requestDeviceAuthorization(gateway: string, signal: AbortSignal):
if (!data.device_code || !data.user_code || !data.verification_uri || !data.expires_in) {
throw new Error("Radius OAuth device authorization response is missing required fields");
}
const verificationUri = trustedHttpUrl(data.verification_uri);
if (!verificationUri) {
throw new Error("Untrusted verification_uri in Radius OAuth device authorization response");
}
return {
device_code: data.device_code,
user_code: data.user_code,
verification_uri: data.verification_uri,
verification_uri: verificationUri,
expires_in: data.expires_in,
interval: data.interval,
};

View file

@ -0,0 +1,10 @@
/**
* The `http:` or `https:` URL `value` names, normalized, or null. Login flows open server-supplied URLs with the
* platform URL handler, which would also launch a `file:` URL or a custom protocol handler. Normalizing
* percent-encodes control characters, so the URL can also be embedded in terminal hyperlinks.
*/
export function trustedHttpUrl(value: unknown): string | null {
if (typeof value !== "string" || !URL.canParse(value)) return null;
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:" ? url.href : null;
}

View file

@ -1,7 +1,7 @@
#!/usr/bin/env node
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
import type { AuthPrompt, OAuthCredential, Provider } from "./index.ts";
import { builtinProviders } from "./providers/all.ts";
@ -26,7 +26,10 @@ function loadAuth(): Record<string, OAuthCredential> {
}
function saveAuth(auth: Record<string, OAuthCredential>): void {
writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), "utf-8");
// OAuth tokens: readable by the user only. `mode` applies when the file is created; chmod covers a file an older
// version created, before the new tokens are in it.
if (existsSync(AUTH_FILE)) chmodSync(AUTH_FILE, 0o600);
writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), { encoding: "utf-8", mode: 0o600 });
}
async function answerPrompt(rl: ReturnType<typeof createInterface>, authPrompt: AuthPrompt): Promise<string> {

View file

@ -1,6 +1,3 @@
export type { Static, TSchema } from "typebox";
export { Type } from "typebox";
// Core only, side-effect free: no generated catalogs, no provider factories,
// no api-registry, no OAuth implementations, no compat. Provider factories
// live under "@earendil-works/pi-ai/providers/*", API implementations under
@ -42,9 +39,9 @@ export * from "./utils/json-parse.ts";
export * from "./utils/overflow.ts";
export * from "./utils/retry.ts";
export * from "./utils/streaming-json.ts";
export * from "./utils/string-enum.ts";
export { contentText, getSystemMessageText, renderSystemMessageUpdate } from "./utils/text.ts";
export * from "./utils/transcript.ts";
export * from "./utils/typebox-helpers.ts";
export { uuidv7 } from "./utils/uuid.ts";
export * from "./utils/validation.ts";
export { PI_AI_VERSION } from "./utils/version.ts";

View file

@ -1,4 +1,4 @@
// This file is auto-generated by scripts/generate-models.ts
// This file is auto-generated by catalog/generate.ts
// Do not edit manually - run 'npm run generate-models' to update
import { AMAZON_BEDROCK_CLASSIFIER_MODELS, AMAZON_BEDROCK_IMAGE_MODELS, AMAZON_BEDROCK_MODELS } from "./providers/amazon-bedrock.models.ts";

View file

@ -1,4 +1,4 @@
// This file is auto-generated by scripts/generate-models.ts
// This file is auto-generated by catalog/generate.ts
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/amazon-bedrock.json" with { type: "json" };

View file

@ -1,4 +1,4 @@
// This file is auto-generated by scripts/generate-models.ts
// This file is auto-generated by catalog/generate.ts
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/ant-ling.json" with { type: "json" };

View file

@ -1,4 +1,4 @@
// This file is auto-generated by scripts/generate-models.ts
// This file is auto-generated by catalog/generate.ts
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/anthropic.json" with { type: "json" };

View file

@ -1,4 +1,4 @@
// This file is auto-generated by scripts/generate-models.ts
// This file is auto-generated by catalog/generate.ts
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/azure.json" with { type: "json" };

Some files were not shown because too many files have changed in this diff Show more