feat(ai,coding-agent): unify image and classifier model infrastructure (#9948)

This commit is contained in:
Armin Ronacher 2026-09-23 17:41:19 +02:00 • committed by GitHub
parent 667fc3dd36
commit a328aa89ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 4032 additions and 2186 deletions

View file

@ -37,5 +37,6 @@
"!packages/mom/data/**/*",
"!!**/node_modules"
]
}
},
"plugins": ["./scripts/biome/model-type-comparison.grit"]
}

View file

@ -27,7 +27,7 @@
"check:install-lock:coding-agent": "node scripts/generate-coding-agent-install-lock.mjs --check",
"check:ts-imports": "node scripts/check-ts-relative-imports.mjs",
"check:entry-graphs": "node scripts/check-entry-graphs.mjs",
"generate:models": "npm --prefix packages/ai run generate-models && npm --prefix packages/ai run generate-image-models",
"generate:models": "npm --prefix packages/ai run generate-models",
"hydrate:model-data": "npm --prefix packages/ai run hydrate-model-data",
"check:model-data": "npm --prefix packages/ai run check:model-data",
"generate:model-catalog": "npm --prefix packages/ai run generate-model-catalog",

View file

@ -2,8 +2,21 @@
## [Unreleased]
### Breaking Changes
- Unified image models into the regular `Provider`/`Models` surface. The separate `ImagesModels` collection is removed: `createImagesModels()`, `createImagesProvider()`, `ImagesProvider`, `openrouterImagesProvider()`, `builtinImagesProviders()`, and `builtinImagesModels()` are gone. Use `builtinModels()`, `models.getModelOfType("image", ...)`, `models.generateImages()`, and `createProvider({ models, images })` instead. Existing unqualified reads remain chat-only.
- Image models are now `ImageModel` with a required `type: "image"` and share `BaseModel` with chat models. The old plural image type names (`ImagesModel`, `ImagesApi`, `KnownImagesApi`, `KnownImagesProvider`, and `ImagesProviderId`) are removed. `generateImages()` accepts only image models. Output modalities (`output`) remain on image models only.
- Generated model data schema is now version 6: every entry carries `type`, operation-specific catalogs include chat, image, and classifier models, and one upstream ID may have separate entries per type. OpenRouter image models live in the `openrouter-images` api group of `openrouter.json`; `image-models.generated.ts` and `scripts/generate-image-models.ts` are removed. Run `npm run hydrate:model-data`.
### Added
- Added `Models.generateImages()` with provider-resolved auth, `Provider.generateImages?`, and `createProvider({ images })` keyed by `model.api`. `createProvider()` `models` and `fetchModels` accept models of every type, and `api` is optional when `images` or `classifiers` is given.
- Added an optional model `type` (`"chat"`, `"image"`, or `"classifier"`). Chat models may omit it, so existing chat models, providers, and stores keep working unchanged. Narrow mixed lists with the new `isModelType()` guard or read the effective type with `getModelType()`.
- Added `getModelsOfType()`, `getModelOfType()`, `getAvailableOfType()`, `getAllModels()`, and `getAllAvailable()` on `Models`; optional `Provider.getAllModels()` and `Provider.filterAllModels()`; corresponding generated-catalog accessors; and the `AnyModel` and `ModelTypeMap` types. `hasApi()`, `calculateCost()`, and `modelsAreEqual()` accept `AnyModel`.
- Added support for models of every type in `ModelsStoreEntry.models`. Stored and fetched models of unknown types are dropped instead of failing a refresh.
- Added classifier models and `Models.classify()` with a provider-neutral JEV-style `choice`/`score`/`bool` contract. The built-in TypeSafe provider exposes models.dev's `jev-latest` through the System One API and translates public `bool` questions to TypeSafe's `noul` wire format.
- Added a runtime chat-model check to the `Models` stream entry points so non-chat models fail with a clear `ModelsError` instead of a missing-api stream error.
- Added array-based `models.all.json` and `providers/{id}.all.json` variants to the generated and published JSON catalog, allowing the same upstream ID once per model type; the existing keyed `models.json` and `providers/{id}.json` stay chat-only for released clients.
- Added `onProviderStreamEvent` to observe parsed provider stream events before normalization, including provider-specific fields not retained in assistant messages ([#9784](https://github.com/earendil-works/pi/issues/9784)).
### Fixed

View file

@ -2,7 +2,7 @@
Unified LLM API with provider collections, automatic auth resolution, token and cost tracking, and simple context persistence and hand-off to other models mid-session.
**Note**: This library only includes models that support tool calling (function calling), as this is essential for agentic workflows.
**Note**: The chat catalog only includes models that support tool calling (function calling), as this is essential for agentic workflows. Image and classifier catalogs use their operation-specific capabilities.
## Table of Contents
@ -29,6 +29,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and
- [Compact Assistant Message Frames](#compact-assistant-message-frames)
- [Image Input](#image-input)
- [Image Generation](#image-generation)
- [Classification](#classification)
- [Thinking/Reasoning](#thinkingreasoning)
- [Unified Interface](#unified-interface-streamsimplecompletesimple)
- [Provider-Specific Options](#provider-specific-options-streamcomplete)
@ -64,6 +65,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and
- **Azure OpenAI (Responses)**
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
- **Radius** (API key or OAuth, with a dynamically refreshed gateway catalog)
- **TypeSafe** (System One classifier API)
- **DeepSeek**
- **NVIDIA NIM**
- **Anthropic**
@ -276,7 +278,7 @@ Reads are synchronous and return the last-known lists:
const providers = models.getProviders(); // registered Provider objects
const provider = models.getProvider('anthropic'); // one provider
const all = models.getModels(); // every model across providers
const all = models.getModels(); // every chat model across providers
const anthropicModels = models.getModels('anthropic');
const model = models.getModel('anthropic', 'claude-sonnet-4-5');
@ -289,7 +291,31 @@ for (const m of anthropicModels) {
}
```
Dynamically listed models are typed `Model<Api>`. Narrow with the `hasApi()` guard when you need API-specific option typing:
The unqualified reads `getModels()`/`getModel()`/`getAvailable()` return chat models (`Model<Api>`) usable with `stream()`. The `*OfType` reads return one model type, and `getAllModels()`/`getAllAvailable()` return every type as `AnyModel`:
```typescript
const images = models.getModelsOfType('image', 'openrouter'); // ImageModel[]
const flux = models.getModelOfType('image', 'openrouter', 'black-forest-labs/flux.2-pro');
const jev = models.getModelOfType('classifier', 'typesafe', 'jev-latest');
const availableImages = await models.getAvailableOfType('image');
const everything = models.getAllModels(); // AnyModel[]
```
The model's `type` decides which operation accepts it: chat models stream, `type: "image"` models generate images, and `type: "classifier"` models classify structured state. `type` is optional on chat models, so a model without `type` is a chat model. Do not compare `type` directly; narrow mixed lists with `isModelType()` or read the effective type with `getModelType()`:
```typescript
import { isModelType } from '@earendil-works/pi-ai';
for (const model of models.getAllModels()) {
if (isModelType(model, 'image')) {
// model: ImageModel<ImageApi>
}
}
```
IDs are unique within each provider and type; one upstream model may have separate entries for different operations. On a provider, `getModels()` returns chat models and the optional `getAllModels()` returns every type; providers with only chat models can omit it.
Dynamically listed chat models are typed `Model<Api>`. Narrow with the `hasApi()` guard when you need API-specific option typing:
```typescript
import { hasApi } from '@earendil-works/pi-ai';
@ -306,12 +332,26 @@ if (m && hasApi(m, 'anthropic-messages')) {
For tooling that wants the generated built-in catalog with full literal typing (provider and model IDs auto-complete), independent of any collection:
```typescript
import { getBuiltinModel, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all';
import {
getAllBuiltinModels,
getBuiltinClassifierModel,
getBuiltinClassifierModels,
getBuiltinImageModel,
getBuiltinImageModels,
getBuiltinModel,
getBuiltinModels,
getBuiltinProviders,
} from '@earendil-works/pi-ai/providers/all';
const model = getBuiltinModel('openai', 'gpt-4o-mini'); // typed Model<'openai-responses'>
const radius = getBuiltinModel('radius', 'balanced'); // typed Model<'pi-messages'>
const flux = getBuiltinImageModel('openrouter', 'black-forest-labs/flux.2-pro');
const jev = getBuiltinClassifierModel('typesafe', 'jev-latest');
const providers = getBuiltinProviders();
const anthropic = getBuiltinModels('anthropic');
const openrouterChat = getBuiltinModels('openrouter'); // Model[]
const openrouterImages = getBuiltinImageModels('openrouter'); // ImageModel[]
const typesafeClassifiers = getBuiltinClassifierModels('typesafe'); // ClassifierModel[]
const openrouterAll = getAllBuiltinModels('openrouter'); // AnyModel[]
```
### Dynamic Providers
@ -423,6 +463,7 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex
| Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` (e.g. `https://{resource}.ai.azure.com`) or `AZURE_OPENAI_RESOURCE_NAME`. Supports `*.openai.azure.com`, `*.cognitiveservices.azure.com` and `*.ai.azure.com`; root endpoints auto-normalize to `/openai/v1`. Optional: `AZURE_OPENAI_API_VERSION` (default `v1`), `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. |
| Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` |
| Radius | `RADIUS_API_KEY` |
| TypeSafe | `TYPESAFE_API_KEY` |
| DeepSeek | `DEEPSEEK_API_KEY` |
| NVIDIA NIM | `NVIDIA_API_KEY` |
| Google | `GEMINI_API_KEY` |
@ -748,20 +789,19 @@ for (const block of response.content) {
## Image Generation
Image generation uses a separate API surface from text/chat generation, mirroring the chat-side design: an `ImagesModels` collection holds `ImagesProvider`s, reads are sync, and auth resolves through the owning provider. Image generation is a one-shot API: `generateImages()` waits for the provider response and returns the final `AssistantImages` result — do not use the chat/stream APIs for it.
Image models live in the same `Models` collection and on the same `Provider` as chat models, so one credential per provider covers both. They are typed `ImageModel` with `type: "image"` and are used through `generateImages()`, a one-shot API that waits for the provider response and returns the final `AssistantImages` result. Do not use the chat/stream APIs for them; `stream()` rejects image models.
### Basic Image Generation
```typescript
import { builtinImagesModels } from '@earendil-works/pi-ai/providers/all';
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
// Every built-in image-generation provider; accepts the same options as createModels()
const imagesModels = builtinImagesModels();
const models = builtinModels();
const model = imagesModels.getModel('openrouter', 'google/gemini-2.5-flash-image')!;
const model = models.getModelOfType('image', 'openrouter', 'google/gemini-2.5-flash-image')!;
// Auth resolves through the provider (OPENROUTER_API_KEY here); explicit apiKey wins
const result = await imagesModels.generateImages(model, {
const result = await models.generateImages(model, {
input: [{ type: 'text', text: 'Generate a red circle on a plain white background.' }]
});
@ -775,7 +815,33 @@ for (const block of result.output) {
}
```
Like the chat side, you can build the collection from parts: `createImagesModels({ credentials?, authContext? })`, the `openrouterImagesProvider()` factory from `@earendil-works/pi-ai/providers/openrouter-images`, and `createImagesProvider({ id, auth, models, refreshModels?, api })` for custom image providers (with `imagesModels.refresh(provider?)` for dynamic lists). Failures never reject — they return an `AssistantImages` with `stopReason: "error"`. The collection's provider-scoped `getAuth(providerId)` works exactly like the chat-side one.
`generateImages()` accepts only `ImageModel` values. If an upstream model supports both chat and image generation, the catalog contains separate entries with the same provider and ID: `getModel()` returns its chat operation and `getModelOfType('image', ...)` returns its image operation. Failures never reject; they return an `AssistantImages` with `stopReason: "error"`, including unknown providers, unconfigured auth, and providers without an image implementation.
A provider declares image support with the `images` option of [`createProvider()`](#createprovider): a map from `model.api` to an implementation with `generateImages()`. Image models go into the same `models` list as chat models. `api` becomes optional when `images` is present, so an image-only provider is just a provider without chat models:
```typescript
import { createProvider, envApiKeyAuth } from '@earendil-works/pi-ai';
const pixels = createProvider({
id: 'pixels',
auth: { apiKey: envApiKeyAuth('Pixels API key', ['PIXELS_API_KEY']) },
models: [{
type: 'image',
id: 'flux-pro',
name: 'FLUX Pro',
api: 'pixels-images',
provider: 'pixels',
baseUrl: 'https://api.pixels.test/v1',
input: ['text'],
output: ['image'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}],
images: {
'pixels-images': { generateImages: async (model, context, options) => { /* ... */ } },
},
});
models.setProvider(pixels);
```
The old global API (`getImageModel()` / `getImageModels()` / `getImageProviders()` / `generateImages()`) remains available on the [compat entrypoint](#migrating-from-the-old-global-api):
@ -796,7 +862,7 @@ Some models also support image input:
import { readFileSync } from 'fs';
const imageBuffer = readFileSync('input.png');
const result = await imagesModels.generateImages(model, {
const result = await models.generateImages(model, {
input: [
{ type: 'text', text: 'Create a variation of this image with a blue background.' },
{ type: 'image', data: imageBuffer.toString('base64'), mimeType: 'image/png' }
@ -807,14 +873,13 @@ const result = await imagesModels.generateImages(model, {
Check capabilities on the model metadata:
```typescript
console.log(model.input); // ['text', 'image']
console.log(model.input); // ['text'] or ['text', 'image']
console.log(model.output); // ['image'] or ['image', 'text']
```
### Notes and Limitations
- Image models live in `ImagesModels` collections, chat models in `Models` collections; the two are separate surfaces.
- Use `generateImages()`, not the chat/stream APIs.
- Image models and chat models share `Models` and `Provider`; list them with `getModelsOfType('image')` and run them with `generateImages()`, never the chat/stream APIs.
- Image-generation models do not participate in tool calling.
- Outputs are returned in `AssistantImages.output` and can include both base64-encoded `ImageContent` blocks and `TextContent` blocks.
- Some models return only images, others return images plus text. Check `model.output`.
@ -823,6 +888,57 @@ console.log(model.output); // ['image'] or ['image', 'text']
- If you want a model to analyze images in a conversation or call tools, use the regular chat APIs with a model that supports image input.
- At the moment, image generation is available through only one provider, OpenRouter.
## Classification
Classifier models consume structured JSON state and answer one or more typed questions. They do not use chat or image-generation APIs. The built-in TypeSafe provider exposes models.dev's `jev-latest` model and reads `TYPESAFE_API_KEY`.
```typescript
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
const models = builtinModels();
const model = models.getModelOfType('classifier', 'typesafe', 'jev-latest')!;
const result = await models.classify(model, {
state: { message: 'The change works perfectly, thanks.' },
questions: {
category: {
type: 'choice',
instructions: 'Classify the message.',
criteria: {
approval: 'The user approves of the result',
correction: 'The user requests a correction'
}
},
satisfaction: {
type: 'score',
instructions: 'Score user satisfaction.',
criteria: ['dissatisfied', 'neutral', 'satisfied']
},
approved: {
type: 'bool',
instructions: 'Does the user approve?',
criteria: { true: 'Approval', false: 'No approval' }
}
}
});
console.log(result.answers);
```
The public contract uses `bool` questions and `{ type: "bool", probability }` answers. The TypeSafe adapter translates those to and from its `noul` wire representation. Like image generation, `classify()` resolves to a result with `stopReason: "error"` instead of rejecting for provider, authentication, or response errors.
Custom providers register classifier models and implementations by API ID:
```typescript
createProvider({
id: 'classifier-service',
auth,
models: [model],
classifiers: {
'classifier-api': { classify: async (model, context, options) => result }
}
});
```
## Thinking/Reasoning
Many models support thinking/reasoning capabilities where they can show their internal thought process. You can check if a model supports reasoning via the `reasoning` property. If you pass reasoning options to a non-reasoning model, they are silently ignored.
@ -1054,7 +1170,7 @@ Callbacks are awaited in stream order, so slow callbacks delay stream consumptio
### createProvider()
`createProvider()` builds a provider from parts: identity, auth, a model list, and an API implementation. Use it for local inference servers, proxies, or any OpenAI/Anthropic-compatible endpoint:
`createProvider()` builds a provider from parts: identity, auth, a model list, and an API implementation (`api` for chat models, `images` for image generation, `classifiers` for classification; at least one is required, see [Image Generation](#image-generation)). Use it for local inference servers, proxies, or any OpenAI/Anthropic-compatible endpoint:
```typescript
import { createModels, createProvider, envApiKeyAuth, type Model } from '@earendil-works/pi-ai';
@ -1136,7 +1252,7 @@ const tenantGateway = createProvider({
});
```
Dynamic model lists use `fetchModels`. `Models.refresh()` refreshes every configured dynamic provider, passing its effective API-key or refreshed OAuth credential. A `ModelsStore` persists dynamic catalogs; both stores default to in-memory implementations. Its `read`, `write`, and `delete` operations accept optional cancellation, and `Models` binds those waits to the provider refresh signal.
Dynamic model lists use `fetchModels`, which can return models of every type. `Models.refresh()` refreshes every configured dynamic provider, passing its effective API-key or refreshed OAuth credential. A `ModelsStore` persists dynamic catalogs; both stores default to in-memory implementations. Its `read`, `write`, and `delete` operations accept optional cancellation, and `Models` binds those waits to the provider refresh signal.
```typescript
const models = createModels({ credentials, modelsStore });
@ -1158,7 +1274,7 @@ for (const [provider, error] of result.errors) console.error(provider, error);
Use `models.refresh({ providers: ['openrouter'] })` to restrict work to selected providers, `models.refresh({ allowNetwork: false })` to restore persisted catalogs without network access, or `models.refresh({ force: true })` to bypass provider freshness checks. Model reads stay synchronous and return the last restored or refreshed list.
`createProvider()` handles dynamic publication and persistence automatically. Handwritten `Provider.refreshModels()` implementations receive the read-only `context.stored` snapshot and publish through `context.publish({ persist?, update? })`. Omit `persist` to leave storage unchanged, pass a `ModelsStoreEntry` to write it, or pass `persist: null` to delete it. Publication is generation-checked; put synchronous in-memory catalog changes in `update` rather than mutating state before publication.
`createProvider()` handles dynamic publication and persistence automatically. Handwritten `Provider.refreshModels()` implementations receive the read-only `context.stored` snapshot and publish through `context.publish({ persist?, update? })`. Omit `persist` to leave storage unchanged, pass a `ModelsStoreEntry` to write it, or pass `persist: null` to delete it. `ModelsStoreEntry.models` contains models of every type. Publication is generation-checked; put synchronous in-memory catalog changes in `update` rather than mutating state before publication.
Custom models can carry `headers` (e.g. proxies behind bot detection) and `compat` flags. `Models.getAuth(model)` includes those model headers, and stream methods merge them before explicit request headers and `transformHeaders`. See [OpenAI Compatibility Settings](#openai-compatibility-settings).
@ -1682,6 +1798,9 @@ Compat is a strict superset of the root entrypoint, so a file can switch its imp
| `getEnvApiKey('openai')` | `await models.getAuth(model.provider)` |
| `streamAnthropic(model, ctx, opts)` | `stream` from `@earendil-works/pi-ai/api/anthropic-messages`, or a provider in a collection |
| `registerFauxProvider()` | `fauxProvider()` + `models.setProvider()` |
| `getImageModel('openrouter', id)` / `generateImages(model, ctx, { apiKey })` | `models.getModelOfType('image', 'openrouter', id)` / `models.generateImages(model, ctx)` |
The separate `ImagesModels`/`ImagesProvider` collection that existed briefly (`createImagesModels()`, `createImagesProvider()`, `openrouterImagesProvider()`, `builtinImagesModels()`) is gone: image models now live on the regular provider. Replace `builtinImagesModels()` with `builtinModels()`, `imagesModels.getModel()` with `models.getModelOfType('image', ...)`, and `createImagesProvider({ models, api })` with `createProvider({ models, images })`. The old plural image type names are removed; use `ImageModel` and `ImageApi`, and add `type: "image"` to image model literals.
## Development
@ -1706,11 +1825,11 @@ Create a new API implementation file (for example `bedrock-converse-stream.ts`)
Add a lazy wrapper `src/api/<api-id>.lazy.ts` (`<name>Api()` via `lazyApi()`) so providers can reference the implementation without importing its SDK. Add any root-level `export type` re-exports in `src/index.ts` that should remain available from `@earendil-works/pi-ai`.
#### 3. Model Generation (`scripts/generate-models.ts`, `scripts/generate-image-models.ts`)
#### 3. Model Generation (`scripts/generate-models.ts`)
- Add logic to fetch and parse models from the provider's source (e.g., models.dev API)
- Map chat/tool-capable provider model data to the standardized `Model` interface via `scripts/generate-models.ts`; hydration groups the ignored `src/providers/data/<id>.json` values by API, while stable `src/providers/<id>.models.ts` wrappers derive exact model/API types directly from those JSON keys
- Map image-generation provider model data to the standardized `ImagesModel` interface via `scripts/generate-image-models.ts`
- 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)
#### 4. Provider Factory (`src/providers/<id>.ts`)

View file

@ -56,7 +56,6 @@
"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",
"generate-image-models": "node scripts/generate-image-models.ts --strict",
"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 && tsgo -p tsconfig.build.json && shx rm -rf dist/providers/data && shx cp -r src/providers/data dist/providers/data",

View file

@ -1,162 +0,0 @@
#!/usr/bin/env node
import { writeFileSync } from "fs";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import type { ImagesModel } from "../src/types.ts";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageRoot = join(__dirname, "..");
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
function readStrictOption(args: string[]): boolean {
for (const arg of args) {
if (arg !== "--strict") throw new Error(`Unknown argument: ${arg}`);
}
return args.includes("--strict");
}
interface OpenRouterModelRecord {
id: string;
name: string;
context_length?: number;
architecture?: {
input_modalities?: string[];
output_modalities?: string[];
};
pricing?: {
prompt?: string;
completion?: string;
input_cache_read?: string;
input_cache_write?: string;
};
}
export function parseOpenRouterImageModels(
payload: unknown,
strict: boolean,
): ImagesModel<"openrouter-images">[] {
const data =
typeof payload === "object" && payload !== null
? (payload as { data?: OpenRouterModelRecord[] }).data
: undefined;
if (!Array.isArray(data) || data.length === 0) {
if (strict) throw new Error("OpenRouter API returned a missing or empty image model list");
return [];
}
const models: ImagesModel<"openrouter-images">[] = [];
for (const model of data) {
const input = Array.from(
new Set(
(model.architecture?.input_modalities ?? []).filter(
(modality): modality is "text" | "image" => modality === "text" || modality === "image",
),
),
);
const output = Array.from(
new Set(
(model.architecture?.output_modalities ?? []).filter(
(modality): modality is "text" | "image" => modality === "text" || modality === "image",
),
),
);
if (!output.includes("image")) continue;
if (input.length === 0) input.push("text");
models.push({
id: model.id,
name: model.name,
api: "openrouter-images",
provider: "openrouter",
baseUrl: OPENROUTER_BASE_URL,
input,
output,
cost: {
input: parseFloat(model.pricing?.prompt || "0") * 1_000_000,
output: parseFloat(model.pricing?.completion || "0") * 1_000_000,
cacheRead: parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000,
cacheWrite: parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000,
},
});
}
if (strict && models.length === 0) {
throw new Error("OpenRouter API returned no usable image models");
}
return models;
}
async function fetchOpenRouterImageModels(strict: boolean): Promise<ImagesModel<"openrouter-images">[]> {
try {
console.log("Fetching image models from OpenRouter API...");
const response = await fetch(`${OPENROUTER_BASE_URL}/models?output_modalities=image`);
if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`);
const models = parseOpenRouterImageModels(await response.json(), strict);
console.log(`Fetched ${models.length} image models from OpenRouter`);
return models;
} catch (error) {
console.error("Failed to fetch OpenRouter image models:", error);
if (strict) throw error;
return [];
}
}
function generateImageModelsFile(models: ImagesModel<"openrouter-images">[]): string {
const imageModelsByProvider = {
openrouter: Object.fromEntries(
models
.sort((a, b) => a.id.localeCompare(b.id))
.map((model) => [
model.id,
`{
id: ${JSON.stringify(model.id)},
name: ${JSON.stringify(model.name)},
api: ${JSON.stringify(model.api)},
provider: ${JSON.stringify(model.provider)},
baseUrl: ${JSON.stringify(model.baseUrl)},
input: ${JSON.stringify(model.input)},
output: ${JSON.stringify(model.output)},
cost: ${JSON.stringify(model.cost, null, 2).replace(/^/gm, "\t")}
} satisfies ImagesModel<${JSON.stringify(model.api)}>`,
]),
),
};
const providerEntries = Object.entries(imageModelsByProvider)
.map(([provider, providerModels]) => {
const modelEntries = Object.entries(providerModels)
.map(([id, serialized]) => `\t\t${JSON.stringify(id)}: ${serialized},`)
.join("\n");
return `\t${JSON.stringify(provider)}: {\n${modelEntries}\n\t},`;
})
.join("\n");
return `// This file is auto-generated by scripts/generate-image-models.ts
// Do not edit manually - run 'npm run generate-image-models' to update
import type { ImagesApi, ImagesModel } from "./types.ts";
export const IMAGE_MODELS = {
${providerEntries}
} as const satisfies Record<string, Record<string, ImagesModel<ImagesApi>>>;
`;
}
async function main(): Promise<void> {
const strict = readStrictOption(process.argv.slice(2));
const models = await fetchOpenRouterImageModels(strict);
const output = generateImageModelsFile(models);
const outputPath = join(packageRoot, "src", "image-models.generated.ts");
writeFileSync(outputPath, output, "utf-8");
console.log(`Generated ${outputPath}`);
}
if (process.argv[1] && resolve(process.argv[1]) === __filename) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}

View file

@ -4,10 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSy
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { getEffortThinkingLevelMap, type ModelsDevReasoningOption } from "./models-dev-reasoning-options.ts";
import {
getOpenRouterThinkingLevelMap,
type OpenRouterReasoningMetadata,
} from "./openrouter-reasoning-options.ts";
import { buildOpenRouterCatalog, type OpenRouterCatalog, type OpenRouterModelListItem } from "./openrouter-catalog.ts";
import {
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
@ -16,7 +13,12 @@ import {
} from "../src/api/cloudflare.ts";
import type {
AnthropicMessagesCompat,
AnyModel,
Api,
ClassifierApi,
ClassifierModel,
ImageApi,
ImageModel,
KnownProvider,
Model,
ModelCost,
@ -131,31 +133,20 @@ interface ModelsDevProvider {
models?: Record<string, ModelsDevModel>;
}
interface ModelsDevMetadata {
id: string;
type?: string;
name: string;
limit?: { context?: number };
modalities?: { input?: string[] };
}
type ModelsDevCatalog = Record<string, ModelsDevProvider>;
interface NvidiaNimModelListItem {
id: string;
}
interface OpenRouterModelListItem {
id: string;
name: string;
supported_parameters?: string[];
architecture?: { modality?: string };
pricing?: {
prompt?: string;
completion?: string;
input_cache_read?: string;
input_cache_write?: string;
};
top_provider?: {
context_length?: number;
max_completion_tokens?: number;
};
context_length?: number;
reasoning?: OpenRouterReasoningMetadata;
}
interface AiGatewayModel {
id: string;
name?: string;
@ -976,14 +967,14 @@ function applyPromptCacheMetadata(model: Model<Api>): void {
// behavior; a documented TTL alone does not establish full cache loss.
}
function applyImageInputMetadata(model: Model<Api>): void {
function applyImageInputMetadata(model: AnyModel): void {
if (!model.input.includes("image")) return;
const providerLimits: Model<Api>["inputLimits"] =
const providerLimits: AnyModel["inputLimits"] =
model.provider === "anthropic"
? {
maxRequestBytes: 32 * 1024 * 1024,
images: { maxPerRequest: model.contextWindow === 200000 ? 100 : 600 },
images: { maxPerRequest: model.type !== "image" && model.contextWindow === 200000 ? 100 : 600 },
}
: model.provider === "amazon-bedrock"
? { images: { maxPerMessage: 20 } }
@ -1283,68 +1274,30 @@ async function fetchNvidiaNimModelIds(): Promise<Map<string, string>> {
}
}
async function fetchOpenRouterModels(): Promise<Model<any>[]> {
try {
console.log("Fetching models from OpenRouter API...");
const response = await fetch("https://openrouter.ai/api/v1/models");
async function fetchOpenRouterList(query: string): Promise<OpenRouterModelListItem[]> {
const response = await fetch(`https://openrouter.ai/api/v1/models${query}`);
if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`);
const data = (await response.json()) as { data?: OpenRouterModelListItem[] };
return data.data ?? [];
}
const models: Model<any>[] = [];
for (const model of data.data ?? []) {
// Only include models that support tools
if (!model.supported_parameters?.includes("tools")) continue;
// Parse provider from model ID
let provider: KnownProvider = "openrouter";
let modelKey = model.id;
modelKey = model.id; // Keep full ID for OpenRouter
// Parse input modalities
const input: ("text" | "image")[] = ["text"];
if (model.architecture?.modality?.includes("image")) {
input.push("image");
async function fetchOpenRouterModels(): Promise<OpenRouterCatalog> {
try {
console.log("Fetching models from OpenRouter API...");
const [listed, imageListed] = await Promise.all([
fetchOpenRouterList(""),
fetchOpenRouterList("?output_modalities=image"),
]);
const catalog = buildOpenRouterCatalog(listed, imageListed);
console.log(`Fetched ${catalog.chat.length} tool-capable and ${catalog.images.length} image models from OpenRouter`);
if (generatorOptions.strict && catalog.images.length === 0) {
throw new Error("OpenRouter API returned no usable image models");
}
// Convert pricing from $/token to $/million tokens
const inputCost = roundCost(parseFloat(model.pricing?.prompt || "0") * 1_000_000);
const outputCost = roundCost(parseFloat(model.pricing?.completion || "0") * 1_000_000);
const cacheReadCost = roundCost(parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000);
const cacheWriteCost = roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000);
const contextWindow = model.top_provider?.context_length || model.context_length || 4096;
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);
const useAnthropicMessages = /^anthropic\//.test(modelKey) && !modelKey.endsWith(":batch");
const normalizedModel: Model<any> = {
id: modelKey,
name: model.name,
api: useAnthropicMessages ? "anthropic-messages" : "openai-completions",
baseUrl: useAnthropicMessages ? "https://openrouter.ai/api" : "https://openrouter.ai/api/v1",
provider,
reasoning: model.supported_parameters?.includes("reasoning") || false,
...(thinkingLevelMap && { thinkingLevelMap }),
input,
cost: {
input: inputCost,
output: outputCost,
cacheRead: cacheReadCost,
cacheWrite: cacheWriteCost,
},
contextWindow,
maxTokens: model.top_provider?.max_completion_tokens || 4096,
};
models.push(normalizedModel);
}
console.log(`Fetched ${models.length} tool-capable models from OpenRouter`);
return models;
return catalog;
} catch (error) {
console.error("Failed to fetch OpenRouter models:", error);
if (generatorOptions.strict) throw error;
return [];
return { chat: [], images: [] };
}
}
@ -2662,6 +2615,37 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
}
async function loadModelsDevClassifierModels(): Promise<ClassifierModel<"typesafe-system-one">[]> {
try {
console.log("Fetching classifier models from models.dev API...");
const response = await fetch("https://models.dev/models.json?type=decision");
if (!response.ok) throw new Error(`models.dev classifier API returned ${response.status}`);
const data = (await response.json()) as Record<string, ModelsDevMetadata>;
const metadata = data["typesafe/jev-latest"];
if (!metadata || metadata.type !== "decision") {
throw new Error("models.dev did not return decision model typesafe/jev-latest");
}
return [
{
type: "classifier",
id: "jev-latest",
name: metadata.name,
api: "typesafe-system-one",
provider: "typesafe",
baseUrl: "https://api.typesafe.ai/v1/",
input: metadata.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
// The canonical models.dev entry has no direct-provider pricing and System One reports no token usage.
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: metadata.limit?.context || 64000,
},
];
} catch (error) {
console.error("Failed to load models.dev classifier data:", error);
if (generatorOptions.strict) throw error;
return [];
}
}
async function generateModels() {
// Fetch models from all upstream catalogs.
// models.dev: Anthropic, Google, OpenAI, Groq, Cerebras, and others
@ -2669,12 +2653,13 @@ async function generateModels() {
// AI Gateway: OpenAI-compatible catalog with tool-capable models
// Radius: its unauthenticated public catalog; authenticated clients overlay it at runtime
const modelsDevModels = await loadModelsDevData();
const openRouterModels = await fetchOpenRouterModels();
const classifierModels = await loadModelsDevClassifierModels();
const openRouterCatalog = await fetchOpenRouterModels();
const aiGatewayModels = await fetchAiGatewayModels();
const radiusModels = await fetchRadiusModels();
// Combine models (models.dev has priority where sources overlap).
const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels, ...radiusModels].filter(
// Combine chat models (models.dev has priority where sources overlap).
const allModels = [...modelsDevModels, ...openRouterCatalog.chat, ...aiGatewayModels, ...radiusModels].filter(
(model) =>
!(model.provider === "xai" && XAI_BUILTIN_EXCLUDED_MODEL_IDS.has(model.id)) &&
!((model.provider === "opencode" || model.provider === "opencode-go") && model.id === "gpt-5.3-codex-spark"),
@ -3290,26 +3275,49 @@ async function generateModels() {
}
applyAnthropicAllowedFallbackModelMetadata(allModels.filter(isAnthropicFallbackMetadataModel));
// Group by provider and deduplicate by model ID
const providers: Record<string, Record<string, Model<any>>> = {};
// Keep chat and image catalogs separate so one upstream ID can expose both
// operations with different API implementations.
type ProviderCatalog = {
chat: Record<string, Model<Api>>;
image: Record<string, ImageModel<ImageApi>>;
classifier: Record<string, ClassifierModel<ClassifierApi>>;
};
const providers: Record<string, ProviderCatalog> = {};
for (const model of allModels) {
if (!providers[model.provider]) {
providers[model.provider] = {};
providers[model.provider] ??= { chat: {}, image: {}, classifier: {} };
// Only add if not already present (models.dev takes priority over OpenRouter).
providers[model.provider].chat[model.id] ??= { ...model, type: "chat" };
}
// Use model ID as key to automatically deduplicate
// Only add if not already present (models.dev takes priority over OpenRouter)
if (!providers[model.provider][model.id]) {
providers[model.provider][model.id] = model;
for (const model of openRouterCatalog.images) {
applyImageInputMetadata(model);
providers[model.provider] ??= { chat: {}, image: {}, classifier: {} };
providers[model.provider].image[model.id] ??= model;
}
for (const model of classifierModels) {
providers[model.provider] ??= { chat: {}, image: {}, classifier: {} };
providers[model.provider].classifier[model.id] ??= model;
}
const sortedProviderIds = Object.keys(providers).sort();
const jsonProviders: Record<string, Record<string, Model<any>>> = {};
const jsonChatProviders: Record<string, Record<string, Model<Api>>> = {};
const jsonImageProviders: Record<string, Record<string, ImageModel<ImageApi>>> = {};
const jsonClassifierProviders: Record<string, Record<string, ClassifierModel<ClassifierApi>>> = {};
const jsonAllProviders: Record<string, AnyModel[]> = {};
for (const providerId of sortedProviderIds) {
jsonProviders[providerId] = {};
for (const modelId of Object.keys(providers[providerId]).sort()) {
jsonProviders[providerId][modelId] = providers[providerId][modelId];
}
jsonChatProviders[providerId] = Object.fromEntries(
Object.entries(providers[providerId].chat).sort(([left], [right]) => left.localeCompare(right)),
);
jsonImageProviders[providerId] = Object.fromEntries(
Object.entries(providers[providerId].image).sort(([left], [right]) => left.localeCompare(right)),
);
jsonClassifierProviders[providerId] = Object.fromEntries(
Object.entries(providers[providerId].classifier).sort(([left], [right]) => left.localeCompare(right)),
);
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`;
@ -3317,25 +3325,29 @@ async function generateModels() {
const generatedDataProviderIds = generatorOptions.dataOnly
? readModelDataProviderIds(packageRoot)
: sortedProviderIds;
const missingProviderIds = generatedDataProviderIds.filter((providerId) => !jsonProviders[providerId]);
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. Public JSON catalog output stays flat.
const generatedDataProviders: Record<string, Record<string, Record<string, Model<Api>>>> = {};
// Only the ignored internal data is grouped by API for type derivation.
const generatedDataProviders: Record<string, Record<string, Record<string, AnyModel>>> = {};
const modelDataStructure: ModelDataStructure = {};
for (const providerId of generatedDataProviderIds) {
const models = jsonProviders[providerId];
const models = jsonAllProviders[providerId];
generatedDataProviders[providerId] = {};
modelDataStructure[providerId] = {};
const apiIds = Array.from(new Set(Object.values(models).map((model) => model.api))).sort();
const apiIds = Array.from(new Set(models.map((model) => model.api))).sort();
for (const api of apiIds) {
generatedDataProviders[providerId][api] = {};
for (const [modelId, model] of Object.entries(models)) {
for (const model of models) {
if (model.api !== api) continue;
generatedDataProviders[providerId][api][modelId] = model;
modelDataStructure[providerId][modelId] = api;
const identity = `${model.type}:${model.id}`;
if (generatedDataProviders[providerId][api][identity]) {
throw new Error(`${providerId}/${identity} has duplicate ${api} catalog entries`);
}
generatedDataProviders[providerId][api][identity] = model;
modelDataStructure[providerId][identity] = api;
}
}
}
@ -3389,13 +3401,21 @@ async function generateModels() {
`;
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 { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";\n\n`;
output += `export const ${catalogConstName(providerId)}: ModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
output += `\tflattenModelCatalog(${JSON.stringify(providerId)}, values);\n`;
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);
@ -3406,7 +3426,7 @@ async function generateModels() {
let output = generatedHeader;
for (const providerId of sortedProviderIds) {
output += `import { ${catalogConstName(providerId)} } from "./providers/${providerId}.models.ts";\n`;
output += `import { ${classifierCatalogConstName(providerId)}, ${imageCatalogConstName(providerId)}, ${catalogConstName(providerId)} } from "./providers/${providerId}.models.ts";\n`;
}
output += `\nexport const MODELS: {\n`;
for (const providerId of sortedProviderIds) {
@ -3416,6 +3436,22 @@ async function generateModels() {
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");
@ -3446,27 +3482,33 @@ async function generateModels() {
}
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"), jsonProviders);
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`), jsonProviders[providerId]);
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 totalModels = allModels.length;
const reasoningModels = allModels.filter(m => m.reasoning).length;
const reasoningModels = allModels.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}: ${Object.keys(models).length} models`);
console.log(
` ${provider}: ${Object.keys(models.chat).length} chat models, ${Object.keys(models.image).length} image models, ${Object.keys(models.classifier).length} classifier models`,
);
}
}

View file

@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
export const MODEL_DATA_SCHEMA_VERSION = 3;
export const MODEL_DATA_SCHEMA_VERSION = 6;
export const MODEL_DATA_MANIFEST_FILE = ".manifest.json";
export type ModelDataStructure = Record<string, Record<string, string>>;
@ -15,7 +15,7 @@ export interface ModelDataManifest {
}
const MODEL_DATA_IMPORT_PATTERN =
/^import \{ [A-Z][A-Z0-9_]*_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm;
/^import \{ [A-Z][A-Z0-9_]*_CLASSIFIER_MODELS, [A-Z][A-Z0-9_]*_IMAGE_MODELS, [A-Z][A-Z0-9_]*_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm;
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
@ -50,6 +50,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isModalityList(value: unknown): value is ("text" | "image")[] {
return Array.isArray(value) && value.length > 0 && value.every((entry) => entry === "text" || entry === "image");
}
function readJsonObject(path: string, description: string, errors: string[]): Record<string, unknown> | undefined {
let parsed: unknown;
try {
@ -73,9 +77,9 @@ function readProviderStructure(path: string, providerId: string): Record<string,
const models = new Map<string, string>();
for (const [api, value] of Object.entries(groups)) {
if (!isRecord(value)) throw new Error(`${path} API group ${JSON.stringify(api)} must be an object`);
for (const modelId of Object.keys(value)) {
if (models.has(modelId)) throw new Error(`${path} contains model ${modelId} in more than one API group`);
models.set(modelId, api);
for (const modelKey of Object.keys(value)) {
if (models.has(modelKey)) throw new Error(`${path} contains ${modelKey} in more than one API group`);
models.set(modelKey, api);
}
}
if (models.size === 0) throw new Error(`${path} contains no generated model data`);
@ -158,20 +162,37 @@ function validateModelValue(
}
if (typeof value.name !== "string" || value.name.length === 0) errors.push(`${label} has no model name`);
if (typeof value.baseUrl !== "string") errors.push(`${label} has no baseUrl string`);
if (!isModalityList(value.input)) errors.push(`${label} has invalid input modalities`);
if (value.type === "image") {
if (!isModalityList(value.output) || !value.output.includes("image")) {
errors.push(`${label} has invalid output modalities`);
}
} else if (value.output !== undefined) {
errors.push(`${label} has unsupported output modalities`);
}
if (value.type === "chat") {
if (typeof value.reasoning !== "boolean") errors.push(`${label} has no reasoning boolean`);
if (
!Array.isArray(value.input) ||
value.input.length === 0 ||
value.input.some((entry) => entry !== "text" && entry !== "image")
typeof value.contextWindow !== "number" ||
!Number.isFinite(value.contextWindow) ||
value.contextWindow <= 0
) {
errors.push(`${label} has invalid input modalities`);
}
if (typeof value.contextWindow !== "number" || !Number.isFinite(value.contextWindow) || value.contextWindow <= 0) {
errors.push(`${label} has invalid contextWindow`);
}
if (typeof value.maxTokens !== "number" || !Number.isFinite(value.maxTokens) || value.maxTokens <= 0) {
errors.push(`${label} has invalid maxTokens`);
}
} else if (value.type === "classifier") {
if (
typeof value.contextWindow !== "number" ||
!Number.isFinite(value.contextWindow) ||
value.contextWindow <= 0
) {
errors.push(`${label} has invalid contextWindow`);
}
} else if (value.type !== "image") {
errors.push(`${label} has type ${JSON.stringify(value.type)}, expected "chat", "image", or "classifier"`);
}
if (!isRecord(value.cost)) {
errors.push(`${label} has invalid cost metadata`);
} else {
@ -246,13 +267,18 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi
errors.push(`${filename} API group ${JSON.stringify(api)} must be an object`);
continue;
}
for (const [modelId, model] of Object.entries(value)) {
if (actualModels.has(modelId)) {
errors.push(`${providerId}/${modelId} appears in more than one API group`);
for (const [modelKey, model] of Object.entries(value)) {
if (actualModels.has(modelKey)) {
errors.push(`${providerId}/${modelKey} appears in more than one API group`);
continue;
}
actualModels.set(modelId, api);
actualModels.set(modelKey, api);
const separator = modelKey.indexOf(":");
const modelId = separator >= 0 ? modelKey.slice(separator + 1) : modelKey;
validateModelValue(model, providerId, modelId, api, errors);
if (isRecord(model) && modelKey !== `${String(model.type)}:${String(model.id)}`) {
errors.push(`${providerId}/${modelKey} has mismatched type/id identity`);
}
}
}

View file

@ -0,0 +1,107 @@
import type { ImageModel, Model, ModelCost } from "../src/types.ts";
import { getOpenRouterThinkingLevelMap, type OpenRouterReasoningMetadata } from "./openrouter-reasoning-options.ts";
export interface OpenRouterModelListItem {
id: string;
name: string;
supported_parameters?: string[];
architecture?: { modality?: string; input_modalities?: string[]; output_modalities?: string[] };
pricing?: {
prompt?: string;
completion?: string;
input_cache_read?: string;
input_cache_write?: string;
};
top_provider?: {
context_length?: number;
max_completion_tokens?: number;
};
context_length?: number;
reasoning?: OpenRouterReasoningMetadata;
}
export interface OpenRouterCatalog {
chat: Model<"anthropic-messages" | "openai-completions">[];
images: ImageModel<"openrouter-images">[];
}
function roundCost(value: number): number {
return Number(value.toFixed(6));
}
function modalities(values: string[] | undefined): ("text" | "image")[] {
return Array.from(
new Set((values ?? []).filter((value): value is "text" | "image" => value === "text" || value === "image")),
);
}
function cost(model: OpenRouterModelListItem): ModelCost {
// Convert pricing from $/token to $/million tokens
return {
input: roundCost(parseFloat(model.pricing?.prompt || "0") * 1_000_000),
output: roundCost(parseFloat(model.pricing?.completion || "0") * 1_000_000),
cacheRead: roundCost(parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000),
cacheWrite: roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000),
};
}
/**
* Build the OpenRouter catalog from the default listing and the
* `output_modalities=image` listing. The default listing omits image-only
* models, so image models come from the second one. An upstream model may
* appear in both results; it then gets separate chat and image entries.
*/
export function buildOpenRouterCatalog(
listed: readonly OpenRouterModelListItem[],
imageListed: readonly OpenRouterModelListItem[],
): OpenRouterCatalog {
const chat: OpenRouterCatalog["chat"] = [];
for (const model of listed) {
// Only include models that support tools
if (!model.supported_parameters?.includes("tools")) continue;
// Parse input modalities
const input: ("text" | "image")[] = ["text"];
if (model.architecture?.modality?.includes("image")) {
input.push("image");
}
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);
const useAnthropicMessages = /^anthropic\//.test(model.id) && !model.id.endsWith(":batch");
chat.push({
type: "chat",
id: model.id,
name: model.name,
api: useAnthropicMessages ? "anthropic-messages" : "openai-completions",
baseUrl: useAnthropicMessages ? "https://openrouter.ai/api" : "https://openrouter.ai/api/v1",
provider: "openrouter",
reasoning: model.supported_parameters?.includes("reasoning") || false,
...(thinkingLevelMap && { thinkingLevelMap }),
input,
cost: cost(model),
contextWindow: model.top_provider?.context_length || model.context_length || 4096,
maxTokens: model.top_provider?.max_completion_tokens || 4096,
});
}
const images: OpenRouterCatalog["images"] = [];
for (const model of imageListed) {
if (images.some((entry) => entry.id === model.id)) continue;
const output = modalities(model.architecture?.output_modalities);
if (!output.includes("image")) continue;
const input = modalities(model.architecture?.input_modalities);
images.push({
type: "image",
id: model.id,
name: model.name,
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: input.length > 0 ? input : ["text"],
output,
cost: cost(model),
});
}
return { chat, images };
}

View file

@ -1,10 +1,6 @@
import type { ImagesModel, ProviderImages } from "../types.ts";
import type { ProviderImages } from "../types.ts";
export const openrouterImagesApi = (): ProviderImages => ({
generateImages: async (model, context, options) =>
(await import("./openrouter-images.ts")).generateImages(
model as ImagesModel<"openrouter-images">,
context,
options,
),
(await import("./openrouter-images.ts")).generateImages(model, context, options),
});

View file

@ -8,10 +8,11 @@ import type {
} from "openai/resources/chat/completions.js";
import type {
AssistantImages,
ImageApi,
ImageContent,
ImageModel,
ImagesContext,
ImagesFunction,
ImagesModel,
ImagesOptions,
ProviderHeaders,
TextContent,
@ -37,8 +38,9 @@ type OpenRouterImageGenerationResponse = ChatCompletion & {
choices: OpenRouterImageGenerationChoice[];
};
export const generateImages: ImagesFunction<"openrouter-images", ImagesOptions> = async (
model: ImagesModel<"openrouter-images">,
/** Image generation over OpenRouter's chat completions endpoint. */
export const generateImages: ImagesFunction<ImagesOptions> = async (
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: ImagesOptions,
) => {
@ -115,7 +117,7 @@ export const generateImages: ImagesFunction<"openrouter-images", ImagesOptions>
};
function createClient(
model: ImagesModel<"openrouter-images">,
model: ImageModel<ImageApi>,
apiKey: string,
optionsHeaders?: ProviderHeaders,
fetch?: typeof globalThis.fetch,
@ -133,7 +135,7 @@ type OpenRouterImagesCreateParams = Omit<ChatCompletionCreateParamsNonStreaming,
modalities: Array<"image" | "text">;
};
function buildParams(model: ImagesModel<"openrouter-images">, context: ImagesContext): OpenRouterImagesCreateParams {
function buildParams(model: ImageModel<ImageApi>, context: ImagesContext): OpenRouterImagesCreateParams {
const content: ChatCompletionContentPart[] = context.input.map((item): ChatCompletionContentPart => {
if (item.type === "text") {
return {
@ -168,7 +170,7 @@ function parseUsage(
completion_tokens?: number;
prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
},
model: ImagesModel<"openrouter-images">,
model: ImageModel<ImageApi>,
) {
const promptTokens = rawUsage.prompt_tokens || 0;
const reportedCachedTokens = rawUsage.prompt_tokens_details?.cached_tokens || 0;

View file

@ -0,0 +1,6 @@
import type { ProviderClassifier } from "../types.ts";
export const typesafeSystemOneApi = (): ProviderClassifier => ({
classify: async (model, context, options) =>
(await import("./typesafe-system-one.ts")).classify(model, context, options),
});

View file

@ -0,0 +1,187 @@
import type {
ClassifierAnswer,
ClassifierApi,
ClassifierContext,
ClassifierFunction,
ClassifierModel,
ClassifierOptions,
ClassifierResult,
ProviderHeaders,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
interface TypeSafeHttpError extends Error {
status: number | undefined;
headers: Headers | undefined;
body: string;
}
function httpError(response: Response, body: string): TypeSafeHttpError {
const error = new Error(`TypeSafe API returned ${response.status}`) as TypeSafeHttpError;
error.status = response.status;
error.headers = response.headers;
error.body = body;
return error;
}
function timeoutError(timeoutMs: number): TypeSafeHttpError {
const error = new Error(`Request timed out after ${timeoutMs}ms`) as TypeSafeHttpError;
error.name = "TimeoutError";
error.status = undefined;
error.headers = undefined;
error.body = "";
return error;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requiredNumber(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`TypeSafe API returned an invalid ${label}`);
}
return value;
}
function probabilities(value: unknown, label: string): Record<string, number> {
if (!isRecord(value)) throw new Error(`TypeSafe API returned invalid probabilities for ${label}`);
return Object.fromEntries(
Object.entries(value).map(([key, probability]) => [
key,
requiredNumber(probability, `probability for ${label}.${key}`),
]),
);
}
function parseAnswers(value: unknown, context: ClassifierContext): Record<string, ClassifierAnswer> {
if (!isRecord(value)) throw new Error("TypeSafe API returned an unexpected response");
const answers: Array<[string, ClassifierAnswer]> = [];
for (const [id, question] of Object.entries(context.questions)) {
const answer = value[id];
if (!isRecord(answer)) throw new Error(`TypeSafe API did not return an answer for ${id}`);
if (question.type === "choice") {
if (answer.type !== "choice" || typeof answer.choice !== "string") {
throw new Error(`TypeSafe API did not return a choice answer for ${id}`);
}
answers.push([
id,
{
type: "choice",
choice: answer.choice,
probabilities: probabilities(answer.probabilities, id),
confidence: requiredNumber(answer.confidence, `confidence for ${id}`),
},
]);
} else if (question.type === "score") {
if (answer.type !== "score") throw new Error(`TypeSafe API did not return a score answer for ${id}`);
answers.push([
id,
{
type: "score",
score: requiredNumber(answer.score, `score for ${id}`),
confidence: requiredNumber(answer.confidence, `confidence for ${id}`),
},
]);
} else {
if (answer.type !== "noul") throw new Error(`TypeSafe API did not return a bool answer for ${id}`);
answers.push([
id,
{
type: "bool",
probability: requiredNumber(answer.noul, `probability for ${id}`),
},
]);
}
}
return Object.fromEntries(answers);
}
function wirePayload(model: ClassifierModel<ClassifierApi>, context: ClassifierContext): Record<string, unknown> {
return {
model: model.id,
state: context.state,
questions: Object.fromEntries(
Object.entries(context.questions).map(([id, question]) => [
id,
question.type === "bool" ? { ...question, type: "noul" } : question,
]),
),
};
}
function requestHeaders(
model: ClassifierModel<ClassifierApi>,
apiKey: string,
optionsHeaders?: ProviderHeaders,
): Record<string, string> {
return (
providerHeadersToRecord(
{ authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
model.headers,
optionsHeaders,
) ?? {}
);
}
/** TypeSafe System One classification with public `bool` values mapped to wire-level `noul`. */
export const classify: ClassifierFunction<ClassifierOptions> = async (
model,
context,
options,
): Promise<ClassifierResult> => {
const output: ClassifierResult = {
api: model.api,
provider: model.provider,
model: model.id,
answers: {},
stopReason: "stop",
timestamp: Date.now(),
};
try {
if (model.api !== "typesafe-system-one") throw new Error(`Unsupported classifier API: ${model.api}`);
if (!options?.apiKey) throw new Error(`No API key for provider: ${model.provider}`);
let payload: unknown = wirePayload(model, context);
const transformed = await options.onPayload?.(payload, model);
if (transformed !== undefined) payload = transformed;
const requestFetch = options.fetch ?? globalThis.fetch;
const { response, body } = await retryProviderRequest(
async () => {
const timeoutSignal = options.timeoutMs !== undefined ? AbortSignal.timeout(options.timeoutMs) : undefined;
const signal =
options.signal && timeoutSignal
? AbortSignal.any([options.signal, timeoutSignal])
: (options.signal ?? timeoutSignal);
try {
const next = await requestFetch(new URL("systemone", `${model.baseUrl.replace(/\/+$/u, "")}/`), {
method: "POST",
headers: requestHeaders(model, options.apiKey!, options.headers),
body: JSON.stringify(payload),
signal,
});
if (!next.ok) throw httpError(next, await next.text());
return { response: next, body: (await next.json()) as unknown };
} catch (error) {
if (timeoutSignal?.aborted && !options.signal?.aborted) throw timeoutError(options.timeoutMs!);
throw error;
}
},
{
maxRetries: options.maxRetries ?? 2,
maxRetryDelayMs: options.maxRetryDelayMs,
signal: options.signal,
},
);
await options.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
if (!isRecord(body)) throw new Error("TypeSafe API returned an unexpected response");
output.answers = parseAnswers(body.answers, context);
return output;
} catch (error) {
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatProviderError(normalizeProviderError(error), "TypeSafe API error");
return output;
}
};

View file

@ -1,6 +1,9 @@
import type { ProviderEnv } from "../types.ts";
import { operationSignal, raceWithAbortSignal } from "../utils/abort.ts";
import { formatThrownValue } from "../utils/diagnostics.ts";
import { ModelsError } from "../utils/models-error.ts";
export { ModelsError, type ModelsErrorCode } from "../utils/models-error.ts";
import type {
ApiKeyAuth,
ApiKeyCredential,
@ -13,8 +16,6 @@ import type {
ProviderAuth,
} from "./types.ts";
export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth";
export interface AuthResolutionOverrides {
apiKey?: string;
env?: ProviderEnv;
@ -23,26 +24,8 @@ export interface AuthResolutionOverrides {
signal?: AbortSignal;
}
export class ModelsError extends Error {
readonly code: ModelsErrorCode;
constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) {
super(withCauseDetail(message, options?.cause), options);
this.name = "ModelsError";
this.code = code;
}
}
/** Callers surface `error.message` only, so keep the underlying reason in it. */
function withCauseDetail(message: string, cause: unknown): string {
if (cause === undefined || cause === null) return message;
const detail = formatThrownValue(cause).trim();
if (!detail || message.includes(detail)) return message;
return `${message}: ${detail}`;
}
/**
* Auth resolution shared by the `Models` and `ImagesModels` collections.
* Auth resolution shared by all operations in a `Models` collection.
* A stored credential owns the provider: ambient/env is consulted only when
* nothing is stored. No silent env fallback after a failed refresh or for a
* credential type without a matching handler.

View file

@ -90,6 +90,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
groq: "GROQ_API_KEY",
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
typesafe: "TYPESAFE_API_KEY",
radius: "RADIUS_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",

View file

@ -1,834 +0,0 @@
// This file is auto-generated by scripts/generate-image-models.ts
// Do not edit manually - run 'npm run generate-image-models' to update
import type { ImagesApi, ImagesModel } from "./types.ts";
export const IMAGE_MODELS = {
openrouter: {
"black-forest-labs/flux.2-flex": {
id: "black-forest-labs/flux.2-flex",
name: "Black Forest Labs: FLUX.2 Flex",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"black-forest-labs/flux.2-klein-4b": {
id: "black-forest-labs/flux.2-klein-4b",
name: "Black Forest Labs: FLUX.2 Klein 4B",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"black-forest-labs/flux.2-max": {
id: "black-forest-labs/flux.2-max",
name: "Black Forest Labs: FLUX.2 Max",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"black-forest-labs/flux.2-pro": {
id: "black-forest-labs/flux.2-pro",
name: "Black Forest Labs: FLUX.2 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"bytedance-seed/seedream-4.5": {
id: "bytedance-seed/seedream-4.5",
name: "ByteDance Seed: Seedream 4.5",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"bytedance-seed/seedream-5-0-lite": {
id: "bytedance-seed/seedream-5-0-lite",
name: "ByteDance Seed: Seedream 5.0 Lite",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"bytedance-seed/seedream-5-0-pro": {
id: "bytedance-seed/seedream-5-0-pro",
name: "ByteDance Seed: Seedream 5.0 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-2.5-flash-image": {
id: "google/gemini-2.5-flash-image",
name: "Google: Nano Banana (Gemini 2.5 Flash Image)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 0.3,
output: 2.5,
cacheRead: 0.03,
cacheWrite: 0.0833333333333333,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3-pro-image": {
id: "google/gemini-3-pro-image",
name: "Google: Nano Banana Pro (Gemini 3 Pro Image)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 2,
output: 12,
cacheRead: 0.19999999999999998,
cacheWrite: 0.375,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3-pro-image-preview": {
id: "google/gemini-3-pro-image-preview",
name: "Google: Nano Banana Pro (Gemini 3 Pro Image Preview)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 2,
output: 12,
cacheRead: 0.19999999999999998,
cacheWrite: 0.375,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3.1-flash-image": {
id: "google/gemini-3.1-flash-image",
name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 0.5,
output: 3,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3.1-flash-image-preview": {
id: "google/gemini-3.1-flash-image-preview",
name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image Preview)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 0.5,
output: 3,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3.1-flash-lite-image": {
id: "google/gemini-3.1-flash-lite-image",
name: "Google: Nano Banana 2 Lite (Gemini 3.1 Flash Lite Image)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 0.25,
output: 1.5,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"inclusionai/ming-image-0.1-design": {
id: "inclusionai/ming-image-0.1-design",
name: "inclusionAI: Ming Image 0.1 Design",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"krea/krea-2-large": {
id: "krea/krea-2-large",
name: "Krea: Krea 2 Large",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"krea/krea-2-medium": {
id: "krea/krea-2-medium",
name: "Krea: Krea 2 Medium",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"krea/krea-2-medium-turbo": {
id: "krea/krea-2-medium-turbo",
name: "Krea: Krea 2 Medium Turbo",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"meta/muse-image": {
id: "meta/muse-image",
name: "Meta: Muse Image",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"microsoft/mai-image-2.5": {
id: "microsoft/mai-image-2.5",
name: "Microsoft AI: MAI-Image-2.5",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 5,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"microsoft/mai-image-2.5-pro": {
id: "microsoft/mai-image-2.5-pro",
name: "Microsoft AI: MAI-Image-2.5 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 5,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"microsoft/mai-image-2.6": {
id: "microsoft/mai-image-2.6",
name: "Microsoft AI: MAI-Image-2.6",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 5,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"microsoft/mai-image-2.6-flash": {
id: "microsoft/mai-image-2.6-flash",
name: "Microsoft AI: MAI-Image-2.6 Flash",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 1.75,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-5-image": {
id: "openai/gpt-5-image",
name: "OpenAI: GPT-5 Image",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 10,
output: 10,
cacheRead: 1.25,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-5-image-mini": {
id: "openai/gpt-5-image-mini",
name: "OpenAI: GPT-5 Image Mini",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 2.5,
output: 2,
cacheRead: 0.25,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-5.4-image-2": {
id: "openai/gpt-5.4-image-2",
name: "OpenAI: GPT-5.4 Image 2",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 8,
output: 15,
cacheRead: 2,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-image-1": {
id: "openai/gpt-image-1",
name: "OpenAI: GPT Image 1",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 10,
output: 10,
cacheRead: 1.25,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-image-1-mini": {
id: "openai/gpt-image-1-mini",
name: "OpenAI: GPT Image 1 Mini",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 2.5,
output: 2.5,
cacheRead: 0.25,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-image-2": {
id: "openai/gpt-image-2",
name: "OpenAI: GPT Image 2",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 8,
output: 8,
cacheRead: 2,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-image-2.5-flare": {
id: "openai/gpt-image-2.5-flare",
name: "OpenAI: GPT Image 2.5 Flare",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 8,
output: 8,
cacheRead: 2,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-image-2.5-sunburst": {
id: "openai/gpt-image-2.5-sunburst",
name: "OpenAI: GPT Image 2.5 Sunburst",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 8,
output: 8,
cacheRead: 2,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openrouter/auto": {
id: "openrouter/auto",
name: "Auto Router",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["text", "image"],
cost: {
input: -1000000,
output: -1000000,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openrouter/auto-beta": {
id: "openrouter/auto-beta",
name: "Auto Router (Beta)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["text", "image"],
cost: {
input: -1000000,
output: -1000000,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"qwen/qwen-image-3": {
id: "qwen/qwen-image-3",
name: "Qwen: Qwen Image 3",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"qwen/qwen-image-3-pro": {
id: "qwen/qwen-image-3-pro",
name: "Qwen: Qwen Image 3 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v3": {
id: "recraft/recraft-v3",
name: "Recraft: Recraft V3",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4": {
id: "recraft/recraft-v4",
name: "Recraft: Recraft V4",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-pro": {
id: "recraft/recraft-v4-pro",
name: "Recraft: Recraft V4 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-pro-vector": {
id: "recraft/recraft-v4-pro-vector",
name: "Recraft: Recraft V4 Pro Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-styles": {
id: "recraft/recraft-v4-styles",
name: "Recraft: Recraft V4 Styles",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-styles-pro": {
id: "recraft/recraft-v4-styles-pro",
name: "Recraft: Recraft V4 Styles Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-styles-pro-vector": {
id: "recraft/recraft-v4-styles-pro-vector",
name: "Recraft: Recraft V4 Styles Pro Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-styles-vector": {
id: "recraft/recraft-v4-styles-vector",
name: "Recraft: Recraft V4 Styles Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-vector": {
id: "recraft/recraft-v4-vector",
name: "Recraft: Recraft V4 Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1": {
id: "recraft/recraft-v4.1",
name: "Recraft: Recraft V4.1",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-pro": {
id: "recraft/recraft-v4.1-pro",
name: "Recraft: Recraft V4.1 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-pro-vector": {
id: "recraft/recraft-v4.1-pro-vector",
name: "Recraft: Recraft V4.1 Pro Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-utility": {
id: "recraft/recraft-v4.1-utility",
name: "Recraft: Recraft V4.1 Utility",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-utility-pro": {
id: "recraft/recraft-v4.1-utility-pro",
name: "Recraft: Recraft V4.1 Utility Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-vector": {
id: "recraft/recraft-v4.1-vector",
name: "Recraft: Recraft V4.1 Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-fast": {
id: "sourceful/riverflow-v2-fast",
name: "Sourceful: Riverflow V2 Fast",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-pro": {
id: "sourceful/riverflow-v2-pro",
name: "Sourceful: Riverflow V2 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2.5-fast": {
id: "sourceful/riverflow-v2.5-fast",
name: "Sourceful: Riverflow V2.5 Fast",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2.5-pro": {
id: "sourceful/riverflow-v2.5-pro",
name: "Sourceful: Riverflow V2.5 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"x-ai/grok-imagine-image-2.0": {
id: "x-ai/grok-imagine-image-2.0",
name: "xAI: Grok Imagine Image 2.0",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"x-ai/grok-imagine-image-quality": {
id: "x-ai/grok-imagine-image-quality",
name: "SpaceXAI: Grok Imagine Image Quality",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
},
} as const satisfies Record<string, Record<string, ImagesModel<ImagesApi>>>;

View file

@ -1,42 +1,50 @@
import { IMAGE_MODELS } from "./image-models.generated.ts";
import type { ImagesApi, ImagesModel, KnownImagesProvider } from "./types.ts";
import { IMAGE_MODELS } from "./models.generated.ts";
import type { ImageApi, ImageModel } from "./types.ts";
const imageModelRegistry: Map<string, Map<string, ImagesModel<ImagesApi>>> = new Map();
/**
* Compat reads of the generated catalog restricted to image models. New code
* uses `Models.getModelOfType("image", ...)` or `getBuiltinImageModel()` from `providers/all`.
*/
type Catalog = typeof IMAGE_MODELS;
type ImageModelIds<TProvider extends keyof Catalog> = keyof Catalog[TProvider];
/** Built-in providers with at least one image model in the generated catalog. */
export type BuiltinImageProvider = {
[TProvider in keyof Catalog]: [ImageModelIds<TProvider>] extends [never] ? never : TProvider;
}[keyof Catalog];
type BuiltinImageModel<
TProvider extends BuiltinImageProvider,
TModelId extends ImageModelIds<TProvider>,
> = Catalog[TProvider][TModelId] extends ImageModel<infer TApi extends ImageApi> ? ImageModel<TApi> : never;
const imageModelsByProvider = new Map<string, Map<string, ImageModel<ImageApi>>>();
for (const [provider, models] of Object.entries(IMAGE_MODELS)) {
const providerModels = new Map<string, ImagesModel<ImagesApi>>();
for (const [id, model] of Object.entries(models)) {
providerModels.set(id, model as ImagesModel<ImagesApi>);
const imageModels = new Map<string, ImageModel<ImageApi>>();
for (const model of Object.values(models as Record<string, ImageModel<ImageApi>>)) {
imageModels.set(model.id, model);
}
imageModelRegistry.set(provider, providerModels);
if (imageModels.size > 0) imageModelsByProvider.set(provider, imageModels);
}
type ImageModelApi<
TProvider extends KnownImagesProvider,
TModelId extends keyof (typeof IMAGE_MODELS)[TProvider],
> = (typeof IMAGE_MODELS)[TProvider][TModelId] extends { api: infer TApi }
? TApi extends ImagesApi
? TApi
: never
: never;
export function getImageModel<
TProvider extends KnownImagesProvider,
TModelId extends keyof (typeof IMAGE_MODELS)[TProvider],
>(provider: TProvider, modelId: TModelId): ImagesModel<ImageModelApi<TProvider, TModelId>> {
const providerModels = imageModelRegistry.get(provider);
return providerModels?.get(modelId as string) as ImagesModel<ImageModelApi<TProvider, TModelId>>;
}
export function getImageProviders(): KnownImagesProvider[] {
return Array.from(imageModelRegistry.keys()) as KnownImagesProvider[];
}
export function getImageModels<TProvider extends KnownImagesProvider>(
/** @deprecated Static catalog read. Use `getBuiltinImageModel` from "@earendil-works/pi-ai/providers/all" or `Models.getModelOfType("image", ...)`. */
export function getImageModel<TProvider extends BuiltinImageProvider, TModelId extends ImageModelIds<TProvider>>(
provider: TProvider,
): ImagesModel<ImageModelApi<TProvider, keyof (typeof IMAGE_MODELS)[TProvider]>>[] {
const models = imageModelRegistry.get(provider);
return models
? (Array.from(models.values()) as ImagesModel<ImageModelApi<TProvider, keyof (typeof IMAGE_MODELS)[TProvider]>>[])
: [];
modelId: TModelId,
): BuiltinImageModel<TProvider, TModelId> {
return imageModelsByProvider.get(provider)?.get(modelId as string) as BuiltinImageModel<TProvider, TModelId>;
}
/** @deprecated Static catalog read. Use `Models.getProviders()`. */
export function getImageProviders(): BuiltinImageProvider[] {
return Array.from(imageModelsByProvider.keys()) as BuiltinImageProvider[];
}
/** @deprecated Static catalog read. Use `getBuiltinImageModels` from "@earendil-works/pi-ai/providers/all" or `Models.getModelsOfType("image")`. */
export function getImageModels<TProvider extends BuiltinImageProvider>(
provider: TProvider,
): BuiltinImageModel<TProvider, ImageModelIds<TProvider>>[] {
const models = imageModelsByProvider.get(provider);
return models ? (Array.from(models.values()) as BuiltinImageModel<TProvider, ImageModelIds<TProvider>>[]) : [];
}

View file

@ -1,18 +1,18 @@
import type { AssistantImages, ImagesApi, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "./types.ts";
import type { AssistantImages, ImageApi, ImageModel, ImagesContext, ImagesFunction, ImagesOptions } from "./types.ts";
export type ImagesApiFunction = (
model: ImagesModel<ImagesApi>,
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: ImagesOptions,
) => Promise<AssistantImages>;
export interface ImagesApiProvider<TApi extends ImagesApi = ImagesApi, TOptions extends ImagesOptions = ImagesOptions> {
export interface ImagesApiProvider<TApi extends ImageApi = ImageApi, TOptions extends ImagesOptions = ImagesOptions> {
api: TApi;
generateImages: ImagesFunction<TApi, TOptions>;
generateImages: ImagesFunction<TOptions>;
}
interface ImagesApiProviderInternal {
api: ImagesApi;
api: ImageApi;
generateImages: ImagesApiFunction;
}
@ -23,19 +23,19 @@ type RegisteredImagesApiProvider = {
const imagesApiProviderRegistry = new Map<string, RegisteredImagesApiProvider>();
function wrapGenerateImages<TApi extends ImagesApi, TOptions extends ImagesOptions>(
api: TApi,
generateImages: ImagesFunction<TApi, TOptions>,
function wrapGenerateImages<TOptions extends ImagesOptions>(
api: ImageApi,
generateImages: ImagesFunction<TOptions>,
): ImagesApiFunction {
return (model, context, options) => {
if (model.api !== api) {
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
}
return generateImages(model as ImagesModel<TApi>, context, options as TOptions);
return generateImages(model, context, options as TOptions);
};
}
export function registerImagesApiProvider<TApi extends ImagesApi, TOptions extends ImagesOptions>(
export function registerImagesApiProvider<TApi extends ImageApi, TOptions extends ImagesOptions>(
provider: ImagesApiProvider<TApi, TOptions>,
sourceId?: string,
): void {
@ -48,6 +48,6 @@ export function registerImagesApiProvider<TApi extends ImagesApi, TOptions exten
});
}
export function getImagesApiProvider(api: ImagesApi): ImagesApiProviderInternal | undefined {
export function getImagesApiProvider(api: ImageApi): ImagesApiProviderInternal | undefined {
return imagesApiProviderRegistry.get(api)?.provider;
}

View file

@ -1,275 +0,0 @@
import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";
import { InMemoryCredentialStore } from "./auth/credential-store.ts";
import { type AuthResolutionOverrides, ModelsError, resolveProviderAuth } from "./auth/resolve.ts";
import type { AuthContext, AuthResult, CredentialStore, ProviderAuth } from "./auth/types.ts";
import type { CreateModelsOptions } from "./models.ts";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ImagesOptions, ProviderImages } from "./types.ts";
/**
* An image-generation provider: the image-side counterpart of `Provider`.
* Owns id/name metadata, auth, model listing, and generation behavior.
*/
export interface ImagesProvider {
readonly id: string;
readonly name: string;
/**
* Required: at least one of `apiKey`/`oauth`. Same semantics as chat
* providers; `ImagesModels.getAuth()` returns undefined when the provider
* is unconfigured.
*/
readonly auth: ProviderAuth;
/**
* Current known models, sync. Static providers return their catalog;
* dynamic providers return the list as of the last `refreshModels()`
* (empty before the first). Must not throw; `ImagesModels` treats a
* throwing implementation as having no models.
*/
getModels(): readonly ImagesModel<ImagesApi>[];
/**
* Dynamic providers only: fetch and update the model list. May reject
* (network); on rejection the model list stays at its last-known state
* and a later call retries.
*/
refreshModels?(): Promise<void>;
generateImages(
model: ImagesModel<ImagesApi>,
context: ImagesContext,
options?: ImagesOptions,
): Promise<AssistantImages>;
}
/**
* Runtime collection of image-generation providers plus auth application and
* generation convenience: the image-side counterpart of `Models`.
*/
export interface ImagesModels {
getProviders(): readonly ImagesProvider[];
getProvider(id: string): ImagesProvider | undefined;
/**
* Sync read of last-known models from one provider or all providers.
* Best-effort: a provider whose `getModels()` throws yields no models.
*/
getModels(provider?: string): readonly ImagesModel<ImagesApi>[];
/** Sync runtime model lookup against last-known lists. */
getModel(provider: string, id: string): ImagesModel<ImagesApi> | undefined;
/**
* Ask dynamic providers to re-fetch their model lists. With a provider id,
* rejects with `ModelsError` ("model_source") on that provider's fetch
* failure; without one, refreshes all providers concurrently best-effort.
* Static providers (no `refreshModels`) are no-ops.
*/
refresh(provider?: string): Promise<void>;
/**
* Resolve request auth by provider id or image model. Same contract as
* `Models.getAuth()`: undefined when unknown/unconfigured, rejects with
* `ModelsError` ("oauth"/"auth") on real failures.
*/
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: ImagesModel<ImagesApi>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
/**
* Generate images through the owning provider with auth resolved and
* merged (explicit options win per field). Never rejects; failures are
* returned as an `AssistantImages` with `stopReason: "error"`.
*/
generateImages(
model: ImagesModel<ImagesApi>,
context: ImagesContext,
options?: ImagesOptions,
): Promise<AssistantImages>;
}
export interface MutableImagesModels extends ImagesModels {
/** Upsert/replace by provider.id. Provider ids are unique. */
setProvider(provider: ImagesProvider): void;
deleteProvider(id: string): void;
clearProviders(): void;
}
class ImagesModelsImpl implements MutableImagesModels {
private providers = new Map<string, ImagesProvider>();
private credentials: CredentialStore;
private authContext: AuthContext;
constructor(options?: CreateModelsOptions) {
this.credentials = options?.credentials ?? new InMemoryCredentialStore();
this.authContext = options?.authContext ?? defaultAuthContext();
}
setProvider(provider: ImagesProvider): void {
this.providers.set(provider.id, provider);
}
deleteProvider(id: string): void {
this.providers.delete(id);
}
clearProviders(): void {
this.providers.clear();
}
getProviders(): readonly ImagesProvider[] {
return Array.from(this.providers.values());
}
getProvider(id: string): ImagesProvider | undefined {
return this.providers.get(id);
}
getModels(provider?: string): readonly ImagesModel<ImagesApi>[] {
if (provider !== undefined) {
const entry = this.providers.get(provider);
if (!entry) return [];
try {
return entry.getModels();
} catch {
return [];
}
}
const models: ImagesModel<ImagesApi>[] = [];
for (const entry of this.providers.values()) {
try {
models.push(...entry.getModels());
} catch {
// Best-effort: ill-behaved providers yield no models.
}
}
return models;
}
getModel(provider: string, id: string): ImagesModel<ImagesApi> | undefined {
return this.getModels(provider).find((model) => model.id === id);
}
async refresh(provider?: string): Promise<void> {
if (provider !== undefined) {
const entry = this.providers.get(provider);
if (!entry?.refreshModels) return;
try {
await entry.refreshModels();
} catch (error) {
if (error instanceof ModelsError) throw error;
throw new ModelsError("model_source", `Model refresh failed for ${provider}`, { cause: error });
}
return;
}
// Cannot reject: the async mapper turns even sync throws from ill-behaved
// providers into rejections, and allSettled captures all of them.
await Promise.allSettled(Array.from(this.providers.values(), async (entry) => entry.refreshModels?.()));
}
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: ImagesModel<ImagesApi>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
async getAuth(
providerOrModel: string | ImagesModel<ImagesApi>,
overrides?: AuthResolutionOverrides,
): Promise<AuthResult | undefined> {
const providerId = typeof providerOrModel === "string" ? providerOrModel : providerOrModel.provider;
const provider = this.providers.get(providerId);
if (!provider) return undefined;
return resolveProviderAuth(provider, this.credentials, this.authContext, overrides);
}
async generateImages(
model: ImagesModel<ImagesApi>,
context: ImagesContext,
options?: ImagesOptions,
): Promise<AssistantImages> {
try {
const provider = this.providers.get(model.provider);
if (!provider) {
throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
}
const resolution = await this.getAuth(model, {
apiKey: options?.apiKey,
env: options?.env,
signal: options?.signal,
});
const auth = resolution?.auth;
if (!auth) {
return provider.generateImages(model, context, options);
}
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
// Explicit request options win per-field; headers/env merge per key.
const apiKey = options?.apiKey ?? auth.apiKey;
const headers = auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined;
const env =
resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
return await provider.generateImages(requestModel, context, { ...options, apiKey, headers, env });
} catch (error) {
return {
api: model.api,
provider: model.provider,
model: model.id,
output: [],
stopReason: "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}
}
}
export function createImagesModels(options?: CreateModelsOptions): MutableImagesModels {
return new ImagesModelsImpl(options);
}
export interface CreateImagesProviderOptions {
id: string;
/** Display name. Default: `id`. */
name?: string;
/** Required — every provider has auth semantics, even ambient/keyless ones. */
auth: ProviderAuth;
/** Initial model list (empty for purely dynamic providers). */
models: readonly ImagesModel<ImagesApi>[];
/**
* Dynamic providers: fetch the current list. Stored on success; concurrent
* calls share one in-flight fetch. May reject: the stored list then stays
* at its last-known state, the rejection propagates to the caller of
* `refreshModels()` (wrapped as ModelsError "model_source" by
* `ImagesModels.refresh(provider)`), and a later call retries.
*/
refreshModels?: () => Promise<readonly ImagesModel<ImagesApi>[]>;
api: ProviderImages;
}
/** Builds an image-generation provider from parts. */
export function createImagesProvider(input: CreateImagesProviderOptions): ImagesProvider {
let models = input.models;
let inflightRefresh: Promise<void> | undefined;
const refreshModels = input.refreshModels;
return {
id: input.id,
name: input.name ?? input.id,
auth: input.auth,
getModels: () => models,
refreshModels: refreshModels
? () => {
inflightRefresh ??= (async () => {
try {
models = await refreshModels();
} finally {
inflightRefresh = undefined;
}
})();
return inflightRefresh;
}
: undefined,
generateImages: (model, context, options) => input.api.generateImages(model, context, options),
};
}

View file

@ -1,9 +1,9 @@
import "./providers/images/register-builtins.ts";
import { getImagesApiProvider } from "./images-api-registry.ts";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ProviderImagesOptions } from "./types.ts";
import type { AssistantImages, ImageApi, ImageModel, ImagesContext, ProviderImagesOptions } from "./types.ts";
function resolveImagesApiProvider(api: ImagesApi) {
function resolveImagesApiProvider(api: ImageApi) {
const provider = getImagesApiProvider(api);
if (!provider) {
throw new Error(`No API provider registered for api: ${api}`);
@ -11,8 +11,13 @@ function resolveImagesApiProvider(api: ImagesApi) {
return provider;
}
export async function generateImages<TApi extends ImagesApi>(
model: ImagesModel<TApi>,
/**
* Global image generation dispatched on `model.api` through the images api
* registry. Auth must be passed explicitly via `options.apiKey`; prefer
* `Models.generateImages()`, which resolves provider auth.
*/
export async function generateImages(
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: ProviderImagesOptions,
): Promise<AssistantImages> {

View file

@ -30,7 +30,6 @@ export type {
OAuthSelectOption,
OAuthSelectPrompt,
} from "./compat/extension-oauth-types.ts";
export * from "./images-models.ts";
export * from "./models.ts";
export * from "./models-store.ts";
export * from "./providers/faux.ts";

View file

@ -1,27 +1,81 @@
import type { Api, Model, ProviderId } from "./types.ts";
import type {
Api,
ClassifierApi,
ClassifierModel,
ImageApi,
ImageModel,
Model,
ModelType,
ProviderId,
} from "./types.ts";
export type ModelGroups = Record<string, Record<string, object>>;
type ModelId<TGroups extends ModelGroups> = {
type ModelKey<TGroups extends ModelGroups> = {
[TApi in keyof TGroups]: keyof TGroups[TApi];
}[keyof TGroups] &
string;
type ModelApi<TGroups extends ModelGroups, TModelId extends ModelId<TGroups>> = {
[TApi in keyof TGroups]: TModelId extends keyof TGroups[TApi] ? TApi : never;
}[keyof TGroups] &
Api;
type KeyForType<TGroups extends ModelGroups, TType extends ModelType> = Extract<
ModelKey<TGroups>,
`${TType}:${string}`
>;
export type ModelCatalog<TGroups extends ModelGroups, TProvider extends ProviderId> = {
[TModelId in ModelId<TGroups>]: Model<ModelApi<TGroups, TModelId>> & {
id: TModelId;
type ModelId<TKey extends string> = TKey extends `${ModelType}:${infer TModelId}` ? TModelId : never;
type ApiForKey<TGroups extends ModelGroups, TKey extends ModelKey<TGroups>> = {
[TApi in keyof TGroups]: TKey extends keyof TGroups[TApi] ? TApi : never;
}[keyof TGroups];
export type ChatModelCatalog<TGroups extends ModelGroups, TProvider extends ProviderId> = {
[TKey in KeyForType<TGroups, "chat"> as ModelId<TKey>]: Model<ApiForKey<TGroups, TKey> & Api> & {
id: ModelId<TKey>;
provider: TProvider;
};
};
export function flattenModelCatalog<const TProvider extends ProviderId, const TGroups extends ModelGroups>(
export type ImageModelCatalog<TGroups extends ModelGroups, TProvider extends ProviderId> = {
[TKey in KeyForType<TGroups, "image"> as ModelId<TKey>]: ImageModel<ApiForKey<TGroups, TKey> & ImageApi> & {
id: ModelId<TKey>;
provider: TProvider;
};
};
export type ClassifierModelCatalog<TGroups extends ModelGroups, TProvider extends ProviderId> = {
[TKey in KeyForType<TGroups, "classifier"> as ModelId<TKey>]: ClassifierModel<
ApiForKey<TGroups, TKey> & ClassifierApi
> & {
id: ModelId<TKey>;
provider: TProvider;
};
};
function flattenModelCatalog(groups: ModelGroups, type: ModelType): Record<string, object> {
return Object.fromEntries(
Object.values(groups)
.flatMap((models) => Object.values(models))
.filter((model) => (model as { type?: unknown }).type === type)
.map((model) => [(model as { id: string }).id, model]),
);
}
export function flattenChatModelCatalog<const TProvider extends ProviderId, const TGroups extends ModelGroups>(
_provider: TProvider,
groups: TGroups,
): ModelCatalog<TGroups, TProvider> {
return Object.assign({}, ...Object.values(groups)) as ModelCatalog<TGroups, TProvider>;
): ChatModelCatalog<TGroups, TProvider> {
return flattenModelCatalog(groups, "chat") as ChatModelCatalog<TGroups, TProvider>;
}
export function flattenImageModelCatalog<const TProvider extends ProviderId, const TGroups extends ModelGroups>(
_provider: TProvider,
groups: TGroups,
): ImageModelCatalog<TGroups, TProvider> {
return flattenModelCatalog(groups, "image") as ImageModelCatalog<TGroups, TProvider>;
}
export function flattenClassifierModelCatalog<const TProvider extends ProviderId, const TGroups extends ModelGroups>(
_provider: TProvider,
groups: TGroups,
): ClassifierModelCatalog<TGroups, TProvider> {
return flattenModelCatalog(groups, "classifier") as ClassifierModelCatalog<TGroups, TProvider>;
}

View file

@ -1,7 +1,8 @@
import type { Api, Model } from "./types.ts";
import type { AnyModel } from "./types.ts";
export interface ModelsStoreEntry {
models: readonly Model<Api>[];
/** Persisted models of every type. */
models: readonly AnyModel[];
/** Unix timestamp from the remote catalog's Last-Modified header. */
lastModified?: number;
/** Unix timestamp of the last completed remote check. */

View file

@ -1,47 +1,48 @@
// This file is auto-generated by scripts/generate-models.ts
// Do not edit manually - run 'npm run generate-models' to update
import { AMAZON_BEDROCK_MODELS } from "./providers/amazon-bedrock.models.ts";
import { ANT_LING_MODELS } from "./providers/ant-ling.models.ts";
import { ANTHROPIC_MODELS } from "./providers/anthropic.models.ts";
import { AZURE_OPENAI_RESPONSES_MODELS } from "./providers/azure-openai-responses.models.ts";
import { BASETEN_MODELS } from "./providers/baseten.models.ts";
import { CEREBRAS_MODELS } from "./providers/cerebras.models.ts";
import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./providers/cloudflare-ai-gateway.models.ts";
import { CLOUDFLARE_WORKERS_AI_MODELS } from "./providers/cloudflare-workers-ai.models.ts";
import { DEEPSEEK_MODELS } from "./providers/deepseek.models.ts";
import { FIREWORKS_MODELS } from "./providers/fireworks.models.ts";
import { GITHUB_COPILOT_MODELS } from "./providers/github-copilot.models.ts";
import { GOOGLE_MODELS } from "./providers/google.models.ts";
import { GOOGLE_VERTEX_MODELS } from "./providers/google-vertex.models.ts";
import { GROQ_MODELS } from "./providers/groq.models.ts";
import { HUGGINGFACE_MODELS } from "./providers/huggingface.models.ts";
import { KIMI_CODING_MODELS } from "./providers/kimi-coding.models.ts";
import { META_MODELS } from "./providers/meta.models.ts";
import { MINIMAX_MODELS } from "./providers/minimax.models.ts";
import { MINIMAX_CN_MODELS } from "./providers/minimax-cn.models.ts";
import { MISTRAL_MODELS } from "./providers/mistral.models.ts";
import { MOONSHOTAI_MODELS } from "./providers/moonshotai.models.ts";
import { MOONSHOTAI_CN_MODELS } from "./providers/moonshotai-cn.models.ts";
import { NVIDIA_MODELS } from "./providers/nvidia.models.ts";
import { OPENAI_MODELS } from "./providers/openai.models.ts";
import { OPENAI_CODEX_MODELS } from "./providers/openai-codex.models.ts";
import { OPENCODE_MODELS } from "./providers/opencode.models.ts";
import { OPENCODE_GO_MODELS } from "./providers/opencode-go.models.ts";
import { OPENROUTER_MODELS } from "./providers/openrouter.models.ts";
import { QWEN_TOKEN_PLAN_MODELS } from "./providers/qwen-token-plan.models.ts";
import { QWEN_TOKEN_PLAN_CN_MODELS } from "./providers/qwen-token-plan-cn.models.ts";
import { QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS } from "./providers/qwen-token-plan-individual.models.ts";
import { RADIUS_MODELS } from "./providers/radius.models.ts";
import { TOGETHER_MODELS } from "./providers/together.models.ts";
import { VERCEL_AI_GATEWAY_MODELS } from "./providers/vercel-ai-gateway.models.ts";
import { XAI_MODELS } from "./providers/xai.models.ts";
import { XIAOMI_MODELS } from "./providers/xiaomi.models.ts";
import { XIAOMI_TOKEN_PLAN_AMS_MODELS } from "./providers/xiaomi-token-plan-ams.models.ts";
import { XIAOMI_TOKEN_PLAN_CN_MODELS } from "./providers/xiaomi-token-plan-cn.models.ts";
import { XIAOMI_TOKEN_PLAN_SGP_MODELS } from "./providers/xiaomi-token-plan-sgp.models.ts";
import { ZAI_MODELS } from "./providers/zai.models.ts";
import { ZAI_CODING_CN_MODELS } from "./providers/zai-coding-cn.models.ts";
import { AMAZON_BEDROCK_CLASSIFIER_MODELS, AMAZON_BEDROCK_IMAGE_MODELS, AMAZON_BEDROCK_MODELS } from "./providers/amazon-bedrock.models.ts";
import { ANT_LING_CLASSIFIER_MODELS, ANT_LING_IMAGE_MODELS, ANT_LING_MODELS } from "./providers/ant-ling.models.ts";
import { ANTHROPIC_CLASSIFIER_MODELS, ANTHROPIC_IMAGE_MODELS, ANTHROPIC_MODELS } from "./providers/anthropic.models.ts";
import { AZURE_OPENAI_RESPONSES_CLASSIFIER_MODELS, AZURE_OPENAI_RESPONSES_IMAGE_MODELS, AZURE_OPENAI_RESPONSES_MODELS } from "./providers/azure-openai-responses.models.ts";
import { BASETEN_CLASSIFIER_MODELS, BASETEN_IMAGE_MODELS, BASETEN_MODELS } from "./providers/baseten.models.ts";
import { CEREBRAS_CLASSIFIER_MODELS, CEREBRAS_IMAGE_MODELS, CEREBRAS_MODELS } from "./providers/cerebras.models.ts";
import { CLOUDFLARE_AI_GATEWAY_CLASSIFIER_MODELS, CLOUDFLARE_AI_GATEWAY_IMAGE_MODELS, CLOUDFLARE_AI_GATEWAY_MODELS } from "./providers/cloudflare-ai-gateway.models.ts";
import { CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS, CLOUDFLARE_WORKERS_AI_IMAGE_MODELS, CLOUDFLARE_WORKERS_AI_MODELS } from "./providers/cloudflare-workers-ai.models.ts";
import { DEEPSEEK_CLASSIFIER_MODELS, DEEPSEEK_IMAGE_MODELS, DEEPSEEK_MODELS } from "./providers/deepseek.models.ts";
import { FIREWORKS_CLASSIFIER_MODELS, FIREWORKS_IMAGE_MODELS, FIREWORKS_MODELS } from "./providers/fireworks.models.ts";
import { GITHUB_COPILOT_CLASSIFIER_MODELS, GITHUB_COPILOT_IMAGE_MODELS, GITHUB_COPILOT_MODELS } from "./providers/github-copilot.models.ts";
import { GOOGLE_CLASSIFIER_MODELS, GOOGLE_IMAGE_MODELS, GOOGLE_MODELS } from "./providers/google.models.ts";
import { GOOGLE_VERTEX_CLASSIFIER_MODELS, GOOGLE_VERTEX_IMAGE_MODELS, GOOGLE_VERTEX_MODELS } from "./providers/google-vertex.models.ts";
import { GROQ_CLASSIFIER_MODELS, GROQ_IMAGE_MODELS, GROQ_MODELS } from "./providers/groq.models.ts";
import { HUGGINGFACE_CLASSIFIER_MODELS, HUGGINGFACE_IMAGE_MODELS, HUGGINGFACE_MODELS } from "./providers/huggingface.models.ts";
import { KIMI_CODING_CLASSIFIER_MODELS, KIMI_CODING_IMAGE_MODELS, KIMI_CODING_MODELS } from "./providers/kimi-coding.models.ts";
import { META_CLASSIFIER_MODELS, META_IMAGE_MODELS, META_MODELS } from "./providers/meta.models.ts";
import { MINIMAX_CLASSIFIER_MODELS, MINIMAX_IMAGE_MODELS, MINIMAX_MODELS } from "./providers/minimax.models.ts";
import { MINIMAX_CN_CLASSIFIER_MODELS, MINIMAX_CN_IMAGE_MODELS, MINIMAX_CN_MODELS } from "./providers/minimax-cn.models.ts";
import { MISTRAL_CLASSIFIER_MODELS, MISTRAL_IMAGE_MODELS, MISTRAL_MODELS } from "./providers/mistral.models.ts";
import { MOONSHOTAI_CLASSIFIER_MODELS, MOONSHOTAI_IMAGE_MODELS, MOONSHOTAI_MODELS } from "./providers/moonshotai.models.ts";
import { MOONSHOTAI_CN_CLASSIFIER_MODELS, MOONSHOTAI_CN_IMAGE_MODELS, MOONSHOTAI_CN_MODELS } from "./providers/moonshotai-cn.models.ts";
import { NVIDIA_CLASSIFIER_MODELS, NVIDIA_IMAGE_MODELS, NVIDIA_MODELS } from "./providers/nvidia.models.ts";
import { OPENAI_CLASSIFIER_MODELS, OPENAI_IMAGE_MODELS, OPENAI_MODELS } from "./providers/openai.models.ts";
import { OPENAI_CODEX_CLASSIFIER_MODELS, OPENAI_CODEX_IMAGE_MODELS, OPENAI_CODEX_MODELS } from "./providers/openai-codex.models.ts";
import { OPENCODE_CLASSIFIER_MODELS, OPENCODE_IMAGE_MODELS, OPENCODE_MODELS } from "./providers/opencode.models.ts";
import { OPENCODE_GO_CLASSIFIER_MODELS, OPENCODE_GO_IMAGE_MODELS, OPENCODE_GO_MODELS } from "./providers/opencode-go.models.ts";
import { OPENROUTER_CLASSIFIER_MODELS, OPENROUTER_IMAGE_MODELS, OPENROUTER_MODELS } from "./providers/openrouter.models.ts";
import { QWEN_TOKEN_PLAN_CLASSIFIER_MODELS, QWEN_TOKEN_PLAN_IMAGE_MODELS, QWEN_TOKEN_PLAN_MODELS } from "./providers/qwen-token-plan.models.ts";
import { QWEN_TOKEN_PLAN_CN_CLASSIFIER_MODELS, QWEN_TOKEN_PLAN_CN_IMAGE_MODELS, QWEN_TOKEN_PLAN_CN_MODELS } from "./providers/qwen-token-plan-cn.models.ts";
import { QWEN_TOKEN_PLAN_INDIVIDUAL_CLASSIFIER_MODELS, QWEN_TOKEN_PLAN_INDIVIDUAL_IMAGE_MODELS, QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS } from "./providers/qwen-token-plan-individual.models.ts";
import { RADIUS_CLASSIFIER_MODELS, RADIUS_IMAGE_MODELS, RADIUS_MODELS } from "./providers/radius.models.ts";
import { TOGETHER_CLASSIFIER_MODELS, TOGETHER_IMAGE_MODELS, TOGETHER_MODELS } from "./providers/together.models.ts";
import { TYPESAFE_CLASSIFIER_MODELS, TYPESAFE_IMAGE_MODELS, TYPESAFE_MODELS } from "./providers/typesafe.models.ts";
import { VERCEL_AI_GATEWAY_CLASSIFIER_MODELS, VERCEL_AI_GATEWAY_IMAGE_MODELS, VERCEL_AI_GATEWAY_MODELS } from "./providers/vercel-ai-gateway.models.ts";
import { XAI_CLASSIFIER_MODELS, XAI_IMAGE_MODELS, XAI_MODELS } from "./providers/xai.models.ts";
import { XIAOMI_CLASSIFIER_MODELS, XIAOMI_IMAGE_MODELS, XIAOMI_MODELS } from "./providers/xiaomi.models.ts";
import { XIAOMI_TOKEN_PLAN_AMS_CLASSIFIER_MODELS, XIAOMI_TOKEN_PLAN_AMS_IMAGE_MODELS, XIAOMI_TOKEN_PLAN_AMS_MODELS } from "./providers/xiaomi-token-plan-ams.models.ts";
import { XIAOMI_TOKEN_PLAN_CN_CLASSIFIER_MODELS, XIAOMI_TOKEN_PLAN_CN_IMAGE_MODELS, XIAOMI_TOKEN_PLAN_CN_MODELS } from "./providers/xiaomi-token-plan-cn.models.ts";
import { XIAOMI_TOKEN_PLAN_SGP_CLASSIFIER_MODELS, XIAOMI_TOKEN_PLAN_SGP_IMAGE_MODELS, XIAOMI_TOKEN_PLAN_SGP_MODELS } from "./providers/xiaomi-token-plan-sgp.models.ts";
import { ZAI_CLASSIFIER_MODELS, ZAI_IMAGE_MODELS, ZAI_MODELS } from "./providers/zai.models.ts";
import { ZAI_CODING_CN_CLASSIFIER_MODELS, ZAI_CODING_CN_IMAGE_MODELS, ZAI_CODING_CN_MODELS } from "./providers/zai-coding-cn.models.ts";
export const MODELS: {
readonly "amazon-bedrock": typeof AMAZON_BEDROCK_MODELS;
@ -77,6 +78,7 @@ export const MODELS: {
readonly "qwen-token-plan-individual": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS;
readonly "radius": typeof RADIUS_MODELS;
readonly "together": typeof TOGETHER_MODELS;
readonly "typesafe": typeof TYPESAFE_MODELS;
readonly "vercel-ai-gateway": typeof VERCEL_AI_GATEWAY_MODELS;
readonly "xai": typeof XAI_MODELS;
readonly "xiaomi": typeof XIAOMI_MODELS;
@ -119,6 +121,7 @@ export const MODELS: {
"qwen-token-plan-individual": QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS,
"radius": RADIUS_MODELS,
"together": TOGETHER_MODELS,
"typesafe": TYPESAFE_MODELS,
"vercel-ai-gateway": VERCEL_AI_GATEWAY_MODELS,
"xai": XAI_MODELS,
"xiaomi": XIAOMI_MODELS,
@ -128,3 +131,179 @@ export const MODELS: {
"zai": ZAI_MODELS,
"zai-coding-cn": ZAI_CODING_CN_MODELS,
};
export const IMAGE_MODELS: {
readonly "amazon-bedrock": typeof AMAZON_BEDROCK_IMAGE_MODELS;
readonly "ant-ling": typeof ANT_LING_IMAGE_MODELS;
readonly "anthropic": typeof ANTHROPIC_IMAGE_MODELS;
readonly "azure-openai-responses": typeof AZURE_OPENAI_RESPONSES_IMAGE_MODELS;
readonly "baseten": typeof BASETEN_IMAGE_MODELS;
readonly "cerebras": typeof CEREBRAS_IMAGE_MODELS;
readonly "cloudflare-ai-gateway": typeof CLOUDFLARE_AI_GATEWAY_IMAGE_MODELS;
readonly "cloudflare-workers-ai": typeof CLOUDFLARE_WORKERS_AI_IMAGE_MODELS;
readonly "deepseek": typeof DEEPSEEK_IMAGE_MODELS;
readonly "fireworks": typeof FIREWORKS_IMAGE_MODELS;
readonly "github-copilot": typeof GITHUB_COPILOT_IMAGE_MODELS;
readonly "google": typeof GOOGLE_IMAGE_MODELS;
readonly "google-vertex": typeof GOOGLE_VERTEX_IMAGE_MODELS;
readonly "groq": typeof GROQ_IMAGE_MODELS;
readonly "huggingface": typeof HUGGINGFACE_IMAGE_MODELS;
readonly "kimi-coding": typeof KIMI_CODING_IMAGE_MODELS;
readonly "meta": typeof META_IMAGE_MODELS;
readonly "minimax": typeof MINIMAX_IMAGE_MODELS;
readonly "minimax-cn": typeof MINIMAX_CN_IMAGE_MODELS;
readonly "mistral": typeof MISTRAL_IMAGE_MODELS;
readonly "moonshotai": typeof MOONSHOTAI_IMAGE_MODELS;
readonly "moonshotai-cn": typeof MOONSHOTAI_CN_IMAGE_MODELS;
readonly "nvidia": typeof NVIDIA_IMAGE_MODELS;
readonly "openai": typeof OPENAI_IMAGE_MODELS;
readonly "openai-codex": typeof OPENAI_CODEX_IMAGE_MODELS;
readonly "opencode": typeof OPENCODE_IMAGE_MODELS;
readonly "opencode-go": typeof OPENCODE_GO_IMAGE_MODELS;
readonly "openrouter": typeof OPENROUTER_IMAGE_MODELS;
readonly "qwen-token-plan": typeof QWEN_TOKEN_PLAN_IMAGE_MODELS;
readonly "qwen-token-plan-cn": typeof QWEN_TOKEN_PLAN_CN_IMAGE_MODELS;
readonly "qwen-token-plan-individual": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_IMAGE_MODELS;
readonly "radius": typeof RADIUS_IMAGE_MODELS;
readonly "together": typeof TOGETHER_IMAGE_MODELS;
readonly "typesafe": typeof TYPESAFE_IMAGE_MODELS;
readonly "vercel-ai-gateway": typeof VERCEL_AI_GATEWAY_IMAGE_MODELS;
readonly "xai": typeof XAI_IMAGE_MODELS;
readonly "xiaomi": typeof XIAOMI_IMAGE_MODELS;
readonly "xiaomi-token-plan-ams": typeof XIAOMI_TOKEN_PLAN_AMS_IMAGE_MODELS;
readonly "xiaomi-token-plan-cn": typeof XIAOMI_TOKEN_PLAN_CN_IMAGE_MODELS;
readonly "xiaomi-token-plan-sgp": typeof XIAOMI_TOKEN_PLAN_SGP_IMAGE_MODELS;
readonly "zai": typeof ZAI_IMAGE_MODELS;
readonly "zai-coding-cn": typeof ZAI_CODING_CN_IMAGE_MODELS;
} = {
"amazon-bedrock": AMAZON_BEDROCK_IMAGE_MODELS,
"ant-ling": ANT_LING_IMAGE_MODELS,
"anthropic": ANTHROPIC_IMAGE_MODELS,
"azure-openai-responses": AZURE_OPENAI_RESPONSES_IMAGE_MODELS,
"baseten": BASETEN_IMAGE_MODELS,
"cerebras": CEREBRAS_IMAGE_MODELS,
"cloudflare-ai-gateway": CLOUDFLARE_AI_GATEWAY_IMAGE_MODELS,
"cloudflare-workers-ai": CLOUDFLARE_WORKERS_AI_IMAGE_MODELS,
"deepseek": DEEPSEEK_IMAGE_MODELS,
"fireworks": FIREWORKS_IMAGE_MODELS,
"github-copilot": GITHUB_COPILOT_IMAGE_MODELS,
"google": GOOGLE_IMAGE_MODELS,
"google-vertex": GOOGLE_VERTEX_IMAGE_MODELS,
"groq": GROQ_IMAGE_MODELS,
"huggingface": HUGGINGFACE_IMAGE_MODELS,
"kimi-coding": KIMI_CODING_IMAGE_MODELS,
"meta": META_IMAGE_MODELS,
"minimax": MINIMAX_IMAGE_MODELS,
"minimax-cn": MINIMAX_CN_IMAGE_MODELS,
"mistral": MISTRAL_IMAGE_MODELS,
"moonshotai": MOONSHOTAI_IMAGE_MODELS,
"moonshotai-cn": MOONSHOTAI_CN_IMAGE_MODELS,
"nvidia": NVIDIA_IMAGE_MODELS,
"openai": OPENAI_IMAGE_MODELS,
"openai-codex": OPENAI_CODEX_IMAGE_MODELS,
"opencode": OPENCODE_IMAGE_MODELS,
"opencode-go": OPENCODE_GO_IMAGE_MODELS,
"openrouter": OPENROUTER_IMAGE_MODELS,
"qwen-token-plan": QWEN_TOKEN_PLAN_IMAGE_MODELS,
"qwen-token-plan-cn": QWEN_TOKEN_PLAN_CN_IMAGE_MODELS,
"qwen-token-plan-individual": QWEN_TOKEN_PLAN_INDIVIDUAL_IMAGE_MODELS,
"radius": RADIUS_IMAGE_MODELS,
"together": TOGETHER_IMAGE_MODELS,
"typesafe": TYPESAFE_IMAGE_MODELS,
"vercel-ai-gateway": VERCEL_AI_GATEWAY_IMAGE_MODELS,
"xai": XAI_IMAGE_MODELS,
"xiaomi": XIAOMI_IMAGE_MODELS,
"xiaomi-token-plan-ams": XIAOMI_TOKEN_PLAN_AMS_IMAGE_MODELS,
"xiaomi-token-plan-cn": XIAOMI_TOKEN_PLAN_CN_IMAGE_MODELS,
"xiaomi-token-plan-sgp": XIAOMI_TOKEN_PLAN_SGP_IMAGE_MODELS,
"zai": ZAI_IMAGE_MODELS,
"zai-coding-cn": ZAI_CODING_CN_IMAGE_MODELS,
};
export const CLASSIFIER_MODELS: {
readonly "amazon-bedrock": typeof AMAZON_BEDROCK_CLASSIFIER_MODELS;
readonly "ant-ling": typeof ANT_LING_CLASSIFIER_MODELS;
readonly "anthropic": typeof ANTHROPIC_CLASSIFIER_MODELS;
readonly "azure-openai-responses": typeof AZURE_OPENAI_RESPONSES_CLASSIFIER_MODELS;
readonly "baseten": typeof BASETEN_CLASSIFIER_MODELS;
readonly "cerebras": typeof CEREBRAS_CLASSIFIER_MODELS;
readonly "cloudflare-ai-gateway": typeof CLOUDFLARE_AI_GATEWAY_CLASSIFIER_MODELS;
readonly "cloudflare-workers-ai": typeof CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS;
readonly "deepseek": typeof DEEPSEEK_CLASSIFIER_MODELS;
readonly "fireworks": typeof FIREWORKS_CLASSIFIER_MODELS;
readonly "github-copilot": typeof GITHUB_COPILOT_CLASSIFIER_MODELS;
readonly "google": typeof GOOGLE_CLASSIFIER_MODELS;
readonly "google-vertex": typeof GOOGLE_VERTEX_CLASSIFIER_MODELS;
readonly "groq": typeof GROQ_CLASSIFIER_MODELS;
readonly "huggingface": typeof HUGGINGFACE_CLASSIFIER_MODELS;
readonly "kimi-coding": typeof KIMI_CODING_CLASSIFIER_MODELS;
readonly "meta": typeof META_CLASSIFIER_MODELS;
readonly "minimax": typeof MINIMAX_CLASSIFIER_MODELS;
readonly "minimax-cn": typeof MINIMAX_CN_CLASSIFIER_MODELS;
readonly "mistral": typeof MISTRAL_CLASSIFIER_MODELS;
readonly "moonshotai": typeof MOONSHOTAI_CLASSIFIER_MODELS;
readonly "moonshotai-cn": typeof MOONSHOTAI_CN_CLASSIFIER_MODELS;
readonly "nvidia": typeof NVIDIA_CLASSIFIER_MODELS;
readonly "openai": typeof OPENAI_CLASSIFIER_MODELS;
readonly "openai-codex": typeof OPENAI_CODEX_CLASSIFIER_MODELS;
readonly "opencode": typeof OPENCODE_CLASSIFIER_MODELS;
readonly "opencode-go": typeof OPENCODE_GO_CLASSIFIER_MODELS;
readonly "openrouter": typeof OPENROUTER_CLASSIFIER_MODELS;
readonly "qwen-token-plan": typeof QWEN_TOKEN_PLAN_CLASSIFIER_MODELS;
readonly "qwen-token-plan-cn": typeof QWEN_TOKEN_PLAN_CN_CLASSIFIER_MODELS;
readonly "qwen-token-plan-individual": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_CLASSIFIER_MODELS;
readonly "radius": typeof RADIUS_CLASSIFIER_MODELS;
readonly "together": typeof TOGETHER_CLASSIFIER_MODELS;
readonly "typesafe": typeof TYPESAFE_CLASSIFIER_MODELS;
readonly "vercel-ai-gateway": typeof VERCEL_AI_GATEWAY_CLASSIFIER_MODELS;
readonly "xai": typeof XAI_CLASSIFIER_MODELS;
readonly "xiaomi": typeof XIAOMI_CLASSIFIER_MODELS;
readonly "xiaomi-token-plan-ams": typeof XIAOMI_TOKEN_PLAN_AMS_CLASSIFIER_MODELS;
readonly "xiaomi-token-plan-cn": typeof XIAOMI_TOKEN_PLAN_CN_CLASSIFIER_MODELS;
readonly "xiaomi-token-plan-sgp": typeof XIAOMI_TOKEN_PLAN_SGP_CLASSIFIER_MODELS;
readonly "zai": typeof ZAI_CLASSIFIER_MODELS;
readonly "zai-coding-cn": typeof ZAI_CODING_CN_CLASSIFIER_MODELS;
} = {
"amazon-bedrock": AMAZON_BEDROCK_CLASSIFIER_MODELS,
"ant-ling": ANT_LING_CLASSIFIER_MODELS,
"anthropic": ANTHROPIC_CLASSIFIER_MODELS,
"azure-openai-responses": AZURE_OPENAI_RESPONSES_CLASSIFIER_MODELS,
"baseten": BASETEN_CLASSIFIER_MODELS,
"cerebras": CEREBRAS_CLASSIFIER_MODELS,
"cloudflare-ai-gateway": CLOUDFLARE_AI_GATEWAY_CLASSIFIER_MODELS,
"cloudflare-workers-ai": CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS,
"deepseek": DEEPSEEK_CLASSIFIER_MODELS,
"fireworks": FIREWORKS_CLASSIFIER_MODELS,
"github-copilot": GITHUB_COPILOT_CLASSIFIER_MODELS,
"google": GOOGLE_CLASSIFIER_MODELS,
"google-vertex": GOOGLE_VERTEX_CLASSIFIER_MODELS,
"groq": GROQ_CLASSIFIER_MODELS,
"huggingface": HUGGINGFACE_CLASSIFIER_MODELS,
"kimi-coding": KIMI_CODING_CLASSIFIER_MODELS,
"meta": META_CLASSIFIER_MODELS,
"minimax": MINIMAX_CLASSIFIER_MODELS,
"minimax-cn": MINIMAX_CN_CLASSIFIER_MODELS,
"mistral": MISTRAL_CLASSIFIER_MODELS,
"moonshotai": MOONSHOTAI_CLASSIFIER_MODELS,
"moonshotai-cn": MOONSHOTAI_CN_CLASSIFIER_MODELS,
"nvidia": NVIDIA_CLASSIFIER_MODELS,
"openai": OPENAI_CLASSIFIER_MODELS,
"openai-codex": OPENAI_CODEX_CLASSIFIER_MODELS,
"opencode": OPENCODE_CLASSIFIER_MODELS,
"opencode-go": OPENCODE_GO_CLASSIFIER_MODELS,
"openrouter": OPENROUTER_CLASSIFIER_MODELS,
"qwen-token-plan": QWEN_TOKEN_PLAN_CLASSIFIER_MODELS,
"qwen-token-plan-cn": QWEN_TOKEN_PLAN_CN_CLASSIFIER_MODELS,
"qwen-token-plan-individual": QWEN_TOKEN_PLAN_INDIVIDUAL_CLASSIFIER_MODELS,
"radius": RADIUS_CLASSIFIER_MODELS,
"together": TOGETHER_CLASSIFIER_MODELS,
"typesafe": TYPESAFE_CLASSIFIER_MODELS,
"vercel-ai-gateway": VERCEL_AI_GATEWAY_CLASSIFIER_MODELS,
"xai": XAI_CLASSIFIER_MODELS,
"xiaomi": XIAOMI_CLASSIFIER_MODELS,
"xiaomi-token-plan-ams": XIAOMI_TOKEN_PLAN_AMS_CLASSIFIER_MODELS,
"xiaomi-token-plan-cn": XIAOMI_TOKEN_PLAN_CN_CLASSIFIER_MODELS,
"xiaomi-token-plan-sgp": XIAOMI_TOKEN_PLAN_SGP_CLASSIFIER_MODELS,
"zai": ZAI_CLASSIFIER_MODELS,
"zai-coding-cn": ZAI_CODING_CN_CLASSIFIER_MODELS,
};

View file

@ -15,18 +15,33 @@ import type {
} from "./auth/types.ts";
import { InMemoryModelsStore, type ModelsStore, type ModelsStoreEntry } from "./models-store.ts";
import type {
AnyModel,
Api,
ApiStreamOptions,
AssistantImages,
AssistantMessage,
AssistantMessageEventStream,
ClassifierApi,
ClassifierContext,
ClassifierModel,
ClassifierOptions,
ClassifierResult,
Context,
DeferredCancelOptions,
DeferredFetchOptions,
DeferredHandle,
ImageApi,
ImageModel,
ImagesContext,
ImagesOptions,
Model,
ModelCostRates,
ModelThinkingLevel,
ModelType,
ModelTypeMap,
ProviderClassifier,
ProviderHeaders,
ProviderImages,
ProviderRequestOptions,
ProviderStreams,
SimpleStreamOptions,
@ -34,9 +49,19 @@ import type {
Usage,
} from "./types.ts";
import { operationSignal, raceWithAbortSignal } from "./utils/abort.ts";
import {
assertChatModel,
assertClassifierModel,
assertImageModel,
classifierErrorResult,
getModelType,
imageErrorResult,
isModelType,
} from "./utils/model-operations.ts";
import { normalizeContext } from "./utils/transcript.ts";
export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";
export { getModelType, isModelType } from "./utils/model-operations.ts";
export interface ModelsPublication {
/** Provider-selected persisted catalog. Omit to leave storage unchanged; null deletes it. */
@ -86,15 +111,34 @@ export type ModelsApiStreamOptions<TApi extends Api> = ApiStreamOptions<TApi> &
export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsRequestTransforms;
export type ModelsDeferredFetchOptions = DeferredFetchOptions & ModelsRequestTransforms;
export type ModelsDeferredCancelOptions = DeferredCancelOptions & ModelsRequestTransforms;
export type ModelsImagesOptions = ImagesOptions & ModelsRequestTransforms;
export type ModelsClassifierOptions = ClassifierOptions & ModelsRequestTransforms;
const KNOWN_MODEL_TYPES: Record<ModelType, true> = { chat: true, image: true, classifier: true };
/** Models from stores and remote sources may have types that only newer versions know. */
function hasKnownModelType(model: AnyModel): boolean {
return Object.hasOwn(KNOWN_MODEL_TYPES, getModelType(model));
}
/** Drops stored models whose type this version does not know. */
function withKnownModelTypes(entry: ModelsStoreEntry): ModelsStoreEntry {
return { ...entry, models: entry.models.filter(hasKnownModelType) };
}
/** Any model a provider with chat APIs `TApi` can list. */
type ProviderModel<TApi extends Api> = Model<TApi> | ImageModel<ImageApi> | ClassifierModel<ClassifierApi>;
/**
* A provider is the concrete runtime unit. It owns id/name/base metadata,
* auth methods, model listing, and stream behavior.
* auth methods, model listing, and the operations its models support
* (streaming, image generation, classification).
*
* `TApi` lets concrete provider factories declare which APIs their models
* `TApi` lets concrete provider factories declare which chat APIs their models
* use (e.g. `openaiProvider(): Provider<"openai-responses" | "openai-completions">`),
* giving typed model lists to direct factory users. Inside a `Models`
* collection providers are held as `Provider<Api>`.
* giving typed chat model lists to direct factory users. Other model types use
* their operation-specific API unions. Inside a `Models` collection providers
* are held as `Provider<Api>`.
*/
export interface Provider<TApi extends Api = Api> {
readonly id: string;
@ -113,13 +157,21 @@ export interface Provider<TApi extends Api = Api> {
readonly auth: ProviderAuth;
/**
* Current known models, sync. Static providers return their catalog;
* dynamic providers return the list as of the last `refreshModels()`
* (empty before the first). Must not throw; `Models` treats a throwing
* Current known chat models, sync. Static providers return their catalog;
* dynamic providers return the list as of the last `refreshModels()` (empty
* before the first). Must not throw; `Models` treats a throwing
* implementation as having no models.
*/
getModels(): readonly Model<TApi>[];
/**
* Current known models of every type, sync, with the same contract as
* `getModels()`. Providers with only chat models may omit it; `Models` then
* uses `getModels()`. Model ids are unique within each type; one upstream
* model may have separate entries for different operations.
*/
getAllModels?(): readonly ProviderModel<TApi>[];
/**
* Dynamic providers only: restore `context.stored` and optionally fetch a newer list using
* the effective credential. Implementations retain their previous list on failure, publish
@ -130,11 +182,21 @@ export interface Provider<TApi extends Api = Api> {
/**
* Optional provider policy for credential-specific model availability.
* `getModels()` remains the complete synchronous catalog; `Models.getAvailable()`
* `getModels()` remains the complete synchronous chat catalog; `Models.getAvailable()`
* applies this filter after confirming that provider auth is configured.
*/
filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
/**
* Optional credential-specific availability policy across every model type.
* Without it, `Models.getAllAvailable()` applies `filterModels` to chat models
* and keeps every other model.
*/
filterAllModels?(
models: readonly ProviderModel<TApi>[],
credential: Credential | undefined,
): readonly ProviderModel<TApi>[];
/** Stream a normalized transcript. `Models` normalizes the caller's `Context` before dispatching here. */
stream<T extends TApi>(
model: Model<T>,
@ -153,29 +215,56 @@ export interface Provider<TApi extends Api = Api> {
options?: DeferredFetchOptions,
): AssistantMessageEventStream;
cancelDeferred?(model: Model<TApi>, handle: DeferredHandle, options?: DeferredCancelOptions): Promise<void>;
/** Present when the provider supports dedicated image models. Never rejects. */
generateImages?(
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: ImagesOptions,
): Promise<AssistantImages>;
/** Present when the provider supports structured classifier models. Never rejects. */
classify?(
model: ClassifierModel<ClassifierApi>,
context: ClassifierContext,
options?: ClassifierOptions,
): Promise<ClassifierResult>;
}
/**
* Runtime collection of providers plus auth application and stream
* convenience. Providers own stream behavior; `Models` resolves auth and
* Runtime collection of providers plus auth application and request
* convenience. Providers own request behavior; `Models` resolves auth and
* delegates each request to the provider that owns the model.
*
* Read accessors come in three flavors: the unqualified ones (`getModels`,
* `getModel`, `getAvailable`) return chat models, the `*OfType` accessors
* return one model type, and `getAllModels`/`getAllAvailable` return every type.
*/
export interface Models {
getProviders(): readonly Provider[];
getProvider(id: string): Provider | undefined;
/**
* Sync read of last-known models from one provider or all providers.
* Sync read of last-known chat models from one provider or all providers.
* Best-effort: a provider whose `getModels()` throws yields no models.
*/
getModels(provider?: string): readonly Model<Api>[];
/**
* Sync runtime model lookup against last-known lists. Dynamic model lists
* Sync runtime chat model lookup against last-known lists. Dynamic model lists
* are typed as `Model<Api>`; narrow with the `hasApi()` type guard.
*/
getModel(provider: string, id: string): Model<Api> | undefined;
/** Sync read of last-known models of one type from one provider or all providers. */
getModelsOfType<TType extends ModelType>(type: TType, provider?: string): readonly ModelTypeMap[TType][];
/** Sync runtime lookup of a model of one type against last-known lists. */
getModelOfType<TType extends ModelType>(type: TType, provider: string, id: string): ModelTypeMap[TType] | undefined;
/** Sync read of last-known models of every type from one provider or all providers. */
getAllModels(provider?: string): readonly AnyModel[];
/**
* Refresh selected configured dynamic providers concurrently (all when `providers` is omitted).
* Provider errors and cancellation are returned without rejecting; static, unknown, and
@ -186,9 +275,19 @@ export interface Models {
/** Check whether a provider has complete auth configuration without refreshing OAuth. */
checkAuth(providerId: string, options?: AuthOperationOptions): Promise<AuthCheck | undefined>;
/** Return models whose providers have complete auth configuration. */
/** Return chat models whose providers have complete auth configuration. */
getAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly Model<Api>[]>;
/** Return models of one type whose providers have complete auth configuration. */
getAvailableOfType<TType extends ModelType>(
type: TType,
providerId?: string,
options?: AuthOperationOptions,
): Promise<readonly ModelTypeMap[TType][]>;
/** Return models of every type whose providers have complete auth configuration. */
getAllAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly AnyModel[]>;
/**
* Resolve provider-scoped auth by provider id, or provider auth plus static
* model headers when passed a model. Includes a source label for status UI.
@ -199,7 +298,7 @@ export interface Models {
* surface rejections as stream errors.
*/
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: Model<Api>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: AnyModel, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
/** Run a provider-owned login flow and persist its returned credential. */
login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential>;
@ -232,6 +331,24 @@ export interface Models {
options?: ModelsDeferredFetchOptions,
): Promise<AssistantMessage>;
cancelDeferred(model: Model<Api>, handle: DeferredHandle, options?: ModelsDeferredCancelOptions): Promise<void>;
/**
* Generate images through the owning provider with auth resolved like
* `stream()`. Never rejects: unknown providers, unconfigured auth, and
* providers without `generateImages` return an error `AssistantImages`.
*/
generateImages(
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: ModelsImagesOptions,
): Promise<AssistantImages>;
/** Classify structured state through the owning provider. Never rejects. */
classify(
model: ClassifierModel<ClassifierApi>,
context: ClassifierContext,
options?: ModelsClassifierOptions,
): Promise<ClassifierResult>;
}
export interface MutableModels extends Models {
@ -325,10 +442,40 @@ class ModelsImpl implements MutableModels {
return models;
}
getAllModels(provider?: string): readonly AnyModel[] {
if (provider !== undefined) {
const entry = this.providers.get(provider);
if (!entry) return [];
try {
return entry.getAllModels?.() ?? entry.getModels();
} catch {
return [];
}
}
const models: AnyModel[] = [];
for (const entry of this.providers.values()) {
try {
models.push(...(entry.getAllModels?.() ?? entry.getModels()));
} catch {
// Best-effort: ill-behaved providers yield no models.
}
}
return models;
}
getModelsOfType<TType extends ModelType>(type: TType, provider?: string): readonly ModelTypeMap[TType][] {
return this.getAllModels(provider).filter((model): model is ModelTypeMap[TType] => isModelType(model, type));
}
getModel(provider: string, id: string): Model<Api> | undefined {
return this.getModels(provider).find((model) => model.id === id);
}
getModelOfType<TType extends ModelType>(type: TType, provider: string, id: string): ModelTypeMap[TType] | undefined {
return this.getModelsOfType(type, provider).find((model) => model.id === id);
}
private supersedeProviderRefresh(providerId: string): number {
const generation = (this.refreshGenerations.get(providerId) ?? 0) + 1;
this.refreshGenerations.set(providerId, generation);
@ -387,7 +534,7 @@ class ModelsImpl implements MutableModels {
const stored = await this.modelsStore.read(provider.id, { signal });
await provider.refreshModels({
credential,
stored: stored ? structuredClone(stored) : undefined,
stored: stored ? withKnownModelTypes(structuredClone(stored)) : undefined,
publish: (publication) => this.publishProviderModels(provider.id, generation, signal, publication),
allowNetwork,
force: allowNetwork ? force : undefined,
@ -531,9 +678,7 @@ class ModelsImpl implements MutableModels {
return raceWithAbortSignal(check, signal);
}
getAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly Model<Api>[]> {
const signal = operationSignal(options?.signal);
const available = (async () => {
private async getAuthenticatedProviders(providerId: string | undefined, signal: AbortSignal) {
signal.throwIfAborted();
const providers = providerId
? [this.providers.get(providerId)].filter((entry) => entry !== undefined)
@ -544,8 +689,14 @@ class ModelsImpl implements MutableModels {
return { provider, credential, auth: await this.checkProviderAuth(provider, credential, signal) };
}),
);
return checks.flatMap(({ provider, credential, auth }) => {
if (!auth) return [];
return checks.filter((entry) => entry.auth !== undefined);
}
getAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly Model<Api>[]> {
const signal = operationSignal(options?.signal);
const available = (async () => {
const providers = await this.getAuthenticatedProviders(providerId, signal);
return providers.flatMap(({ provider, credential }) => {
const models = provider.getModels();
return provider.filterModels?.(models, credential) ?? models;
});
@ -553,10 +704,37 @@ class ModelsImpl implements MutableModels {
return raceWithAbortSignal(available, signal);
}
async getAvailableOfType<TType extends ModelType>(
type: TType,
providerId?: string,
options?: AuthOperationOptions,
): Promise<readonly ModelTypeMap[TType][]> {
return (await this.getAllAvailable(providerId, options)).filter((model): model is ModelTypeMap[TType] =>
isModelType(model, type),
);
}
getAllAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly AnyModel[]> {
const signal = operationSignal(options?.signal);
const available = (async () => {
const providers = await this.getAuthenticatedProviders(providerId, signal);
return providers.flatMap(({ provider, credential }) => {
const models = provider.getAllModels?.() ?? provider.getModels();
if (provider.filterAllModels) return provider.filterAllModels(models, credential);
if (!provider.filterModels) return models;
const availableChatIds = new Set(
provider.filterModels(provider.getModels(), credential).map((model) => model.id),
);
return models.filter((model) => !isModelType(model, "chat") || availableChatIds.has(model.id));
});
})();
return raceWithAbortSignal(available, signal);
}
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: Model<Api>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: AnyModel, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
async getAuth(
providerOrModel: string | Model<Api>,
providerOrModel: string | AnyModel,
overrides?: AuthResolutionOverrides,
): Promise<AuthResult | undefined> {
const signal = operationSignal(overrides?.signal);
@ -637,7 +815,7 @@ class ModelsImpl implements MutableModels {
}
}
private requireProvider(model: Model<Api>): Provider {
private requireProvider(model: AnyModel): Provider {
const provider = this.providers.get(model.provider);
if (!provider) {
throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
@ -645,12 +823,20 @@ class ModelsImpl implements MutableModels {
return provider;
}
private async applyAuth<TOptions extends ProviderRequestOptions & ModelsRequestTransforms>(
model: Model<Api>,
private requireChatProvider(model: Model<Api>): Provider {
assertChatModel(model);
return this.requireProvider(model);
}
private async applyAuth<
TModel extends AnyModel,
TOptions extends ProviderRequestOptions<TModel> & ModelsRequestTransforms,
>(
model: TModel,
options: TOptions | undefined,
): Promise<{
requestModel: Model<Api>;
requestOptions: Omit<TOptions, "transformHeaders"> & ProviderRequestOptions;
requestModel: TModel;
requestOptions: Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;
}> {
this.requireProvider(model);
const resolution = await this.getAuth(model, {
@ -668,10 +854,10 @@ class ModelsImpl implements MutableModels {
let headers = mergeHeaders(auth.headers, options?.headers);
if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});
const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};
const requestOptions = { ...providerOptions, apiKey, headers, env } as Omit<TOptions, "transformHeaders"> &
ProviderRequestOptions;
ProviderRequestOptions<TModel>;
return { requestModel, requestOptions };
}
@ -683,12 +869,12 @@ class ModelsImpl implements MutableModels {
): AssistantMessageEventStream {
const transcript = normalizeContext(context);
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const provider = this.requireChatProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(
model,
options as ModelsApiStreamOptions<Api> | undefined,
);
return provider.stream(requestModel as Model<TApi>, transcript, requestOptions as ApiStreamOptions<TApi>);
return provider.stream(requestModel, transcript, requestOptions as ApiStreamOptions<TApi>);
});
}
@ -703,7 +889,7 @@ class ModelsImpl implements MutableModels {
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream {
const transcript = normalizeContext(context);
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const provider = this.requireChatProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return provider.streamSimple(requestModel, transcript, requestOptions as SimpleStreamOptions);
});
@ -723,7 +909,7 @@ class ModelsImpl implements MutableModels {
options?: ModelsDeferredFetchOptions,
): AssistantMessageEventStream {
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const provider = this.requireChatProvider(model);
if (!provider.fetchDeferred) {
throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);
}
@ -745,13 +931,49 @@ class ModelsImpl implements MutableModels {
handle: DeferredHandle,
options?: ModelsDeferredCancelOptions,
): Promise<void> {
const provider = this.requireProvider(model);
const provider = this.requireChatProvider(model);
if (!provider.cancelDeferred) {
throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);
}
const { requestModel, requestOptions } = await this.applyAuth(model, options);
await provider.cancelDeferred(requestModel, handle, requestOptions);
}
async generateImages(
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: ModelsImagesOptions,
): Promise<AssistantImages> {
try {
assertImageModel(model);
const provider = this.requireProvider(model);
if (!provider.generateImages) {
throw new ModelsError("provider", `Provider ${model.provider} does not support image generation`);
}
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return await provider.generateImages(requestModel, context, requestOptions);
} catch (error) {
return imageErrorResult(model, error, options?.signal?.aborted);
}
}
async classify(
model: ClassifierModel<ClassifierApi>,
context: ClassifierContext,
options?: ModelsClassifierOptions,
): Promise<ClassifierResult> {
try {
assertClassifierModel(model);
const provider = this.requireProvider(model);
if (!provider.classify) {
throw new ModelsError("provider", `Provider ${model.provider} does not support classification`);
}
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return await provider.classify(requestModel, context, requestOptions);
} catch (error) {
return classifierErrorResult(model, error, options?.signal?.aborted);
}
}
}
export function createModels(options?: CreateModelsOptions): MutableModels {
@ -766,38 +988,72 @@ export interface CreateProviderOptions<TApi extends Api = Api> {
headers?: ProviderHeaders;
/** Required — every provider has auth semantics, even ambient/keyless ones. */
auth: ProviderAuth;
/** Static baseline model list (empty for purely dynamic providers). */
models: readonly Model<TApi>[];
/** Fetch a dynamic model overlay. createProvider restores and publishes it transactionally. */
fetchModels?: (context: RefreshModelsContext) => Promise<readonly Model<TApi>[]>;
/**
* Static baseline models of every type (empty for purely dynamic providers).
* Models without `type` are chat models.
*/
models: readonly ProviderModel<TApi>[];
/**
* Fetch a dynamic model overlay of every type. createProvider restores and
* publishes it transactionally and drops models of unknown types.
*/
fetchModels?: (context: RefreshModelsContext) => Promise<readonly ProviderModel<TApi>[]>;
/** Credential-specific chat model availability. See `Provider.filterModels`. */
filterModels?: (models: readonly Model<TApi>[], credential: Credential | undefined) => readonly Model<TApi>[];
/** Single implementation, or map keyed by `model.api` for mixed-API providers. */
api: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;
/** Credential-specific availability across every model type. See `Provider.filterAllModels`. */
filterAllModels?: (
models: readonly ProviderModel<TApi>[],
credential: Credential | undefined,
) => readonly ProviderModel<TApi>[];
/**
* Chat implementation: a single one for all chat models, or a map keyed by
* `model.api` for mixed-API providers. Optional when `images` or
* `classifiers` is given.
*/
api?: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;
/** Image-generation implementations keyed by `model.api`. */
images?: Partial<Record<ImageApi, ProviderImages>>;
/** Classifier implementations keyed by `model.api`. */
classifiers?: Partial<Record<ClassifierApi, ProviderClassifier>>;
}
/**
* Builds a provider from parts. Built-in provider factories and models.json
* custom providers both go through this. A single `api` streams all models;
* an `api` map dispatches on `model.api`, and a model whose api has no entry
* produces a stream error.
* custom providers both go through this. A single `api` streams all chat
* models; an `api` map dispatches on `model.api`, and a model whose api has
* no entry produces a stream error. One-shot operation maps dispatch on
* `model.api` the same way. At least one concrete implementation across
* `api`/`images`/`classifiers` is required; empty maps are rejected.
*/
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
const single =
input.api && typeof (input.api as ProviderStreams).stream === "function"
? (input.api as ProviderStreams)
: undefined;
const byApi = single || !input.api ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
const images = input.images as Partial<Record<string, ProviderImages>> | undefined;
const classifiers = input.classifiers as Partial<Record<string, ProviderClassifier>> | undefined;
const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry) => entry !== undefined);
const imageImplementations = Object.values(images ?? {}).filter((entry) => entry !== undefined);
const classifierImplementations = Object.values(classifiers ?? {}).filter((entry) => entry !== undefined);
if (streams.length === 0 && imageImplementations.length === 0 && classifierImplementations.length === 0) {
throw new Error(`Provider ${input.id}: at least one of "api", "images", or "classifiers" is required.`);
}
const baselineModels = input.models;
let dynamicModels: readonly Model<TApi>[] = [];
let dynamicModels: readonly ProviderModel<TApi>[] = [];
const fetchModels = input.fetchModels;
const currentModels = (): readonly Model<TApi>[] => {
const currentModels = (): readonly ProviderModel<TApi>[] => {
const merged = [...baselineModels];
for (const model of dynamicModels) {
const index = merged.findIndex((entry) => entry.id === model.id);
const index = merged.findIndex(
(entry) => getModelType(entry) === getModelType(model) && entry.id === model.id,
);
if (index >= 0) merged[index] = model;
else merged.push(model);
}
return merged;
};
const single =
typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined;
const byApi = single ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];
const dispatch = (
@ -819,13 +1075,14 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
baseUrl: input.baseUrl,
headers: input.headers,
auth: input.auth,
getModels: currentModels,
getModels: () => currentModels().filter((model): model is Model<TApi> => isModelType(model, "chat")),
getAllModels: currentModels,
refreshModels: fetchModels
? async (context) => {
if (context.stored) {
const restored = context.stored.models
.filter((model) => model.provider === input.id)
.map((model) => model as Model<TApi>);
.map((model) => model as ProviderModel<TApi>);
if (
!(await context.publish({
update: () => {
@ -837,8 +1094,9 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
}
}
if (!context.allowNetwork || context.signal.aborted) return;
const refreshed = await fetchModels(context);
const fetched = await fetchModels(context);
if (context.signal.aborted) return;
const refreshed = fetched.filter(hasKnownModelType);
await context.publish({
persist: { models: refreshed, checkedAt: Date.now() },
update: () => {
@ -848,12 +1106,12 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
}
: undefined,
filterModels: input.filterModels,
filterAllModels: input.filterAllModels,
stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
streamSimple: (model, context, options) =>
dispatch(model, (streams) => streams.streamSimple(model, context, options)),
};
const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry) => entry !== undefined);
if (streams.some((entry) => entry.fetchDeferred !== undefined)) {
provider.fetchDeferred = (model, handle, options) =>
lazyStream(model, async () => {
@ -879,6 +1137,33 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
await implementation.cancelDeferred(model, handle, options);
};
}
if (images && imageImplementations.length > 0) {
provider.generateImages = async (model, context, options) => {
const implementation = images[model.api];
if (!implementation) {
return imageErrorResult(
model,
new ModelsError(
"provider",
`Provider ${input.id} has no image generation implementation for "${model.api}"`,
),
);
}
return implementation.generateImages(model, context, options);
};
}
if (classifiers && classifierImplementations.length > 0) {
provider.classify = async (model, context, options) => {
const implementation = classifiers[model.api];
if (!implementation) {
return classifierErrorResult(
model,
new ModelsError("provider", `Provider ${input.id} has no classifier implementation for "${model.api}"`),
);
}
return implementation.classify(model, context, options);
};
}
return provider;
}
@ -892,12 +1177,14 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
* // model: Model<"anthropic-messages">, stream options fully typed
* }
* ```
*
* Non-chat models never match, even when their api id equals `api`.
*/
export function hasApi<TApi extends Api>(model: Model<Api>, api: TApi): model is Model<TApi> {
return model.api === api;
export function hasApi<TApi extends Api>(model: AnyModel, api: TApi): model is Model<TApi> {
return isModelType(model, "chat") && model.api === api;
}
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
export function calculateCost(model: AnyModel, usage: Usage): Usage["cost"] {
const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
let rates: ModelCostRates = model.cost;
let matchedThreshold = -1;
@ -954,13 +1241,10 @@ export function clampThinkingLevel<TApi extends Api>(
}
/**
* Check if two models are equal by comparing both their id and provider.
* Check if two models are equal by comparing their type, id, and provider.
* Returns false if either model is null or undefined.
*/
export function modelsAreEqual<TApi extends Api>(
a: Model<TApi> | null | undefined,
b: Model<TApi> | null | undefined,
): boolean {
export function modelsAreEqual(a: AnyModel | null | undefined, b: AnyModel | null | undefined): boolean {
if (!a || !b) return false;
return a.id === b.id && a.provider === b.provider;
return getModelType(a) === getModelType(b) && a.id === b.id && a.provider === b.provider;
}

View file

@ -1,7 +1,6 @@
import { createImagesModels, type ImagesProvider, type MutableImagesModels } from "../images-models.ts";
import { MODELS } from "../models.generated.ts";
import { CLASSIFIER_MODELS, IMAGE_MODELS, MODELS } from "../models.generated.ts";
import { type CreateModelsOptions, createModels, type MutableModels, type Provider } from "../models.ts";
import type { Api, Model } from "../types.ts";
import type { AnyModel, Api, ClassifierApi, ClassifierModel, ImageApi, ImageModel, Model } from "../types.ts";
import { amazonBedrockProvider } from "./amazon-bedrock.ts";
import { antLingProvider } from "./ant-ling.ts";
import { anthropicProvider } from "./anthropic.ts";
@ -31,12 +30,12 @@ import { openaiCodexProvider } from "./openai-codex.ts";
import { opencodeProvider } from "./opencode.ts";
import { opencodeGoProvider } from "./opencode-go.ts";
import { openrouterProvider } from "./openrouter.ts";
import { openrouterImagesProvider } from "./openrouter-images.ts";
import { qwenTokenPlanProvider } from "./qwen-token-plan.ts";
import { qwenTokenPlanCnProvider } from "./qwen-token-plan-cn.ts";
import { qwenTokenPlanIndividualProvider } from "./qwen-token-plan-individual.ts";
import { radiusProvider } from "./radius.ts";
import { togetherProvider } from "./together.ts";
import { typesafeProvider } from "./typesafe.ts";
import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts";
import { xaiProvider } from "./xai.ts";
import { xiaomiProvider } from "./xiaomi.ts";
@ -53,18 +52,43 @@ export { radiusProvider };
* catalog entry. */
export type BuiltinProvider = keyof typeof MODELS;
type BuiltinModelApi<
TProvider extends BuiltinProvider,
TModelId extends keyof (typeof MODELS)[TProvider],
> = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never;
type BuiltinChatModelId<TProvider extends BuiltinProvider> = keyof (typeof MODELS)[TProvider];
type BuiltinImageModelId<TProvider extends BuiltinProvider> = keyof (typeof IMAGE_MODELS)[TProvider];
type BuiltinClassifierModelId<TProvider extends BuiltinProvider> = keyof (typeof CLASSIFIER_MODELS)[TProvider];
/** API ids of catalog entries. Built-in getters return `Model<Api>` shapes, not literal entry types. */
type CatalogApi<TEntry> = TEntry extends { api: infer TApi extends string } ? TApi : never;
/** Typed read of the generated built-in catalog. */
export function getBuiltinModel<TProvider extends BuiltinProvider, TModelId extends keyof (typeof MODELS)[TProvider]>(
/** Typed read of one generated built-in chat model. */
export function getBuiltinModel<TProvider extends BuiltinProvider, TModelId extends BuiltinChatModelId<TProvider>>(
provider: TProvider,
modelId: TModelId,
): Model<BuiltinModelApi<TProvider, TModelId>> {
const models = MODELS[provider] as Record<string, Model<Api>> | undefined;
return models?.[modelId as string] as Model<BuiltinModelApi<TProvider, TModelId>>;
): Model<CatalogApi<(typeof MODELS)[TProvider][TModelId]>> {
return (MODELS as Record<string, Record<string, Model<Api>> | undefined>)[provider]?.[modelId as string] as Model<
CatalogApi<(typeof MODELS)[TProvider][TModelId]>
>;
}
/** Typed read of one generated built-in image model. */
export function getBuiltinImageModel<
TProvider extends BuiltinProvider,
TModelId extends BuiltinImageModelId<TProvider>,
>(provider: TProvider, modelId: TModelId): ImageModel<CatalogApi<(typeof IMAGE_MODELS)[TProvider][TModelId]>> {
return (IMAGE_MODELS as Record<string, Record<string, ImageModel<ImageApi>> | undefined>)[provider]?.[
modelId as string
] as ImageModel<CatalogApi<(typeof IMAGE_MODELS)[TProvider][TModelId]>>;
}
/** Typed read of one generated built-in classifier model. */
export function getBuiltinClassifierModel<
TProvider extends BuiltinProvider,
TModelId extends BuiltinClassifierModelId<TProvider>,
>(
provider: TProvider,
modelId: TModelId,
): ClassifierModel<CatalogApi<(typeof CLASSIFIER_MODELS)[TProvider][TModelId]>> {
return (CLASSIFIER_MODELS as Record<string, Record<string, ClassifierModel<ClassifierApi>> | undefined>)[provider]?.[
modelId as string
] as ClassifierModel<CatalogApi<(typeof CLASSIFIER_MODELS)[TProvider][TModelId]>>;
}
export function getBuiltinProviders(): BuiltinProvider[] {
@ -79,11 +103,33 @@ export function getBuiltinModelDataGeneratedAt(): number | undefined {
export function getBuiltinModels<TProvider extends BuiltinProvider>(
provider: TProvider,
): Model<BuiltinModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] {
const models = MODELS[provider] as Record<string, Model<Api>> | undefined;
return models
? (Object.values(models) as Model<BuiltinModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[])
: [];
): Model<CatalogApi<(typeof MODELS)[TProvider][BuiltinChatModelId<TProvider>]>>[] {
const models = (MODELS as Record<string, Record<string, Model<Api>> | undefined>)[provider];
return Object.values(models ?? {}) as Model<CatalogApi<(typeof MODELS)[TProvider][BuiltinChatModelId<TProvider>]>>[];
}
export function getBuiltinImageModels<TProvider extends BuiltinProvider>(
provider: TProvider,
): ImageModel<CatalogApi<(typeof IMAGE_MODELS)[TProvider][BuiltinImageModelId<TProvider>]>>[] {
const models = (IMAGE_MODELS as Record<string, Record<string, ImageModel<ImageApi>> | undefined>)[provider];
return Object.values(models ?? {}) as ImageModel<
CatalogApi<(typeof IMAGE_MODELS)[TProvider][BuiltinImageModelId<TProvider>]>
>[];
}
export function getBuiltinClassifierModels<TProvider extends BuiltinProvider>(
provider: TProvider,
): ClassifierModel<CatalogApi<(typeof CLASSIFIER_MODELS)[TProvider][BuiltinClassifierModelId<TProvider>]>>[] {
const models = (CLASSIFIER_MODELS as Record<string, Record<string, ClassifierModel<ClassifierApi>> | undefined>)[
provider
];
return Object.values(models ?? {}) as ClassifierModel<
CatalogApi<(typeof CLASSIFIER_MODELS)[TProvider][BuiltinClassifierModelId<TProvider>]>
>[];
}
export function getAllBuiltinModels<TProvider extends BuiltinProvider>(provider: TProvider): AnyModel[] {
return [...getBuiltinModels(provider), ...getBuiltinImageModels(provider), ...getBuiltinClassifierModels(provider)];
}
/** All built-in providers, freshly constructed. */
@ -122,6 +168,7 @@ export function builtinProviders(): Provider[] {
qwenTokenPlanIndividualProvider(),
radiusProvider(),
togetherProvider(),
typesafeProvider(),
vercelAIGatewayProvider(),
xaiProvider(),
xiaomiProvider(),
@ -141,17 +188,3 @@ export function builtinModels(options?: CreateModelsOptions): MutableModels {
}
return models;
}
/** All built-in image-generation providers, freshly constructed. */
export function builtinImagesProviders(): ImagesProvider[] {
return [openrouterImagesProvider()];
}
/** An `ImagesModels` collection with every built-in image-generation provider registered. */
export function builtinImagesModels(options?: CreateModelsOptions): MutableImagesModels {
const models = createImagesModels(options);
for (const provider of builtinImagesProviders()) {
models.setProvider(provider);
}
return models;
}

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/amazon-bedrock.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const AMAZON_BEDROCK_MODELS: ModelCatalog<typeof values, "amazon-bedrock"> =
flattenModelCatalog("amazon-bedrock", values);
export const AMAZON_BEDROCK_MODELS: ChatModelCatalog<typeof values, "amazon-bedrock"> =
flattenChatModelCatalog("amazon-bedrock", values);
export const AMAZON_BEDROCK_IMAGE_MODELS: ImageModelCatalog<typeof values, "amazon-bedrock"> =
flattenImageModelCatalog("amazon-bedrock", values);
export const AMAZON_BEDROCK_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "amazon-bedrock"> =
flattenClassifierModelCatalog("amazon-bedrock", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/ant-ling.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const ANT_LING_MODELS: ModelCatalog<typeof values, "ant-ling"> =
flattenModelCatalog("ant-ling", values);
export const ANT_LING_MODELS: ChatModelCatalog<typeof values, "ant-ling"> =
flattenChatModelCatalog("ant-ling", values);
export const ANT_LING_IMAGE_MODELS: ImageModelCatalog<typeof values, "ant-ling"> =
flattenImageModelCatalog("ant-ling", values);
export const ANT_LING_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "ant-ling"> =
flattenClassifierModelCatalog("ant-ling", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/anthropic.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const ANTHROPIC_MODELS: ModelCatalog<typeof values, "anthropic"> =
flattenModelCatalog("anthropic", values);
export const ANTHROPIC_MODELS: ChatModelCatalog<typeof values, "anthropic"> =
flattenChatModelCatalog("anthropic", values);
export const ANTHROPIC_IMAGE_MODELS: ImageModelCatalog<typeof values, "anthropic"> =
flattenImageModelCatalog("anthropic", values);
export const ANTHROPIC_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "anthropic"> =
flattenClassifierModelCatalog("anthropic", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/azure-openai-responses.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const AZURE_OPENAI_RESPONSES_MODELS: ModelCatalog<typeof values, "azure-openai-responses"> =
flattenModelCatalog("azure-openai-responses", values);
export const AZURE_OPENAI_RESPONSES_MODELS: ChatModelCatalog<typeof values, "azure-openai-responses"> =
flattenChatModelCatalog("azure-openai-responses", values);
export const AZURE_OPENAI_RESPONSES_IMAGE_MODELS: ImageModelCatalog<typeof values, "azure-openai-responses"> =
flattenImageModelCatalog("azure-openai-responses", values);
export const AZURE_OPENAI_RESPONSES_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "azure-openai-responses"> =
flattenClassifierModelCatalog("azure-openai-responses", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/baseten.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const BASETEN_MODELS: ModelCatalog<typeof values, "baseten"> =
flattenModelCatalog("baseten", values);
export const BASETEN_MODELS: ChatModelCatalog<typeof values, "baseten"> =
flattenChatModelCatalog("baseten", values);
export const BASETEN_IMAGE_MODELS: ImageModelCatalog<typeof values, "baseten"> =
flattenImageModelCatalog("baseten", values);
export const BASETEN_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "baseten"> =
flattenClassifierModelCatalog("baseten", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/cerebras.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const CEREBRAS_MODELS: ModelCatalog<typeof values, "cerebras"> =
flattenModelCatalog("cerebras", values);
export const CEREBRAS_MODELS: ChatModelCatalog<typeof values, "cerebras"> =
flattenChatModelCatalog("cerebras", values);
export const CEREBRAS_IMAGE_MODELS: ImageModelCatalog<typeof values, "cerebras"> =
flattenImageModelCatalog("cerebras", values);
export const CEREBRAS_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "cerebras"> =
flattenClassifierModelCatalog("cerebras", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/cloudflare-ai-gateway.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const CLOUDFLARE_AI_GATEWAY_MODELS: ModelCatalog<typeof values, "cloudflare-ai-gateway"> =
flattenModelCatalog("cloudflare-ai-gateway", values);
export const CLOUDFLARE_AI_GATEWAY_MODELS: ChatModelCatalog<typeof values, "cloudflare-ai-gateway"> =
flattenChatModelCatalog("cloudflare-ai-gateway", values);
export const CLOUDFLARE_AI_GATEWAY_IMAGE_MODELS: ImageModelCatalog<typeof values, "cloudflare-ai-gateway"> =
flattenImageModelCatalog("cloudflare-ai-gateway", values);
export const CLOUDFLARE_AI_GATEWAY_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "cloudflare-ai-gateway"> =
flattenClassifierModelCatalog("cloudflare-ai-gateway", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/cloudflare-workers-ai.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const CLOUDFLARE_WORKERS_AI_MODELS: ModelCatalog<typeof values, "cloudflare-workers-ai"> =
flattenModelCatalog("cloudflare-workers-ai", values);
export const CLOUDFLARE_WORKERS_AI_MODELS: ChatModelCatalog<typeof values, "cloudflare-workers-ai"> =
flattenChatModelCatalog("cloudflare-workers-ai", values);
export const CLOUDFLARE_WORKERS_AI_IMAGE_MODELS: ImageModelCatalog<typeof values, "cloudflare-workers-ai"> =
flattenImageModelCatalog("cloudflare-workers-ai", values);
export const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "cloudflare-workers-ai"> =
flattenClassifierModelCatalog("cloudflare-workers-ai", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/deepseek.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const DEEPSEEK_MODELS: ModelCatalog<typeof values, "deepseek"> =
flattenModelCatalog("deepseek", values);
export const DEEPSEEK_MODELS: ChatModelCatalog<typeof values, "deepseek"> =
flattenChatModelCatalog("deepseek", values);
export const DEEPSEEK_IMAGE_MODELS: ImageModelCatalog<typeof values, "deepseek"> =
flattenImageModelCatalog("deepseek", values);
export const DEEPSEEK_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "deepseek"> =
flattenClassifierModelCatalog("deepseek", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/fireworks.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const FIREWORKS_MODELS: ModelCatalog<typeof values, "fireworks"> =
flattenModelCatalog("fireworks", values);
export const FIREWORKS_MODELS: ChatModelCatalog<typeof values, "fireworks"> =
flattenChatModelCatalog("fireworks", values);
export const FIREWORKS_IMAGE_MODELS: ImageModelCatalog<typeof values, "fireworks"> =
flattenImageModelCatalog("fireworks", values);
export const FIREWORKS_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "fireworks"> =
flattenClassifierModelCatalog("fireworks", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/github-copilot.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const GITHUB_COPILOT_MODELS: ModelCatalog<typeof values, "github-copilot"> =
flattenModelCatalog("github-copilot", values);
export const GITHUB_COPILOT_MODELS: ChatModelCatalog<typeof values, "github-copilot"> =
flattenChatModelCatalog("github-copilot", values);
export const GITHUB_COPILOT_IMAGE_MODELS: ImageModelCatalog<typeof values, "github-copilot"> =
flattenImageModelCatalog("github-copilot", values);
export const GITHUB_COPILOT_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "github-copilot"> =
flattenClassifierModelCatalog("github-copilot", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/google-vertex.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const GOOGLE_VERTEX_MODELS: ModelCatalog<typeof values, "google-vertex"> =
flattenModelCatalog("google-vertex", values);
export const GOOGLE_VERTEX_MODELS: ChatModelCatalog<typeof values, "google-vertex"> =
flattenChatModelCatalog("google-vertex", values);
export const GOOGLE_VERTEX_IMAGE_MODELS: ImageModelCatalog<typeof values, "google-vertex"> =
flattenImageModelCatalog("google-vertex", values);
export const GOOGLE_VERTEX_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "google-vertex"> =
flattenClassifierModelCatalog("google-vertex", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/google.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const GOOGLE_MODELS: ModelCatalog<typeof values, "google"> =
flattenModelCatalog("google", values);
export const GOOGLE_MODELS: ChatModelCatalog<typeof values, "google"> =
flattenChatModelCatalog("google", values);
export const GOOGLE_IMAGE_MODELS: ImageModelCatalog<typeof values, "google"> =
flattenImageModelCatalog("google", values);
export const GOOGLE_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "google"> =
flattenClassifierModelCatalog("google", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/groq.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const GROQ_MODELS: ModelCatalog<typeof values, "groq"> =
flattenModelCatalog("groq", values);
export const GROQ_MODELS: ChatModelCatalog<typeof values, "groq"> =
flattenChatModelCatalog("groq", values);
export const GROQ_IMAGE_MODELS: ImageModelCatalog<typeof values, "groq"> =
flattenImageModelCatalog("groq", values);
export const GROQ_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "groq"> =
flattenClassifierModelCatalog("groq", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/huggingface.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const HUGGINGFACE_MODELS: ModelCatalog<typeof values, "huggingface"> =
flattenModelCatalog("huggingface", values);
export const HUGGINGFACE_MODELS: ChatModelCatalog<typeof values, "huggingface"> =
flattenChatModelCatalog("huggingface", values);
export const HUGGINGFACE_IMAGE_MODELS: ImageModelCatalog<typeof values, "huggingface"> =
flattenImageModelCatalog("huggingface", values);
export const HUGGINGFACE_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "huggingface"> =
flattenClassifierModelCatalog("huggingface", values);

View file

@ -1,6 +1,13 @@
import type { generateImages as generateImagesOpenRouterFunction } from "../../api/openrouter-images.ts";
import { registerImagesApiProvider } from "../../images-api-registry.ts";
import type { AssistantImages, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "../../types.ts";
import type {
AssistantImages,
ImageApi,
ImageModel,
ImagesContext,
ImagesFunction,
ImagesOptions,
} from "../../types.ts";
interface OpenRouterImagesProviderModule {
generateImages: typeof generateImagesOpenRouterFunction;
@ -8,7 +15,7 @@ interface OpenRouterImagesProviderModule {
let openRouterImagesProviderModulePromise: Promise<OpenRouterImagesProviderModule> | undefined;
function createLazyLoadErrorImages(model: ImagesModel<"openrouter-images">, error: unknown): AssistantImages {
function createLazyLoadErrorImages(model: ImageModel<ImageApi>, error: unknown): AssistantImages {
return {
api: model.api,
provider: model.provider,
@ -27,8 +34,8 @@ function loadOpenRouterImagesProviderModule(): Promise<OpenRouterImagesProviderM
return openRouterImagesProviderModulePromise;
}
export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", ImagesOptions> = async (
model: ImagesModel<"openrouter-images">,
export const generateImagesOpenRouter: ImagesFunction<ImagesOptions> = async (
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: ImagesOptions,
) => {

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/kimi-coding.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const KIMI_CODING_MODELS: ModelCatalog<typeof values, "kimi-coding"> =
flattenModelCatalog("kimi-coding", values);
export const KIMI_CODING_MODELS: ChatModelCatalog<typeof values, "kimi-coding"> =
flattenChatModelCatalog("kimi-coding", values);
export const KIMI_CODING_IMAGE_MODELS: ImageModelCatalog<typeof values, "kimi-coding"> =
flattenImageModelCatalog("kimi-coding", values);
export const KIMI_CODING_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "kimi-coding"> =
flattenClassifierModelCatalog("kimi-coding", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/meta.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const META_MODELS: ModelCatalog<typeof values, "meta"> =
flattenModelCatalog("meta", values);
export const META_MODELS: ChatModelCatalog<typeof values, "meta"> =
flattenChatModelCatalog("meta", values);
export const META_IMAGE_MODELS: ImageModelCatalog<typeof values, "meta"> =
flattenImageModelCatalog("meta", values);
export const META_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "meta"> =
flattenClassifierModelCatalog("meta", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/minimax-cn.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const MINIMAX_CN_MODELS: ModelCatalog<typeof values, "minimax-cn"> =
flattenModelCatalog("minimax-cn", values);
export const MINIMAX_CN_MODELS: ChatModelCatalog<typeof values, "minimax-cn"> =
flattenChatModelCatalog("minimax-cn", values);
export const MINIMAX_CN_IMAGE_MODELS: ImageModelCatalog<typeof values, "minimax-cn"> =
flattenImageModelCatalog("minimax-cn", values);
export const MINIMAX_CN_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "minimax-cn"> =
flattenClassifierModelCatalog("minimax-cn", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/minimax.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const MINIMAX_MODELS: ModelCatalog<typeof values, "minimax"> =
flattenModelCatalog("minimax", values);
export const MINIMAX_MODELS: ChatModelCatalog<typeof values, "minimax"> =
flattenChatModelCatalog("minimax", values);
export const MINIMAX_IMAGE_MODELS: ImageModelCatalog<typeof values, "minimax"> =
flattenImageModelCatalog("minimax", values);
export const MINIMAX_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "minimax"> =
flattenClassifierModelCatalog("minimax", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/mistral.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const MISTRAL_MODELS: ModelCatalog<typeof values, "mistral"> =
flattenModelCatalog("mistral", values);
export const MISTRAL_MODELS: ChatModelCatalog<typeof values, "mistral"> =
flattenChatModelCatalog("mistral", values);
export const MISTRAL_IMAGE_MODELS: ImageModelCatalog<typeof values, "mistral"> =
flattenImageModelCatalog("mistral", values);
export const MISTRAL_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "mistral"> =
flattenClassifierModelCatalog("mistral", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/moonshotai-cn.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const MOONSHOTAI_CN_MODELS: ModelCatalog<typeof values, "moonshotai-cn"> =
flattenModelCatalog("moonshotai-cn", values);
export const MOONSHOTAI_CN_MODELS: ChatModelCatalog<typeof values, "moonshotai-cn"> =
flattenChatModelCatalog("moonshotai-cn", values);
export const MOONSHOTAI_CN_IMAGE_MODELS: ImageModelCatalog<typeof values, "moonshotai-cn"> =
flattenImageModelCatalog("moonshotai-cn", values);
export const MOONSHOTAI_CN_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "moonshotai-cn"> =
flattenClassifierModelCatalog("moonshotai-cn", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/moonshotai.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const MOONSHOTAI_MODELS: ModelCatalog<typeof values, "moonshotai"> =
flattenModelCatalog("moonshotai", values);
export const MOONSHOTAI_MODELS: ChatModelCatalog<typeof values, "moonshotai"> =
flattenChatModelCatalog("moonshotai", values);
export const MOONSHOTAI_IMAGE_MODELS: ImageModelCatalog<typeof values, "moonshotai"> =
flattenImageModelCatalog("moonshotai", values);
export const MOONSHOTAI_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "moonshotai"> =
flattenClassifierModelCatalog("moonshotai", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/nvidia.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const NVIDIA_MODELS: ModelCatalog<typeof values, "nvidia"> =
flattenModelCatalog("nvidia", values);
export const NVIDIA_MODELS: ChatModelCatalog<typeof values, "nvidia"> =
flattenChatModelCatalog("nvidia", values);
export const NVIDIA_IMAGE_MODELS: ImageModelCatalog<typeof values, "nvidia"> =
flattenImageModelCatalog("nvidia", values);
export const NVIDIA_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "nvidia"> =
flattenClassifierModelCatalog("nvidia", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/openai-codex.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const OPENAI_CODEX_MODELS: ModelCatalog<typeof values, "openai-codex"> =
flattenModelCatalog("openai-codex", values);
export const OPENAI_CODEX_MODELS: ChatModelCatalog<typeof values, "openai-codex"> =
flattenChatModelCatalog("openai-codex", values);
export const OPENAI_CODEX_IMAGE_MODELS: ImageModelCatalog<typeof values, "openai-codex"> =
flattenImageModelCatalog("openai-codex", values);
export const OPENAI_CODEX_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "openai-codex"> =
flattenClassifierModelCatalog("openai-codex", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/openai.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const OPENAI_MODELS: ModelCatalog<typeof values, "openai"> =
flattenModelCatalog("openai", values);
export const OPENAI_MODELS: ChatModelCatalog<typeof values, "openai"> =
flattenChatModelCatalog("openai", values);
export const OPENAI_IMAGE_MODELS: ImageModelCatalog<typeof values, "openai"> =
flattenImageModelCatalog("openai", values);
export const OPENAI_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "openai"> =
flattenClassifierModelCatalog("openai", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/opencode-go.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const OPENCODE_GO_MODELS: ModelCatalog<typeof values, "opencode-go"> =
flattenModelCatalog("opencode-go", values);
export const OPENCODE_GO_MODELS: ChatModelCatalog<typeof values, "opencode-go"> =
flattenChatModelCatalog("opencode-go", values);
export const OPENCODE_GO_IMAGE_MODELS: ImageModelCatalog<typeof values, "opencode-go"> =
flattenImageModelCatalog("opencode-go", values);
export const OPENCODE_GO_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "opencode-go"> =
flattenClassifierModelCatalog("opencode-go", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/opencode.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const OPENCODE_MODELS: ModelCatalog<typeof values, "opencode"> =
flattenModelCatalog("opencode", values);
export const OPENCODE_MODELS: ChatModelCatalog<typeof values, "opencode"> =
flattenChatModelCatalog("opencode", values);
export const OPENCODE_IMAGE_MODELS: ImageModelCatalog<typeof values, "opencode"> =
flattenImageModelCatalog("opencode", values);
export const OPENCODE_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "opencode"> =
flattenClassifierModelCatalog("opencode", values);

View file

@ -1,22 +0,0 @@
import { openrouterImagesApi } from "../api/openrouter-images.lazy.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
import { loadOpenRouterOAuth } from "../auth/oauth/load.ts";
import { IMAGE_MODELS } from "../image-models.generated.ts";
import { createImagesProvider, type ImagesProvider } from "../images-models.ts";
export function openrouterImagesProvider(): ImagesProvider {
return createImagesProvider({
id: "openrouter",
name: "OpenRouter",
auth: {
apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]),
oauth: lazyOAuth({
name: "OpenRouter OAuth",
loginLabel: "Sign in with OpenRouter",
load: loadOpenRouterOAuth,
}),
},
models: Object.values(IMAGE_MODELS.openrouter),
api: openrouterImagesApi(),
});
}

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/openrouter.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const OPENROUTER_MODELS: ModelCatalog<typeof values, "openrouter"> =
flattenModelCatalog("openrouter", values);
export const OPENROUTER_MODELS: ChatModelCatalog<typeof values, "openrouter"> =
flattenChatModelCatalog("openrouter", values);
export const OPENROUTER_IMAGE_MODELS: ImageModelCatalog<typeof values, "openrouter"> =
flattenImageModelCatalog("openrouter", values);
export const OPENROUTER_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "openrouter"> =
flattenClassifierModelCatalog("openrouter", values);

View file

@ -1,12 +1,13 @@
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
import { openrouterImagesApi } from "../api/openrouter-images.lazy.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
import { loadOpenRouterOAuth } from "../auth/oauth/load.ts";
import { createProvider, type Provider } from "../models.ts";
import { OPENROUTER_MODELS } from "./openrouter.models.ts";
import { OPENROUTER_IMAGE_MODELS, OPENROUTER_MODELS } from "./openrouter.models.ts";
export function openrouterProvider(): Provider<"anthropic-messages" | "openai-completions"> {
return createProvider({
return createProvider<"anthropic-messages" | "openai-completions">({
id: "openrouter",
name: "OpenRouter",
baseUrl: "https://openrouter.ai/api/v1",
@ -18,10 +19,11 @@ export function openrouterProvider(): Provider<"anthropic-messages" | "openai-co
load: loadOpenRouterOAuth,
}),
},
models: Object.values(OPENROUTER_MODELS),
models: [...Object.values(OPENROUTER_MODELS), ...Object.values(OPENROUTER_IMAGE_MODELS)],
api: {
"anthropic-messages": anthropicMessagesApi(),
"openai-completions": openAICompletionsApi(),
},
images: { "openrouter-images": openrouterImagesApi() },
});
}

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/qwen-token-plan-cn.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const QWEN_TOKEN_PLAN_CN_MODELS: ModelCatalog<typeof values, "qwen-token-plan-cn"> =
flattenModelCatalog("qwen-token-plan-cn", values);
export const QWEN_TOKEN_PLAN_CN_MODELS: ChatModelCatalog<typeof values, "qwen-token-plan-cn"> =
flattenChatModelCatalog("qwen-token-plan-cn", values);
export const QWEN_TOKEN_PLAN_CN_IMAGE_MODELS: ImageModelCatalog<typeof values, "qwen-token-plan-cn"> =
flattenImageModelCatalog("qwen-token-plan-cn", values);
export const QWEN_TOKEN_PLAN_CN_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "qwen-token-plan-cn"> =
flattenClassifierModelCatalog("qwen-token-plan-cn", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/qwen-token-plan-individual.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS: ModelCatalog<typeof values, "qwen-token-plan-individual"> =
flattenModelCatalog("qwen-token-plan-individual", values);
export const QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS: ChatModelCatalog<typeof values, "qwen-token-plan-individual"> =
flattenChatModelCatalog("qwen-token-plan-individual", values);
export const QWEN_TOKEN_PLAN_INDIVIDUAL_IMAGE_MODELS: ImageModelCatalog<typeof values, "qwen-token-plan-individual"> =
flattenImageModelCatalog("qwen-token-plan-individual", values);
export const QWEN_TOKEN_PLAN_INDIVIDUAL_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "qwen-token-plan-individual"> =
flattenClassifierModelCatalog("qwen-token-plan-individual", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/qwen-token-plan.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const QWEN_TOKEN_PLAN_MODELS: ModelCatalog<typeof values, "qwen-token-plan"> =
flattenModelCatalog("qwen-token-plan", values);
export const QWEN_TOKEN_PLAN_MODELS: ChatModelCatalog<typeof values, "qwen-token-plan"> =
flattenChatModelCatalog("qwen-token-plan", values);
export const QWEN_TOKEN_PLAN_IMAGE_MODELS: ImageModelCatalog<typeof values, "qwen-token-plan"> =
flattenImageModelCatalog("qwen-token-plan", values);
export const QWEN_TOKEN_PLAN_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "qwen-token-plan"> =
flattenClassifierModelCatalog("qwen-token-plan", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/radius.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const RADIUS_MODELS: ModelCatalog<typeof values, "radius"> =
flattenModelCatalog("radius", values);
export const RADIUS_MODELS: ChatModelCatalog<typeof values, "radius"> =
flattenChatModelCatalog("radius", values);
export const RADIUS_IMAGE_MODELS: ImageModelCatalog<typeof values, "radius"> =
flattenImageModelCatalog("radius", values);
export const RADIUS_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "radius"> =
flattenClassifierModelCatalog("radius", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/together.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const TOGETHER_MODELS: ModelCatalog<typeof values, "together"> =
flattenModelCatalog("together", values);
export const TOGETHER_MODELS: ChatModelCatalog<typeof values, "together"> =
flattenChatModelCatalog("together", values);
export const TOGETHER_IMAGE_MODELS: ImageModelCatalog<typeof values, "together"> =
flattenImageModelCatalog("together", values);
export const TOGETHER_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "together"> =
flattenClassifierModelCatalog("together", values);

View file

@ -0,0 +1,14 @@
// This file is auto-generated by scripts/generate-models.ts
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/typesafe.json" with { type: "json" };
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const TYPESAFE_MODELS: ChatModelCatalog<typeof values, "typesafe"> =
flattenChatModelCatalog("typesafe", values);
export const TYPESAFE_IMAGE_MODELS: ImageModelCatalog<typeof values, "typesafe"> =
flattenImageModelCatalog("typesafe", values);
export const TYPESAFE_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "typesafe"> =
flattenClassifierModelCatalog("typesafe", values);

View file

@ -0,0 +1,18 @@
import { typesafeSystemOneApi } from "../api/typesafe-system-one.lazy.ts";
import { envApiKeyAuth } from "../auth/helpers.ts";
import { createProvider, type Provider } from "../models.ts";
import { TYPESAFE_CLASSIFIER_MODELS } from "./typesafe.models.ts";
export function typesafeProvider(): Provider {
return createProvider({
id: "typesafe",
name: "TypeSafe",
auth: {
apiKey: envApiKeyAuth("TypeSafe API key", ["TYPESAFE_API_KEY"]),
},
models: Object.values(TYPESAFE_CLASSIFIER_MODELS),
classifiers: {
"typesafe-system-one": typesafeSystemOneApi(),
},
});
}

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/vercel-ai-gateway.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const VERCEL_AI_GATEWAY_MODELS: ModelCatalog<typeof values, "vercel-ai-gateway"> =
flattenModelCatalog("vercel-ai-gateway", values);
export const VERCEL_AI_GATEWAY_MODELS: ChatModelCatalog<typeof values, "vercel-ai-gateway"> =
flattenChatModelCatalog("vercel-ai-gateway", values);
export const VERCEL_AI_GATEWAY_IMAGE_MODELS: ImageModelCatalog<typeof values, "vercel-ai-gateway"> =
flattenImageModelCatalog("vercel-ai-gateway", values);
export const VERCEL_AI_GATEWAY_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "vercel-ai-gateway"> =
flattenClassifierModelCatalog("vercel-ai-gateway", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/xai.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const XAI_MODELS: ModelCatalog<typeof values, "xai"> =
flattenModelCatalog("xai", values);
export const XAI_MODELS: ChatModelCatalog<typeof values, "xai"> =
flattenChatModelCatalog("xai", values);
export const XAI_IMAGE_MODELS: ImageModelCatalog<typeof values, "xai"> =
flattenImageModelCatalog("xai", values);
export const XAI_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "xai"> =
flattenClassifierModelCatalog("xai", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/xiaomi-token-plan-ams.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const XIAOMI_TOKEN_PLAN_AMS_MODELS: ModelCatalog<typeof values, "xiaomi-token-plan-ams"> =
flattenModelCatalog("xiaomi-token-plan-ams", values);
export const XIAOMI_TOKEN_PLAN_AMS_MODELS: ChatModelCatalog<typeof values, "xiaomi-token-plan-ams"> =
flattenChatModelCatalog("xiaomi-token-plan-ams", values);
export const XIAOMI_TOKEN_PLAN_AMS_IMAGE_MODELS: ImageModelCatalog<typeof values, "xiaomi-token-plan-ams"> =
flattenImageModelCatalog("xiaomi-token-plan-ams", values);
export const XIAOMI_TOKEN_PLAN_AMS_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "xiaomi-token-plan-ams"> =
flattenClassifierModelCatalog("xiaomi-token-plan-ams", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/xiaomi-token-plan-cn.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const XIAOMI_TOKEN_PLAN_CN_MODELS: ModelCatalog<typeof values, "xiaomi-token-plan-cn"> =
flattenModelCatalog("xiaomi-token-plan-cn", values);
export const XIAOMI_TOKEN_PLAN_CN_MODELS: ChatModelCatalog<typeof values, "xiaomi-token-plan-cn"> =
flattenChatModelCatalog("xiaomi-token-plan-cn", values);
export const XIAOMI_TOKEN_PLAN_CN_IMAGE_MODELS: ImageModelCatalog<typeof values, "xiaomi-token-plan-cn"> =
flattenImageModelCatalog("xiaomi-token-plan-cn", values);
export const XIAOMI_TOKEN_PLAN_CN_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "xiaomi-token-plan-cn"> =
flattenClassifierModelCatalog("xiaomi-token-plan-cn", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/xiaomi-token-plan-sgp.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const XIAOMI_TOKEN_PLAN_SGP_MODELS: ModelCatalog<typeof values, "xiaomi-token-plan-sgp"> =
flattenModelCatalog("xiaomi-token-plan-sgp", values);
export const XIAOMI_TOKEN_PLAN_SGP_MODELS: ChatModelCatalog<typeof values, "xiaomi-token-plan-sgp"> =
flattenChatModelCatalog("xiaomi-token-plan-sgp", values);
export const XIAOMI_TOKEN_PLAN_SGP_IMAGE_MODELS: ImageModelCatalog<typeof values, "xiaomi-token-plan-sgp"> =
flattenImageModelCatalog("xiaomi-token-plan-sgp", values);
export const XIAOMI_TOKEN_PLAN_SGP_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "xiaomi-token-plan-sgp"> =
flattenClassifierModelCatalog("xiaomi-token-plan-sgp", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/xiaomi.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const XIAOMI_MODELS: ModelCatalog<typeof values, "xiaomi"> =
flattenModelCatalog("xiaomi", values);
export const XIAOMI_MODELS: ChatModelCatalog<typeof values, "xiaomi"> =
flattenChatModelCatalog("xiaomi", values);
export const XIAOMI_IMAGE_MODELS: ImageModelCatalog<typeof values, "xiaomi"> =
flattenImageModelCatalog("xiaomi", values);
export const XIAOMI_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "xiaomi"> =
flattenClassifierModelCatalog("xiaomi", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/zai-coding-cn.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const ZAI_CODING_CN_MODELS: ModelCatalog<typeof values, "zai-coding-cn"> =
flattenModelCatalog("zai-coding-cn", values);
export const ZAI_CODING_CN_MODELS: ChatModelCatalog<typeof values, "zai-coding-cn"> =
flattenChatModelCatalog("zai-coding-cn", values);
export const ZAI_CODING_CN_IMAGE_MODELS: ImageModelCatalog<typeof values, "zai-coding-cn"> =
flattenImageModelCatalog("zai-coding-cn", values);
export const ZAI_CODING_CN_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "zai-coding-cn"> =
flattenClassifierModelCatalog("zai-coding-cn", values);

View file

@ -2,7 +2,13 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/zai.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";
export const ZAI_MODELS: ModelCatalog<typeof values, "zai"> =
flattenModelCatalog("zai", values);
export const ZAI_MODELS: ChatModelCatalog<typeof values, "zai"> =
flattenChatModelCatalog("zai", values);
export const ZAI_IMAGE_MODELS: ImageModelCatalog<typeof values, "zai"> =
flattenImageModelCatalog("zai", values);
export const ZAI_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "zai"> =
flattenClassifierModelCatalog("zai", values);

View file

@ -28,9 +28,13 @@ export type KnownApi =
export type Api = KnownApi | (string & {});
export type KnownImagesApi = "openrouter-images";
export type KnownImageApi = "openrouter-images";
export type ImagesApi = KnownImagesApi | (string & {});
export type ImageApi = KnownImageApi | (string & {});
export type KnownClassifierApi = "typesafe-system-one";
export type ClassifierApi = KnownClassifierApi | (string & {});
export type KnownProvider =
| "amazon-bedrock"
@ -42,6 +46,7 @@ export type KnownProvider =
| "azure-openai-responses"
| "openai-codex"
| "radius"
| "typesafe"
| "nvidia"
| "deepseek"
| "github-copilot"
@ -76,10 +81,6 @@ export type KnownProvider =
| "xiaomi-token-plan-sgp";
export type ProviderId = KnownProvider | string;
export type KnownImagesProvider = "openrouter";
export type ImagesProviderId = KnownImagesProvider | string;
export type ToolChoice = "auto" | "none";
export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export type ModelThinkingLevel = "off" | ThinkingLevel;
@ -300,18 +301,29 @@ export interface ProviderStreams {
/**
* The uniform contract of an image-generation API implementation module:
* every image API module under `src/api/` exports exactly `generateImages`,
* so the module itself satisfies this interface. Lazy wrappers and image
* provider factories pass these around as values.
* so the module itself satisfies this interface. Lazy wrappers and
* `createProvider({ images })` pass these around as values.
*/
export interface ProviderImages {
generateImages(
model: ImagesModel<ImagesApi>,
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: ImagesOptions,
): Promise<AssistantImages>;
}
export interface ImagesOptions extends ProviderRequestOptions<ImagesModel<ImagesApi>> {
/** The uniform contract implemented by classifier API modules. */
export interface ProviderClassifier {
classify(
model: ClassifierModel<ClassifierApi>,
context: ClassifierContext,
options?: ClassifierOptions,
): Promise<ClassifierResult>;
}
export interface ClassifierOptions extends ProviderRequestOptions<ClassifierModel<ClassifierApi>> {}
export interface ImagesOptions extends ProviderRequestOptions<ImageModel<ImageApi>> {
/**
* Optional metadata to include in API requests.
* Providers extract the fields they understand and ignore the rest.
@ -355,12 +367,18 @@ export type StreamFunction<TApi extends Api = Api, TOptions extends StreamOption
options?: TOptions,
) => AssistantMessageEventStream;
export type ImagesFunction<TApi extends ImagesApi = ImagesApi, TOptions extends ImagesOptions = ImagesOptions> = (
model: ImagesModel<TApi>,
export type ImagesFunction<TOptions extends ImagesOptions = ImagesOptions> = (
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: TOptions,
) => Promise<AssistantImages>;
export type ClassifierFunction<TOptions extends ClassifierOptions = ClassifierOptions> = (
model: ClassifierModel<ClassifierApi>,
context: ClassifierContext,
options?: TOptions,
) => Promise<ClassifierResult>;
export interface TextSignatureV1 {
v: 1;
id: string;
@ -568,8 +586,8 @@ export interface ImagesContext {
export type ImagesStopReason = "stop" | "error" | "aborted";
export interface AssistantImages {
api: ImagesApi;
provider: ImagesProviderId;
api: ImageApi;
provider: ProviderId;
model: string;
output: ImagesOutputContent[];
responseId?: string;
@ -579,6 +597,62 @@ export interface AssistantImages {
timestamp: number; // Unix timestamp in milliseconds
}
export interface ClassifierChoiceQuestion {
type: "choice";
instructions: string;
criteria: Record<string, string>;
}
export interface ClassifierScoreQuestion {
type: "score";
instructions: string;
criteria: string[];
}
export interface ClassifierBoolQuestion {
type: "bool";
instructions: string;
criteria: { true: string; false: string };
}
export type ClassifierQuestion = ClassifierChoiceQuestion | ClassifierScoreQuestion | ClassifierBoolQuestion;
export interface ClassifierContext {
state: JsonObject;
questions: Record<string, ClassifierQuestion>;
}
export interface ClassifierChoiceAnswer {
type: "choice";
choice: string;
probabilities: Record<string, number>;
confidence: number;
}
export interface ClassifierScoreAnswer {
type: "score";
score: number;
confidence: number;
}
export interface ClassifierBoolAnswer {
type: "bool";
probability: number;
}
export type ClassifierAnswer = ClassifierChoiceAnswer | ClassifierScoreAnswer | ClassifierBoolAnswer;
export type ClassifierStopReason = "stop" | "error" | "aborted";
export interface ClassifierResult {
api: ClassifierApi;
provider: ProviderId;
model: string;
answers: Record<string, ClassifierAnswer>;
stopReason: ClassifierStopReason;
errorMessage?: string;
timestamp: number; // Unix timestamp in milliseconds
}
import type { TSchema } from "typebox";
/** OpenAI grammar variants for constrained sampling. */
@ -984,30 +1058,40 @@ export interface ModelInputLimits {
images?: ModelImageInputLimits;
}
// Model interface for the unified model system
export interface Model<TApi extends Api> {
/** Fields shared by every catalog entry, regardless of what you can do with it. */
export interface BaseModel<TApi extends string> {
id: string;
name: string;
api: TApi;
provider: ProviderId;
baseUrl: string;
input: ("text" | "image")[];
/** Provider input limits and cache-safe preprocessing metadata. */
inputLimits?: ModelInputLimits;
cost: ModelCost;
headers?: Record<string, string>;
}
/** Chat model: usable with `stream()` and friends. */
export interface Model<TApi extends Api> extends BaseModel<TApi> {
/**
* Optional: chat is the default model type, so models without `type` are chat
* models. Narrow mixed model lists with `isModelType()` instead of comparing
* `type` directly.
*/
type?: "chat";
reasoning: boolean;
/**
* Maps pi thinking levels to provider/model-specific values.
* Missing keys use provider defaults. null marks a level as unsupported.
*/
thinkingLevelMap?: ThinkingLevelMap;
input: ("text" | "image")[];
/** Provider input limits and cache-safe preprocessing metadata. */
inputLimits?: ModelInputLimits;
cost: ModelCost;
/** Prompt cache lifetimes per retention tier. Unset when the provider's cache behavior is unknown. */
promptCache?: ModelPromptCache;
contextWindow: number;
maxTokens: number;
/** Default sampling parameters for this model. See {@link StreamOptions.samplingParams}; per-request keys override these. */
samplingParams?: Record<string, unknown>;
headers?: Record<string, string>;
/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */
compat?: TApi extends "openai-completions"
? OpenAICompletionsCompat
@ -1022,9 +1106,28 @@ export interface Model<TApi extends Api> {
: never;
}
export interface ImagesModel<TApi extends ImagesApi>
extends Omit<Model<Api>, "api" | "provider" | "reasoning" | "contextWindow" | "maxTokens" | "compat"> {
api: TApi;
provider: ImagesProviderId;
/** Image-generation model: usable with `generateImages()` only. */
export interface ImageModel<TApi extends ImageApi> extends BaseModel<TApi> {
type: "image";
/** Output modalities. Always includes `"image"`; `"text"` means the model can also return text blocks. */
output: ("text" | "image")[];
}
/** Structured classifier model: usable with `classify()` only. */
export interface ClassifierModel<TApi extends ClassifierApi> extends BaseModel<TApi> {
type: "classifier";
contextWindow: number;
}
/** Model shape for each model type. */
export interface ModelTypeMap {
chat: Model<Api>;
image: ImageModel<ImageApi>;
classifier: ClassifierModel<ClassifierApi>;
}
/** What a catalog entry is for. Decides which `Models` operation accepts it. */
export type ModelType = keyof ModelTypeMap;
/** Anything a provider can list. Narrow with `isModelType()`. */
export type AnyModel = ModelTypeMap[ModelType];

View file

@ -8,11 +8,16 @@ export function headersToRecord(headers: Headers): Record<string, string> {
return result;
}
export function providerHeadersToRecord(headers: ProviderHeaders | undefined): Record<string, string> | undefined {
if (!headers) return undefined;
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (value !== null) result[key] = value;
export function providerHeadersToRecord(
...headerSources: (ProviderHeaders | undefined)[]
): Record<string, string> | undefined {
const merged = new Map<string, [string, string]>();
for (const source of headerSources) {
for (const [name, value] of Object.entries(source ?? {})) {
const normalizedName = name.toLowerCase();
merged.delete(normalizedName);
if (value !== null) merged.set(normalizedName, [name, value]);
}
return Object.keys(result).length > 0 ? result : undefined;
}
return merged.size > 0 ? Object.fromEntries(merged.values()) : undefined;
}

View file

@ -0,0 +1,70 @@
import type {
AnyModel,
Api,
AssistantImages,
ClassifierApi,
ClassifierModel,
ClassifierResult,
ImageApi,
ImageModel,
Model,
ModelType,
ModelTypeMap,
} from "../types.ts";
import { ModelsError } from "./models-error.ts";
/** The type of a model. Models without `type` are chat models. */
export function getModelType(model: AnyModel): ModelType {
return model.type ?? "chat";
}
/** Runtime-checked model type narrowing, including legacy chat models without `type`. */
export function isModelType<TType extends ModelType>(model: AnyModel, type: TType): model is ModelTypeMap[TType] {
return getModelType(model) === type;
}
export function assertChatModel(model: AnyModel): asserts model is Model<Api> {
if (!isModelType(model, "chat")) {
throw new ModelsError("provider", `Model ${model.provider}/${model.id} is not a chat model`);
}
}
export function assertImageModel(model: AnyModel): asserts model is ImageModel<ImageApi> {
if (!isModelType(model, "image")) {
throw new ModelsError("provider", `Model ${model.provider}/${model.id} is not an image model`);
}
}
export function assertClassifierModel(model: AnyModel): asserts model is ClassifierModel<ClassifierApi> {
if (!isModelType(model, "classifier")) {
throw new ModelsError("provider", `Model ${model.provider}/${model.id} is not a classifier model`);
}
}
export function imageErrorResult(model: ImageModel<ImageApi>, error: unknown, aborted = false): AssistantImages {
return {
api: model.api,
provider: model.provider,
model: model.id,
output: [],
stopReason: aborted ? "aborted" : "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}
export function classifierErrorResult(
model: ClassifierModel<ClassifierApi>,
error: unknown,
aborted = false,
): ClassifierResult {
return {
api: model.api,
provider: model.provider,
model: model.id,
answers: {},
stopReason: aborted ? "aborted" : "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}

View file

@ -0,0 +1,21 @@
import { formatThrownValue } from "./diagnostics.ts";
export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth";
export class ModelsError extends Error {
readonly code: ModelsErrorCode;
constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) {
super(withCauseDetail(message, options?.cause), options);
this.name = "ModelsError";
this.code = code;
}
}
/** Callers surface `error.message` only, so keep the underlying reason in it. */
function withCauseDetail(message: string, cause: unknown): string {
if (cause === undefined || cause === null) return message;
const detail = formatThrownValue(cause).trim();
if (!detail || message.includes(detail)) return message;
return `${message}: ${detail}`;
}

View file

@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import { createModels, createProvider, getModelType } from "../src/models.ts";
import {
builtinModels,
getAllBuiltinModels,
getBuiltinClassifierModel,
getBuiltinClassifierModels,
} from "../src/providers/all.ts";
import type { Api, ClassifierApi, ClassifierContext, ClassifierModel, ClassifierResult, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function classifierModel(provider: string, id: string): ClassifierModel<ClassifierApi> {
return {
type: "classifier",
id,
name: id,
api: "test-classifier",
provider,
baseUrl: "https://example.test/v1",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
};
}
function chatModel(provider: string, id: string): Model<Api> {
return {
id,
name: id,
api: "test-chat",
provider,
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
}
const context: ClassifierContext = {
state: { text: "yes" },
questions: {
approved: {
type: "bool",
instructions: "Does this express approval?",
criteria: { true: "Approval", false: "No approval" },
},
},
};
describe("Models with classifier models", () => {
it("keeps chat and classifier entries with the same provider and id separate", async () => {
const chat = chatModel("test", "shared");
const classifier = classifierModel("test", "shared");
const provider = createProvider({
id: "test",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [chat, classifier],
api: {
"test-chat": {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
},
},
classifiers: {
"test-classifier": {
classify: async (model): Promise<ClassifierResult> => ({
api: model.api,
provider: model.provider,
model: model.id,
answers: { approved: { type: "bool", probability: 0.9 } },
stopReason: "stop",
timestamp: Date.now(),
}),
},
},
});
const models = createModels();
models.setProvider(provider);
const listedChat = models.getModel("test", "shared");
expect(listedChat && getModelType(listedChat)).toBe("chat");
expect(models.getModelOfType("classifier", "test", "shared")?.type).toBe("classifier");
expect(models.getModelsOfType("classifier")).toEqual([classifier]);
expect(models.getAllModels()).toHaveLength(2);
expect(await models.getAvailableOfType("classifier")).toEqual([classifier]);
expect((await models.classify(classifier, context)).answers.approved).toEqual({
type: "bool",
probability: 0.9,
});
});
it("rejects chat models at the classifier entry point at runtime", async () => {
const chat = chatModel("test", "chat");
const models = createModels();
models.setProvider(
createProvider({
id: "test",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [chat],
api: {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
},
}),
);
const result = await models.classify(chat as unknown as ClassifierModel<ClassifierApi>, context);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("is not a classifier model");
});
it("exposes Jev only through classifier catalog accessors", () => {
const jev = getBuiltinClassifierModel("typesafe", "jev-latest");
expect(jev).toMatchObject({
type: "classifier",
api: "typesafe-system-one",
provider: "typesafe",
contextWindow: 64000,
});
expect(getBuiltinClassifierModels("typesafe")).toEqual([jev]);
expect(getAllBuiltinModels("typesafe")).toEqual([jev]);
const models = builtinModels();
expect(models.getModel("typesafe", "jev-latest")).toBeUndefined();
expect(models.getModelOfType("classifier", "typesafe", "jev-latest")).toEqual(jev);
});
});

View file

@ -9,7 +9,7 @@ import { streamSimple as streamOpenAICompletions } from "../src/api/openai-compl
import { streamSimple as streamOpenAIResponses } from "../src/api/openai-responses.ts";
import { generateImages } from "../src/api/openrouter-images.ts";
import { streamSimple as streamPiMessages } from "../src/api/pi-messages.ts";
import type { Api, FetchFunction, ImagesModel, Model } from "../src/types.ts";
import type { Api, FetchFunction, ImageModel, Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const context = normalizeContext({
@ -160,8 +160,9 @@ describe("fetch stream option", () => {
it("uses fetch for image generation", async () => {
const { custom, fallback } = mockFetches();
const model: ImagesModel<"openrouter-images"> = {
const model: ImageModel<"openrouter-images"> = {
...createModel("openrouter-images"),
type: "image",
provider: "openrouter",
output: ["image"],
};

View file

@ -40,7 +40,8 @@ function generateFireworksModels(
`globalThis.fetch = async (input) => {\n` +
` const url = String(input);\n` +
` if (url === "https://models.dev/api.json") return Response.json(catalog);\n` +
` if (url === "https://openrouter.ai/api/v1/models" || url === "https://ai-gateway.vercel.sh/v1/models") return Response.json({ data: [] });\n` +
` if (url === "https://models.dev/models.json?type=decision") return Response.json({ "typesafe/jev-latest": { name: "Jev", type: "decision", limit: { context: 64000, output: 0 } } });\n` +
` if (url.startsWith("https://openrouter.ai/api/v1/models") || url === "https://ai-gateway.vercel.sh/v1/models") return Response.json({ data: [] });\n` +
` if (url === "https://radius.pi.dev/v1/config") return Response.json({ baseUrl: "https://radius.pi.dev", models: [{ id: "test", name: "Test", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 4096, maxTokens: 4096 }] });\n` +
` throw new Error(\`Unexpected fetch: \${url}\`);\n` +
`};\n`,

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { parseOpenRouterImageModels } from "../scripts/generate-image-models.ts";
import { buildOpenRouterCatalog, type OpenRouterModelListItem } from "../scripts/openrouter-catalog.ts";
const validImageModel = {
const imageOnly: OpenRouterModelListItem = {
id: "example/image-model",
name: "Example Image Model",
architecture: {
@ -14,34 +14,56 @@ const validImageModel = {
},
};
describe("OpenRouter image model parsing", () => {
it.each([{}, { data: [] }, { data: "invalid" }])("rejects a missing or empty strict catalog", (payload) => {
expect(() => parseOpenRouterImageModels(payload, true)).toThrow("missing or empty image model list");
});
it("rejects a strict catalog with no usable image models", () => {
expect(() =>
parseOpenRouterImageModels(
{
data: [
{
...validImageModel,
architecture: { input_modalities: ["text"], output_modalities: ["text"] },
const chatWithImages: OpenRouterModelListItem = {
id: "example/multimodal",
name: "Example Multimodal",
supported_parameters: ["tools"],
architecture: {
modality: "text+image->text+image",
input_modalities: ["text", "image"],
output_modalities: ["text", "image"],
},
],
},
true,
),
).toThrow("no usable image models");
});
context_length: 32000,
};
it("parses a non-empty image model catalog", () => {
expect(parseOpenRouterImageModels({ data: [validImageModel] }, true)).toEqual([
const chatOnly: OpenRouterModelListItem = {
id: "example/chat",
name: "Example Chat",
supported_parameters: ["tools"],
architecture: { modality: "text->text", output_modalities: ["text"] },
};
describe("OpenRouter catalog parsing", () => {
it("emits image-only models from the image listing as image models", () => {
const catalog = buildOpenRouterCatalog([], [imageOnly]);
expect(catalog.chat).toEqual([]);
expect(catalog.images).toEqual([
expect.objectContaining({
type: "image",
id: "example/image-model",
api: "openrouter-images",
input: ["text", "image"],
output: ["image"],
cost: expect.objectContaining({ input: 1, output: 2 }),
}),
]);
});
it("emits separate chat and image entries for an id that supports both operations", () => {
const catalog = buildOpenRouterCatalog([chatWithImages, chatOnly], [chatWithImages, imageOnly]);
expect(catalog.chat.map((model) => model.id)).toEqual(["example/multimodal", "example/chat"]);
expect(catalog.chat.map((model) => model.type)).toEqual(["chat", "chat"]);
expect(catalog.images.map((model) => model.id)).toEqual(["example/multimodal", "example/image-model"]);
expect(catalog.images.every((model) => model.type === "image")).toBe(true);
expect(catalog.images.map((model) => model.output)).toEqual([["text", "image"], ["image"]]);
});
it("ignores listed models that neither support tools nor emit images", () => {
const catalog = buildOpenRouterCatalog(
[{ ...chatOnly, supported_parameters: [] }],
[{ ...imageOnly, architecture: { output_modalities: ["text"] } }],
);
expect(catalog.chat).toEqual([]);
expect(catalog.images).toEqual([]);
});
});

View file

@ -1,8 +1,34 @@
import { describe, expect, it } from "vitest";
import type { AuthContext } from "../src/auth/types.ts";
import { createImagesModels, createImagesProvider, type ImagesProvider } from "../src/images-models.ts";
import { builtinImagesModels } from "../src/providers/all.ts";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ImagesOptions } from "../src/types.ts";
import { getModels as getCompatModels } from "../src/compat.ts";
import {
type CreateProviderOptions,
createModels,
createProvider,
getModelType,
hasApi,
isModelType,
type Provider,
} from "../src/models.ts";
import { InMemoryModelsStore } from "../src/models-store.ts";
import {
builtinModels,
getAllBuiltinModels,
getBuiltinImageModel,
getBuiltinImageModels,
getBuiltinModels,
} from "../src/providers/all.ts";
import type {
AnyModel,
Api,
AssistantImages,
ImageApi,
ImageModel,
ImagesContext,
ImagesOptions,
Model,
} from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function fakeAuthContext(env: Record<string, string>): AuthContext {
return {
@ -11,8 +37,9 @@ function fakeAuthContext(env: Record<string, string>): AuthContext {
};
}
function testImageModel(provider: string, id: string): ImagesModel<ImagesApi> {
function imageModel(provider: string, id: string): ImageModel<ImageApi> {
return {
type: "image",
id,
name: id,
api: "test-images",
@ -24,7 +51,22 @@ function testImageModel(provider: string, id: string): ImagesModel<ImagesApi> {
};
}
function okResult(model: ImagesModel<ImagesApi>): AssistantImages {
function chatModel(provider: string, id: string): Model<Api> {
return {
id,
name: id,
api: "test-chat",
provider,
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
}
function okResult(model: ImageModel<ImageApi>): AssistantImages {
return {
api: model.api,
provider: model.provider,
@ -36,17 +78,24 @@ function okResult(model: ImagesModel<ImagesApi>): AssistantImages {
}
interface GenerateCall {
model: ImagesModel<ImagesApi>;
model: ImageModel<ImageApi>;
options: ImagesOptions | undefined;
}
function testProvider(input: {
id: string;
models?: ImagesModel<ImagesApi>[];
models?: AnyModel[];
envVar?: string;
calls?: GenerateCall[];
}): ImagesProvider {
return createImagesProvider({
images?: Record<string, boolean>;
}): Provider {
const generateImages = async (model: ImageModel<ImageApi>, _context: ImagesContext, options?: ImagesOptions) => {
input.calls?.push({ model, options });
return okResult(model);
};
const imageApis = Object.keys(input.images ?? { "test-images": true });
const allModels = input.models ?? [imageModel(input.id, "model-a")];
return createProvider({
id: input.id,
auth: {
apiKey: {
@ -58,39 +107,102 @@ function testProvider(input: {
},
},
},
models: input.models ?? [testImageModel(input.id, "model-a")],
models: allModels,
api: {
generateImages: async (model, _context, options) => {
input.calls?.push({ model, options });
return okResult(model);
"test-chat": {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
},
},
images: Object.fromEntries(imageApis.map((api) => [api, { generateImages }])),
});
}
const context: ImagesContext = { input: [{ type: "text", text: "a red circle" }] };
describe("ImagesModels", () => {
it("registers providers and reads models synchronously", () => {
const models = createImagesModels();
models.setProvider(testProvider({ id: "p1", models: [testImageModel("p1", "m1"), testImageModel("p1", "m2")] }));
models.setProvider(testProvider({ id: "p2", models: [testImageModel("p2", "m3")] }));
describe("model discriminants", () => {
it("treats models without a type as chat models", () => {
const chat = chatModel("p", "c");
const image = imageModel("p", "i");
expect(models.getProviders().map((p) => p.id)).toEqual(["p1", "p2"]);
expect(models.getModels().map((m) => m.id)).toEqual(["m1", "m2", "m3"]);
expect(models.getModels("p1").map((m) => m.id)).toEqual(["m1", "m2"]);
expect(models.getModel("p2", "m3")?.id).toBe("m3");
expect(models.getModel("p2", "missing")).toBeUndefined();
expect(chat.type).toBeUndefined();
expect(getModelType(chat)).toBe("chat");
expect(getModelType({ ...chat, type: "chat" })).toBe("chat");
expect(getModelType(image)).toBe("image");
expect(isModelType(chat, "chat")).toBe(true);
expect(isModelType(chat, "image")).toBe(false);
expect(isModelType(image, "image")).toBe(true);
models.deleteProvider("p1");
expect(models.getProvider("p1")).toBeUndefined();
// hasApi never matches an image model, even on an equal api string
expect(hasApi({ ...image, api: "test-chat" }, "test-chat")).toBe(false);
expect(hasApi(chat, "test-chat")).toBe(true);
});
});
describe("Models with image models", () => {
it("lists models without a type as chat models at createProvider boundaries", async () => {
const provider = createProvider({
id: "legacy",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [chatModel("legacy", "static"), imageModel("legacy", "static")],
fetchModels: async () => [chatModel("legacy", "dynamic")],
api: {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
},
});
const models = createModels();
models.setProvider(provider);
expect(provider.getModels().map((model) => model.id)).toEqual(["static"]);
await models.refresh({ providers: [provider.id] });
expect(provider.getModels().map((model) => model.id)).toEqual(["static", "dynamic"]);
expect(provider.getModels().map((model) => model.type)).toEqual([undefined, undefined]);
expect(models.getModelsOfType("image", "legacy").map((model) => model.id)).toEqual(["static"]);
});
it("resolves auth through the provider and merges it into requests; explicit options win", async () => {
it("lists chat, image, and all models through typed accessors", () => {
const models = createModels();
models.setProvider(
testProvider({
id: "p1",
models: [chatModel("p1", "c1"), imageModel("p1", "i1"), imageModel("p1", "i2")],
}),
);
models.setProvider(testProvider({ id: "p2", models: [imageModel("p2", "i3")] }));
expect(models.getModels().map((m) => m.id)).toEqual(["c1"]);
expect(models.getModelsOfType("chat").map((m) => m.id)).toEqual(["c1"]);
expect(models.getModelsOfType("image").map((m) => m.id)).toEqual(["i1", "i2", "i3"]);
expect(models.getModelsOfType("image", "p1").map((m) => m.id)).toEqual(["i1", "i2"]);
expect(models.getModelsOfType("classifier")).toEqual([]);
expect(models.getAllModels().map((m) => m.id)).toEqual(["c1", "i1", "i2", "i3"]);
expect(models.getModel("p1", "c1")?.id).toBe("c1");
expect(models.getModel("p1", "i1")).toBeUndefined();
expect(models.getModelOfType("chat", "p1", "c1")?.id).toBe("c1");
expect(models.getModelOfType("image", "p1", "i1")?.id).toBe("i1");
expect(models.getModelOfType("image", "p1", "c1")).toBeUndefined();
});
it("splits available models by type", async () => {
const models = createModels({ authContext: fakeAuthContext({ KEY: "k" }) });
models.setProvider(
testProvider({ id: "p1", envVar: "KEY", models: [chatModel("p1", "c1"), imageModel("p1", "i1")] }),
);
models.setProvider(testProvider({ id: "p2", envVar: "MISSING", models: [imageModel("p2", "i2")] }));
expect((await models.getAvailable()).map((m) => m.id)).toEqual(["c1"]);
expect((await models.getAvailableOfType("chat")).map((m) => m.id)).toEqual(["c1"]);
expect((await models.getAvailableOfType("image")).map((m) => m.id)).toEqual(["i1"]);
expect((await models.getAllAvailable()).map((m) => m.id)).toEqual(["c1", "i1"]);
});
it("resolves auth through the provider and merges it into image requests; explicit options win", async () => {
const calls: GenerateCall[] = [];
const models = createImagesModels({ authContext: fakeAuthContext({ TEST_KEY: "env-key" }) });
const models = createModels({ authContext: fakeAuthContext({ TEST_KEY: "env-key" }) });
models.setProvider(testProvider({ id: "p1", envVar: "TEST_KEY", calls }));
const model = models.getModel("p1", "model-a")!;
const model = models.getModelOfType("image", "p1", "model-a")!;
expect((await models.getAuth(model))?.auth.apiKey).toBe("env-key");
expect((await models.getAuth(model.provider))?.auth.apiKey).toBe("env-key");
@ -104,35 +216,38 @@ describe("ImagesModels", () => {
expect(calls[1].options?.apiKey).toBe("explicit");
});
it("merges provider-resolved env into image options", async () => {
it("merges provider-resolved env and applies header transforms", async () => {
const calls: GenerateCall[] = [];
const models = createImagesModels();
const models = createModels();
models.setProvider(
createImagesProvider({
createProvider({
id: "p1",
auth: {
apiKey: {
name: "Test key",
resolve: async () => ({
auth: { apiKey: "provider-key" },
auth: { apiKey: "provider-key", headers: { "x-base": "1" } },
env: { PROVIDER_ONLY: "provider", SHARED: "provider" },
}),
},
},
models: [testImageModel("p1", "model-a")],
api: {
models: [imageModel("p1", "model-a")],
images: {
"test-images": {
generateImages: async (model, _context, options) => {
calls.push({ model, options });
return okResult(model);
},
},
},
}),
);
const model = models.getModel("p1", "model-a")!;
const model = models.getModelOfType("image", "p1", "model-a")!;
await models.generateImages(model, context, {
apiKey: "request-key",
env: { REQUEST_ONLY: "request", SHARED: "request" },
transformHeaders: (headers) => ({ ...headers, "x-extra": "2" }),
});
expect(calls[0].options?.apiKey).toBe("request-key");
@ -141,69 +256,154 @@ describe("ImagesModels", () => {
REQUEST_ONLY: "request",
SHARED: "request",
});
expect(calls[0].options?.headers).toEqual({ "x-base": "1", "x-extra": "2" });
});
it("returns an error result for unknown providers and unconfigured auth rejections", async () => {
const models = createImagesModels({ authContext: fakeAuthContext({}) });
const ghost = await models.generateImages(testImageModel("ghost", "m"), context);
it("returns error results instead of rejecting", async () => {
const models = createModels({ authContext: fakeAuthContext({}) });
const ghost = await models.generateImages(imageModel("ghost", "m"), context);
expect(ghost.stopReason).toBe("error");
expect(ghost.errorMessage).toContain("Unknown provider: ghost");
// unconfigured (resolve -> undefined) still dispatches; provider decides what to do
// Unconfigured auth is an error, matching stream().
const calls: GenerateCall[] = [];
models.setProvider(testProvider({ id: "p1", envVar: "MISSING", calls }));
const model = models.getModel("p1", "model-a")!;
const model = models.getModelOfType("image", "p1", "model-a")!;
expect(await models.getAuth(model)).toBeUndefined();
await models.generateImages(model, context);
expect(calls[0].options?.apiKey).toBeUndefined();
const unconfigured = await models.generateImages(model, context);
expect(unconfigured.stopReason).toBe("error");
expect(unconfigured.errorMessage).toContain("not configured");
expect(calls).toEqual([]);
const controller = new AbortController();
controller.abort();
const cancelled = await models.generateImages(model, context, { signal: controller.signal });
expect(cancelled.stopReason).toBe("aborted");
expect(calls).toEqual([]);
// A provider without any images implementation rejects image models it lists.
models.setProvider(
createProvider({
id: "chat-only",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [imageModel("chat-only", "i")],
api: {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
},
}),
);
const unsupported = await models.generateImages(models.getModelOfType("image", "chat-only", "i")!, context);
expect(unsupported.stopReason).toBe("error");
expect(unsupported.errorMessage).toContain("does not support image generation");
// An images map without the model's api yields a provider error result.
models.setProvider(
testProvider({ id: "wrong-api", models: [imageModel("wrong-api", "i")], images: { "other-images": true } }),
);
const missingApi = await models.generateImages(models.getModelOfType("image", "wrong-api", "i")!, context);
expect(missingApi.stopReason).toBe("error");
expect(missingApi.errorMessage).toContain('no image generation implementation for "test-images"');
});
it("supports dynamic providers via refresh with in-flight dedupe", async () => {
it("rejects chat models at the image entry point at runtime", async () => {
const models = createModels();
const chat = chatModel("p1", "chat");
models.setProvider(testProvider({ id: "p1", models: [chat], images: { "test-chat": true } }));
const result = await models.generateImages(chat as unknown as ImageModel<ImageApi>, context);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("is not an image model");
});
it("rejects image models at the stream entry points at runtime", async () => {
const models = createModels();
models.setProvider(testProvider({ id: "p1" }));
const image = models.getModelOfType("image", "p1", "model-a")!;
const result = await models.streamSimple(image as unknown as Model<Api>, { messages: [] }).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("is not a chat model");
});
it("requires at least one concrete operation implementation", () => {
const createEmptyProvider = (implementations: Pick<CreateProviderOptions, "api" | "images" | "classifiers">) =>
createProvider({
id: "empty",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
...implementations,
});
const message = 'at least one of "api", "images", or "classifiers"';
expect(() => createEmptyProvider({})).toThrow(message);
expect(() => createEmptyProvider({ api: {} })).toThrow(message);
expect(() => createEmptyProvider({ images: {} })).toThrow(message);
expect(() => createEmptyProvider({ classifiers: {} })).toThrow(message);
});
it("supports dynamic providers listing image models via refresh", async () => {
let fetches = 0;
const provider = createImagesProvider({
const modelsStore = new InMemoryModelsStore();
const models = createModels({ modelsStore });
models.setProvider(
createProvider({
id: "dyn",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
refreshModels: async () => {
fetchModels: async () => {
fetches++;
await new Promise((resolve) => setTimeout(resolve, 5));
return [testImageModel("dyn", "listed")];
return [imageModel("dyn", "listed"), chatModel("dyn", "chat")];
},
api: { generateImages: async (model) => okResult(model) },
});
const models = createImagesModels();
models.setProvider(provider);
expect(models.getModels("dyn")).toEqual([]);
await Promise.all([models.refresh("dyn"), models.refresh("dyn")]);
expect(fetches).toBe(1);
expect(models.getModel("dyn", "listed")).toBeDefined();
// failures reject with ModelsError for a single provider
models.setProvider(
createImagesProvider({
id: "flaky",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
refreshModels: async () => {
throw new Error("fetch failed");
},
api: { generateImages: async (model) => okResult(model) },
images: { "test-images": { generateImages: async (model) => okResult(model) } },
}),
);
await expect(models.refresh("flaky")).rejects.toMatchObject({ code: "model_source" });
await expect(models.refresh()).resolves.toBeUndefined();
expect(models.getAllModels("dyn")).toEqual([]);
const result = await models.refresh({ providers: ["dyn"] });
expect(result.errors.size).toBe(0);
expect(fetches).toBe(1);
expect(models.getModelOfType("image", "dyn", "listed")).toBeDefined();
expect(models.getModel("dyn", "chat")).toBeDefined();
const stored = await modelsStore.read("dyn");
expect(stored?.models.map((model) => model.id)).toEqual(["listed", "chat"]);
});
it("builtinImagesModels registers the openrouter provider with its catalog", async () => {
const models = builtinImagesModels({ authContext: fakeAuthContext({ OPENROUTER_API_KEY: "or-key" }) });
const providers = models.getProviders();
expect(providers.map((p) => p.id)).toEqual(["openrouter"]);
it("keeps existing built-in and compat model reads chat-only", () => {
const chat = getBuiltinModels("openrouter");
const images = getBuiltinImageModels("openrouter");
const all = getAllBuiltinModels("openrouter");
const compat = getCompatModels("openrouter");
const list = models.getModels("openrouter");
expect(list.length).toBeGreaterThan(0);
expect(list.every((m) => m.api === "openrouter-images")).toBe(true);
expect(chat.every((model) => isModelType(model, "chat"))).toBe(true);
expect(images.every((model) => isModelType(model, "image"))).toBe(true);
expect(all.some((model) => isModelType(model, "image"))).toBe(true);
expect(compat).toEqual(chat);
expect(chat.every((model) => model.contextWindow > 0)).toBe(true);
expect(chat.length + images.length).toBe(all.length);
expect(getBuiltinImageModel("openrouter", "black-forest-labs/flux.2-pro").type).toBe("image");
});
expect((await models.getAuth(list[0]))?.auth.apiKey).toBe("or-key");
it("builtinModels exposes OpenRouter image models under the openrouter provider", async () => {
const models = builtinModels({ authContext: fakeAuthContext({ OPENROUTER_API_KEY: "or-key" }) });
const provider = models.getProvider("openrouter")!;
const images = models.getModelsOfType("image", "openrouter");
expect(images.length).toBeGreaterThan(0);
expect(provider.getModels().every((model) => isModelType(model, "chat"))).toBe(true);
expect(provider.getAllModels?.().some((model) => isModelType(model, "image"))).toBe(true);
expect(images.every((m) => m.type === "image" && m.api === "openrouter-images")).toBe(true);
expect(models.getModelsOfType("image").every((m) => m.provider === "openrouter")).toBe(true);
// One upstream id can expose separate chat and image operations.
const chat = models.getModel("openrouter", "google/gemini-3-pro-image");
const image = models.getModelOfType("image", "openrouter", "google/gemini-3-pro-image");
expect(chat?.api).toBe("openai-completions");
expect(image?.api).toBe("openrouter-images");
// One credential covers both.
expect((await models.getAuth(images[0]))?.auth.apiKey).toBe("or-key");
expect((await models.getAuth(chat!))?.auth.apiKey).toBe("or-key");
expect(provider.generateImages).toBeDefined();
});
});

View file

@ -4,14 +4,14 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { getImageModel } from "../src/image-models.ts";
import { generateImages } from "../src/images.ts";
import type { ImageContent, ImagesContext, ImagesModel, ProviderImagesOptions } from "../src/types.ts";
import type { ImageContent, ImageModel, ImagesContext, ProviderImagesOptions } from "../src/types.ts";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
type ImagesOptionsWithExtras = ProviderImagesOptions & Record<string, unknown>;
async function basicImageGeneration<TApi extends string>(model: ImagesModel<TApi>, options?: ImagesOptionsWithExtras) {
async function basicImageGeneration<TApi extends string>(model: ImageModel<TApi>, options?: ImagesOptionsWithExtras) {
const context: ImagesContext = {
input: [{ type: "text", text: "Generate a simple red circle on a plain white background. No text." }],
};
@ -24,27 +24,7 @@ async function basicImageGeneration<TApi extends string>(model: ImagesModel<TApi
expect(response.timestamp).toBeGreaterThan(0);
}
async function handleTextAndImageOutput<TApi extends string>(
model: ImagesModel<TApi>,
options?: ImagesOptionsWithExtras,
) {
if (!model.output.includes("text")) {
console.log(`Skipping text+image output test - model ${model.id} doesn't support text output`);
return;
}
const context: ImagesContext = {
input: [{ type: "text", text: "Generate a red circle and include a brief description of the image." }],
};
const response = await generateImages(model, context, options);
expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("stop");
expect(response.output.some((item) => item.type === "image")).toBe(true);
expect(response.output.some((item) => item.type === "text" && item.text.trim().length > 0)).toBe(true);
}
async function handleImageInput<TApi extends string>(model: ImagesModel<TApi>, options?: ImagesOptionsWithExtras) {
async function handleImageInput<TApi extends string>(model: ImageModel<TApi>, options?: ImagesOptionsWithExtras) {
if (!model.input.includes("image")) {
console.log(`Skipping image input test - model ${model.id} doesn't support image input`);
return;
@ -78,10 +58,6 @@ describe("Images E2E Tests", () => {
await basicImageGeneration(model);
});
it("should handle text plus image output", { retry: 3 }, async () => {
await handleTextAndImageOutput(model);
});
it("should handle image input", { retry: 3 }, async () => {
await handleImageInput(model);
});

View file

@ -32,20 +32,21 @@ function createFixture(): {
mkdirSync(dataDir, { recursive: true });
writeFileSync(
join(packageRoot, "src", "models.generated.ts"),
'import { TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\n',
'import { TEST_PROVIDER_CLASSIFIER_MODELS, TEST_PROVIDER_IMAGE_MODELS, TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\n',
);
writeFileSync(
join(providersDir, "test-provider.models.ts"),
'import values from "./data/test-provider.json" with { type: "json" };\nimport { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";\n\nexport const TEST_PROVIDER_MODELS: ModelCatalog<typeof values, "test-provider"> =\n\tflattenModelCatalog("test-provider", values);\n',
'import values from "./data/test-provider.json" with { type: "json" };\n',
);
const structure: ModelDataStructure = {
"test-provider": {
"model-a": "openai-completions",
"chat:model-a": "openai-completions",
},
};
const values: Record<string, unknown> = {
"model-a": {
"chat:model-a": {
type: "chat",
id: "model-a",
name: "Model A",
api: "openai-completions",
@ -108,12 +109,90 @@ describe("generated model data validation", () => {
["api", "anthropic-messages", "has api"],
] as const)("rejects a wrong model %s", (field, value, expectedMessage) => {
const fixture = createFixture();
const model = fixture.values["model-a"] as Record<string, unknown>;
const model = fixture.values["chat:model-a"] as Record<string, unknown>;
model[field] = value;
writeFixtureData(fixture.dataDir, fixture.structure, fixture.values);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow(expectedMessage);
});
it("rejects a model without a known type", () => {
const fixture = createFixture();
const model = fixture.values["chat:model-a"] as Record<string, unknown>;
delete model.type;
writeFixtureData(fixture.dataDir, fixture.structure, fixture.values);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow(
'expected "chat", "image", or "classifier"',
);
});
it("validates image models with output modalities and without chat limits", () => {
const fixture = createFixture();
const structure: ModelDataStructure = { "test-provider": { "image:image-a": "test-images" } };
const image: Record<string, unknown> = {
type: "image",
id: "image-a",
name: "Image A",
api: "test-images",
provider: "test-provider",
baseUrl: "https://example.test/v1",
input: ["text"],
output: ["image", "text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
};
const validate = () => {
writeFixtureData(
fixture.dataDir,
structure,
{ "image:image-a": image },
MODEL_DATA_SCHEMA_VERSION,
"test-images",
);
validateModelDataDirectory(structure, fixture.dataDir);
};
expect(validate).not.toThrow();
delete image.output;
expect(validate).toThrow("invalid output modalities");
image.output = ["text"];
expect(validate).toThrow("invalid output modalities");
});
it("rejects output modalities on chat models", () => {
const fixture = createFixture();
const model = fixture.values["chat:model-a"] as Record<string, unknown>;
model.output = ["text"];
writeFixtureData(fixture.dataDir, fixture.structure, fixture.values);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow(
"unsupported output modalities",
);
});
it("validates classifier models without chat output limits", () => {
const fixture = createFixture();
const structure: ModelDataStructure = {
"test-provider": { "classifier:classifier-a": "test-classifier" },
};
const classifier = {
type: "classifier",
id: "classifier-a",
name: "Classifier A",
api: "test-classifier",
provider: "test-provider",
baseUrl: "https://example.test/v1",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
};
writeFixtureData(
fixture.dataDir,
structure,
{ "classifier:classifier-a": classifier },
MODEL_DATA_SCHEMA_VERSION,
"test-classifier",
);
expect(() => validateModelDataDirectory(structure, fixture.dataDir)).not.toThrow();
});
it("rejects a model in the wrong API group", () => {
const fixture = createFixture();
writeFixtureData(
@ -170,7 +249,7 @@ describe("generated model data validation", () => {
const { packageRoot } = createFixture();
writeFileSync(
join(packageRoot, "src", "models.generated.ts"),
'import { TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\nimport { MISSING_MODELS } from "./providers/missing.models.ts";\n',
'import { TEST_PROVIDER_CLASSIFIER_MODELS, TEST_PROVIDER_IMAGE_MODELS, TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\nimport { MISSING_CLASSIFIER_MODELS, MISSING_IMAGE_MODELS, MISSING_MODELS } from "./providers/missing.models.ts";\n',
);
expect(() => readModelDataStructure(packageRoot)).toThrow("aggregator and provider shards do not match");
});

View file

@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import { getModel as getCompatModel } from "../src/compat.ts";
import {
createModels,
createProvider,
getModelType,
hasApi,
isModelType,
modelsAreEqual,
type Provider,
} from "../src/models.ts";
import { InMemoryModelsStore, type ModelsStoreEntry } from "../src/models-store.ts";
import { getBuiltinModel } from "../src/providers/all.ts";
import { fauxAssistantMessage, fauxProvider } from "../src/providers/faux.ts";
import type { Api, ImageApi, ImageModel, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function chatModel(provider: string, id: string): Model<Api> {
return {
id,
name: id,
api: "test-chat",
provider,
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
}
function imageModel(provider: string, id: string): ImageModel<ImageApi> {
return {
type: "image",
id,
name: id,
api: "test-images",
provider,
baseUrl: "https://example.test/v1",
input: ["text"],
output: ["image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
}
const noAuth = { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } };
const chatStreams = {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
};
describe("chat models without a type", () => {
it("work through a handwritten provider without getAllModels", async () => {
const faux = fauxProvider({ provider: "handwritten" });
const handwritten: Provider = {
id: "handwritten",
name: "Handwritten",
auth: faux.provider.auth,
getModels: () => faux.models,
stream: faux.provider.stream,
streamSimple: faux.provider.streamSimple,
};
const models = createModels();
models.setProvider(handwritten);
const model = models.getModel("handwritten", faux.models[0].id);
expect(model).toBeDefined();
expect(model?.type).toBeUndefined();
expect(getModelType(model!)).toBe("chat");
expect(hasApi(model!, model!.api)).toBe(true);
expect(modelsAreEqual(model, { ...model!, type: "chat" })).toBe(true);
expect(modelsAreEqual(model, { ...imageModel(model!.provider, model!.id) })).toBe(false);
expect(models.getModelsOfType("chat", "handwritten")).toEqual(faux.models);
expect(models.getAllModels("handwritten")).toEqual(faux.models);
expect(models.getModelsOfType("image", "handwritten")).toEqual([]);
faux.setResponses([fauxAssistantMessage("hi")]);
const result = await models.complete(model!, { messages: [{ role: "user", content: "hi", timestamp: 0 }] });
expect(result.stopReason).toBe("stop");
});
it("narrow mixed lists with isModelType", () => {
const mixed = [chatModel("p", "c"), { ...chatModel("p", "typed"), type: "chat" as const }, imageModel("p", "i")];
expect(mixed.filter((model) => isModelType(model, "chat")).map((model) => model.id)).toEqual(["c", "typed"]);
expect(mixed.filter((model) => isModelType(model, "image")).map((model) => model.id)).toEqual(["i"]);
expect(mixed.filter((model) => isModelType(model, "classifier"))).toEqual([]);
});
});
describe("built-in catalog getters", () => {
it("return model shapes that can be reassigned within one api", () => {
// Compile-time regression check: the return type must not carry literal model ids.
let model = getBuiltinModel("openai", "gpt-4o-mini");
model = getBuiltinModel("openai", "gpt-4o");
let compat = getCompatModel("openai", "gpt-4o-mini");
compat = getCompatModel("openai", "gpt-4o");
expect(model.id).toBe("gpt-4o");
expect(compat.id).toBe("gpt-4o");
});
});
describe("stored and fetched models of unknown types", () => {
it("are dropped instead of failing the refresh", async () => {
const modelsStore = new InMemoryModelsStore();
const stored = {
models: [
chatModel("dyn", "stored-chat"),
imageModel("dyn", "stored-image"),
{ ...chatModel("dyn", "future-embedding"), type: "embedding" },
{ ...imageModel("dyn", "future-video"), type: "video" },
],
} as unknown as ModelsStoreEntry;
await modelsStore.write("dyn", stored);
let fetched: unknown[] = [];
const models = createModels({ modelsStore });
models.setProvider(
createProvider({
id: "dyn",
auth: noAuth,
models: [],
fetchModels: async () => fetched as Model<Api>[],
api: chatStreams,
}),
);
const restored = await models.refresh({ providers: ["dyn"], allowNetwork: false });
expect(restored.errors.size).toBe(0);
expect(models.getAllModels("dyn").map((model) => model.id)).toEqual(["stored-chat", "stored-image"]);
fetched = [chatModel("dyn", "fetched-chat"), { ...imageModel("dyn", "fetched-video"), type: "video" }];
const refreshed = await models.refresh({ providers: ["dyn"] });
expect(refreshed.errors.size).toBe(0);
expect(models.getAllModels("dyn").map((model) => model.id)).toEqual(["fetched-chat"]);
expect(await modelsStore.read("dyn")).toMatchObject({ models: [{ id: "fetched-chat" }] });
});
});

View file

@ -198,6 +198,20 @@ describe("Models runtime", () => {
}
});
it("keeps chat reads independent from the all-model catalog", async () => {
const provider = testProvider({ id: "chat-only" });
provider.getAllModels = () => {
throw new Error("all models unavailable");
};
const models = createModels();
models.setProvider(provider);
expect(models.getModels("chat-only").map((model) => model.id)).toEqual(["model-a"]);
expect(models.getModel("chat-only", "model-a")?.id).toBe("model-a");
expect((await models.getAvailable("chat-only")).map((model) => model.id)).toEqual(["model-a"]);
expect(models.getAllModels("chat-only")).toEqual([]);
});
it("swallows provider source failures for both all-provider and single-provider listing", () => {
const models = createModels();
models.setProvider(

View file

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { generateImages } from "../src/images.ts";
import type { ImagesContext, ImagesModel } from "../src/types.ts";
import type { ImageModel, ImagesContext } from "../src/types.ts";
const mockState = vi.hoisted(() => ({
lastParams: undefined as unknown,
@ -65,7 +65,8 @@ describe("openrouter images", () => {
});
it("returns text plus images in final output", async () => {
const model: ImagesModel<"openrouter-images"> = {
const model: ImageModel<"openrouter-images"> = {
type: "image",
id: "google/gemini-3.1-flash-image-preview",
name: "Gemini 3.1 Flash Image Preview",
api: "openrouter-images",
@ -97,7 +98,8 @@ describe("openrouter images", () => {
});
it("passes through abort signal and returns aborted result", async () => {
const model: ImagesModel<"openrouter-images"> = {
const model: ImageModel<"openrouter-images"> = {
type: "image",
id: "black-forest-labs/flux.2-pro",
name: "FLUX.2 Pro",
api: "openrouter-images",
@ -120,7 +122,8 @@ describe("openrouter images", () => {
});
it("generateImages resolves the final assistant images result", async () => {
const model: ImagesModel<"openrouter-images"> = {
const model: ImageModel<"openrouter-images"> = {
type: "image",
id: "black-forest-labs/flux.2-pro",
name: "FLUX.2 Pro",
api: "openrouter-images",
@ -136,5 +139,7 @@ describe("openrouter images", () => {
const output = await generateImages(model, context, { apiKey: "test" });
expect(output.output.some((item) => item.type === "image")).toBe(true);
// Image-only models must not request text output.
expect((mockState.lastParams as { modalities?: string[] }).modalities).toEqual(["image"]);
});
});

View file

@ -1,10 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { openRouterOAuth } from "../src/auth/oauth/openrouter.ts";
import { createImagesModels } from "../src/images-models.ts";
import { createModels } from "../src/models.ts";
import { openrouterProvider } from "../src/providers/openrouter.ts";
import { openrouterImagesProvider } from "../src/providers/openrouter-images.ts";
const TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
const nativeFetch = globalThis.fetch;
@ -26,15 +24,14 @@ describe.sequential("OpenRouter OAuth", () => {
vi.unstubAllEnvs();
});
it("is exposed by both OpenRouter providers alongside API-key auth", () => {
for (const provider of [openrouterProvider(), openrouterImagesProvider()]) {
it("is exposed alongside API-key auth", () => {
const provider = openrouterProvider();
expect(provider.auth.apiKey).toBeDefined();
expect(provider.auth.oauth).toBeDefined();
expect(provider.auth.oauth?.loginLabel).toBe("Sign in with OpenRouter");
}
});
it("resolves the same stored OAuth key for text and image providers", async () => {
it("resolves the same stored OAuth key for chat and image models", async () => {
const credentials = new InMemoryCredentialStore();
await credentials.modify("openrouter", async () => ({
type: "oauth",
@ -43,13 +40,15 @@ describe.sequential("OpenRouter OAuth", () => {
expires: Number.MAX_SAFE_INTEGER,
}));
const textModels = createModels({ credentials });
textModels.setProvider(openrouterProvider());
const imageModels = createImagesModels({ credentials });
imageModels.setProvider(openrouterImagesProvider());
const models = createModels({ credentials });
models.setProvider(openrouterProvider());
const chatModel = models.getModels("openrouter")[0];
const imageModel = models.getModelsOfType("image", "openrouter")[0];
expect(chatModel).toBeDefined();
expect(imageModel).toBeDefined();
expect((await textModels.getAuth("openrouter"))?.auth.apiKey).toBe("sk-or-stored");
expect((await imageModels.getAuth("openrouter"))?.auth.apiKey).toBe("sk-or-stored");
expect((await models.getAuth(chatModel))?.auth.apiKey).toBe("sk-or-stored");
expect((await models.getAuth(imageModel))?.auth.apiKey).toBe("sk-or-stored");
});
it("runs PKCE on a one-shot loopback callback and exchanges the code for a permanent API key", async () => {

View file

@ -14,7 +14,7 @@
import { describe, expect, it, vi } from "vitest";
import { generateImages } from "../src/images.ts";
import type { ImagesContext, ImagesModel } from "../src/types.ts";
import type { ImageModel, ImagesContext } from "../src/types.ts";
// Reproduce the openai SDK APIError shape: makeMessage(status, error, message)
// returns `"403 status code (no body)"` when status is set but the parsed body
@ -52,7 +52,8 @@ vi.mock("openai", () => {
describe("provider error body passthrough", () => {
it("surfaces the HTTP body reason instead of the opaque SDK message (openrouter images)", async () => {
const model: ImagesModel<"openrouter-images"> = {
const model: ImageModel<"openrouter-images"> = {
type: "image",
id: "black-forest-labs/flux.2-pro",
name: "FLUX.2 Pro",
api: "openrouter-images",

View file

@ -2,11 +2,17 @@ import { describe, expect, it } from "vitest";
import { lazyApi } from "../src/api/lazy.ts";
import { envApiKeyAuth } from "../src/auth/helpers.ts";
import type { AuthContext, AuthEvent } from "../src/auth/types.ts";
import { getModel as getCompatModel, getModels as getCompatModels } from "../src/compat.ts";
import { createModels, createProvider, getSupportedThinkingLevels } from "../src/models.ts";
import { InMemoryModelsStore } from "../src/models-store.ts";
import {
builtinModels,
builtinProviders,
getAllBuiltinModels,
getBuiltinClassifierModel,
getBuiltinClassifierModels,
getBuiltinImageModel,
getBuiltinImageModels,
getBuiltinModel,
getBuiltinModels,
getBuiltinProviders,
@ -60,7 +66,7 @@ describe("builtin providers", () => {
expect(all.length).toBeGreaterThan(500);
for (const provider of providers) {
const list = models.getModels(provider.id);
const list = models.getAllModels(provider.id);
expect(list.length).toBeGreaterThan(0);
expect(list.every((m) => m.provider === provider.id)).toBe(true);
}
@ -70,6 +76,21 @@ describe("builtin providers", () => {
});
});
it("returns empty results for unknown provider ids", () => {
const unknownProvider = "not-a-provider" as never;
const unknownModel = "x" as never;
expect(getBuiltinModel(unknownProvider, unknownModel)).toBeUndefined();
expect(getBuiltinImageModel(unknownProvider, unknownModel)).toBeUndefined();
expect(getBuiltinClassifierModel(unknownProvider, unknownModel)).toBeUndefined();
expect(getBuiltinModels(unknownProvider)).toEqual([]);
expect(getBuiltinImageModels(unknownProvider)).toEqual([]);
expect(getBuiltinClassifierModels(unknownProvider)).toEqual([]);
expect(getAllBuiltinModels(unknownProvider)).toEqual([]);
expect(getCompatModel(unknownProvider, unknownModel)).toBeUndefined();
expect(getCompatModels(unknownProvider)).toEqual([]);
});
it("stores native constrained-sampling capabilities in model metadata", () => {
const gpt4o = getBuiltinModel("openai", "gpt-4o");
expect(gpt4o.compat?.supportsStrictMode).toBe(true);

View file

@ -3,9 +3,8 @@ import { describe, expect, it } from "vitest";
import { buildBaseOptions } from "../src/api/simple-options.ts";
import { generateImages } from "../src/images.ts";
import { registerImagesApiProvider } from "../src/images-api-registry.ts";
import { createImagesModels, createImagesProvider } from "../src/images-models.ts";
import { createModels, createProvider } from "../src/models.ts";
import type { DeferredHandle, ImagesContext, ImagesModel, Model, ProviderRequestOptions } from "../src/types.ts";
import type { DeferredHandle, ImageModel, ImagesContext, Model, ProviderRequestOptions } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
@ -26,7 +25,8 @@ const model: Model<"telemetry-test"> = {
maxTokens: 100,
};
const imageModel: ImagesModel<"telemetry-test-images"> = {
const imageModel: ImageModel<"telemetry-test-images"> = {
type: "image",
id: "image-model",
name: "Image Model",
api: "telemetry-test-images",
@ -119,7 +119,7 @@ describe("ProviderRequestOptions.telemetryContext", () => {
expect(observed.every((value) => value === telemetryContext)).toBe(true);
});
it("survives direct and ImagesModels image dispatch", async () => {
it("survives direct and Models image dispatch", async () => {
const observed: Array<TelemetryContext | undefined> = [];
registerImagesApiProvider({
api: imageModel.api,
@ -137,13 +137,14 @@ describe("ProviderRequestOptions.telemetryContext", () => {
});
await generateImages(imageModel, imagesContext, { telemetryContext });
const models = createImagesModels();
const models = createModels();
models.setProvider(
createImagesProvider({
createProvider({
id: imageModel.provider,
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [imageModel],
api: {
images: {
[imageModel.api]: {
generateImages: async (requestModel, _context, options) => {
observed.push(options?.telemetryContext);
return {
@ -156,6 +157,7 @@ describe("ProviderRequestOptions.telemetryContext", () => {
};
},
},
},
}),
);
await models.generateImages(imageModel, imagesContext, { telemetryContext });

View file

@ -0,0 +1,171 @@
import { describe, expect, it, vi } from "vitest";
import { classify } from "../src/api/typesafe-system-one.ts";
import type { ClassifierContext, ClassifierModel } from "../src/types.ts";
const model: ClassifierModel<"typesafe-system-one"> = {
type: "classifier",
id: "jev-latest",
name: "Jev",
api: "typesafe-system-one",
provider: "typesafe",
baseUrl: "https://api.typesafe.ai/v1/",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 64000,
};
const context: ClassifierContext = {
state: { text: "The deployment succeeded, thank you." },
questions: {
category: {
type: "choice",
instructions: "Classify the message",
criteria: { success: "Successful", failure: "Failed" },
},
satisfaction: {
type: "score",
instructions: "Score satisfaction",
criteria: ["low", "neutral", "high"],
},
approved: {
type: "bool",
instructions: "Does the user approve?",
criteria: { true: "Approval", false: "No approval" },
},
},
};
const wireAnswers = {
category: {
type: "choice",
choice: "success",
probabilities: { success: 0.9, failure: 0.1 },
confidence: 0.8,
},
satisfaction: { type: "score", score: 2, confidence: 0.7 },
approved: { type: "noul", noul: 0.95 },
};
describe("TypeSafe System One", () => {
it("maps public bool questions and answers to TypeSafe noul values", async () => {
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
const payload = JSON.parse(String(init?.body)) as {
model: string;
questions: Record<string, { type: string }>;
};
expect(payload.model).toBe("jev-latest");
expect(payload.questions.category?.type).toBe("choice");
expect(payload.questions.satisfaction?.type).toBe("score");
expect(payload.questions.approved?.type).toBe("noul");
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer secret");
return Response.json({ answers: wireAnswers });
});
const result = await classify(model, context, { apiKey: "secret", fetch });
expect(fetch).toHaveBeenCalledOnce();
expect(String(fetch.mock.calls[0]?.[0])).toBe("https://api.typesafe.ai/v1/systemone");
expect(result.stopReason).toBe("stop");
expect(result.answers.approved).toEqual({ type: "bool", probability: 0.95 });
expect(result.answers.category).toMatchObject({ type: "choice", choice: "success" });
expect(result.answers.satisfaction).toEqual({ type: "score", score: 2, confidence: 0.7 });
});
it("merges headers case-insensitively and supports null suppression", async () => {
const requests: Array<Record<string, string>> = [];
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
requests.push(Object.fromEntries(new Headers(init?.headers)));
return Response.json({ answers: wireAnswers });
});
const modelWithHeaders = {
...model,
headers: { authorization: "Bearer model", "X-Source": "model" },
};
await classify(modelWithHeaders, context, {
apiKey: "secret",
fetch,
headers: { Authorization: "Bearer request", "x-source": "request" },
});
await classify(modelWithHeaders, context, {
apiKey: "secret",
fetch,
headers: { Authorization: null },
});
expect(requests[0]).toMatchObject({ authorization: "Bearer request", "x-source": "request" });
expect(Object.keys(requests[0]).filter((name) => name.toLowerCase() === "authorization")).toHaveLength(1);
expect(requests[1]).not.toHaveProperty("authorization");
});
it("preserves prototype-sensitive question IDs in answers", async () => {
const prototypeContext: ClassifierContext = {
state: {},
questions: JSON.parse(
'{"__proto__":{"type":"bool","instructions":"Is this true?","criteria":{"true":"Yes","false":"No"}}}',
),
};
const result = await classify(model, prototypeContext, {
apiKey: "secret",
fetch: async () => Response.json({ answers: JSON.parse('{"__proto__":{"type":"noul","noul":0.75}}') }),
});
const serializedAnswers = JSON.parse(JSON.stringify(result.answers)) as Record<string, unknown>;
expect(result.stopReason).toBe("stop");
expect(Object.keys(result.answers)).toEqual(["__proto__"]);
expect(Object.hasOwn(result.answers, "__proto__")).toBe(true);
expect(result.answers.__proto__).toEqual({ type: "bool", probability: 0.75 });
expect(serializedAnswers.__proto__).toEqual({ type: "bool", probability: 0.75 });
});
it("reports request timeouts separately from caller cancellation", async () => {
const result = await classify(model, context, {
apiKey: "secret",
timeoutMs: 5,
maxRetries: 0,
fetch: async (_input, init) => {
const signal = init?.signal;
if (!signal) throw new Error("missing request signal");
return new Promise<Response>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
},
});
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Request timed out after 5ms");
});
it("creates a fresh timeout for every retry attempt", async () => {
const signals: AbortSignal[] = [];
let attempt = 0;
const result = await classify(model, context, {
apiKey: "secret",
timeoutMs: 1000,
maxRetries: 1,
fetch: async (_input, init) => {
if (!(init?.signal instanceof AbortSignal)) throw new Error("missing request signal");
signals.push(init.signal);
attempt++;
return attempt === 1
? new Response("retry", { status: 500, headers: { "retry-after-ms": "0" } })
: Response.json({ answers: wireAnswers });
},
});
expect(result.stopReason).toBe("stop");
expect(signals).toHaveLength(2);
expect(signals[0]).not.toBe(signals[1]);
});
it("returns malformed responses as classifier errors", async () => {
const result = await classify(model, context, {
apiKey: "secret",
fetch: async () => Response.json({ answers: {} }),
});
expect(result.stopReason).toBe("error");
expect(result.answers).toEqual({});
expect(result.errorMessage).toContain("did not return an answer for category");
});
});

View file

@ -4,6 +4,9 @@
### Added
- Added image generation to `ModelRuntime`: `generateImages()` with runtime-resolved auth (stored credentials, OAuth, runtime API keys, `models.json` headers), plus `getModelsOfType()`, `getModelOfType()`, `getAvailableOfType()`, `getAllModels()`, and `getAllAvailable()`. OpenRouter image models are listed under the `openrouter` provider and share its credential; an upstream ID can have separate chat and image entries. `models.json` providers and extension registrations without a model list keep built-in image generation. Extension model lists can include discriminated chat, image, and classifier entries with operation implementations; when supplied, they replace the provider catalog across every operation. Chat-facing reads (`getModels()`, `getAvailableSnapshot()`, the model picker) are unchanged.
- Added classifier support to `ModelRuntime`, including `classify()`, classifier model accessors, runtime-resolved authentication, and the built-in TypeSafe `jev-latest` model.
- Added `types=chat,image,classifier` to pi.dev model catalog requests so remote refreshes overlay every supported model type; entries of unknown model types are ignored.
- Added the `provider_stream_event` extension event for observing parsed provider events before normalization, with an opt-in `/debug-provider` example viewer ([#9784](https://github.com/earendil-works/pi/issues/9784)).
### Fixed

View file

@ -27,7 +27,45 @@ There are two registration forms:
Prefer a complete provider for new integrations that own more than static endpoint and model metadata. Pi composes `models.json` overrides above a registered native provider.
Registering only `baseUrl` or `headers` for an existing provider preserves its built-in models. Supplying `models` in the legacy form replaces the models supplied by that registration.
Registering only `baseUrl` or `headers` for an existing provider preserves its built-in models. Supplying `models` in the legacy form replaces that provider's models across chat, image, and classifier operations. An omitted `type` means `"chat"`; image and classifier models require explicit discriminants and implementations keyed by their `api` values through the `images` and `classifiers` fields.
For example, a mixed-operation provider can register non-chat models and their implementations together:
```typescript
pi.registerProvider("media-tools", {
apiKey: "$MEDIA_TOOLS_API_KEY",
models: [
{
type: "image",
id: "image-v1",
name: "Image V1",
api: "media-images",
baseUrl: "https://media.example.com/v1",
input: ["text"],
output: ["image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
{
type: "classifier",
id: "classifier-v1",
name: "Classifier V1",
api: "media-classifier",
baseUrl: "https://media.example.com/v1",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 64000,
},
],
images: {
"media-images": { generateImages: async (model, context, options) => result },
},
classifiers: {
"media-classifier": { classify: async (model, context, options) => result },
},
});
```
Model-level `baseUrl` values take precedence over the provider endpoint. If no `models` list is supplied, built-in models of every operation remain registered. Equal model IDs in different operations remain distinct, including their model-specific headers.
Calls made after initial extension loading take effect immediately. Use `pi.unregisterProvider()` to remove the dynamic provider and restore built-in behavior that it replaced.
@ -52,7 +90,7 @@ Never write access tokens, refresh tokens, authorization headers, or complete pr
## Supply and refresh models
Every model needs an ID, display name, input capabilities, context window, output limit, reasoning support, and cost metadata. Choose the API implementation at the provider level unless one model requires an override.
Every model needs an ID, display name, input capabilities, and cost metadata. Chat and classifier models also need a context window; chat models need an output limit and reasoning support; image models declare their output modalities. Choose the API implementation at the provider level unless one model requires an override.
Set `promptCache.short` or `promptCache.long` to the provider's best-effort cache lifetime in seconds when Pi should keep an idle prompt cache warm. Leave them unset to disable cache warming for that retention tier.
@ -65,7 +103,7 @@ Use `refreshModels` when the available catalog comes from a live service. Pass `
The two registration forms have different refresh contracts:
- A complete `Provider` returns nothing. It calls `context.publish({ update })` to install provider-owned model state, after which its synchronous `getModels()` exposes the latest list.
- Legacy `ProviderConfig.refreshModels` returns model definitions. Pi replaces that registration’s live models with the returned list and applies any requested persistence.
- Legacy `ProviderConfig.refreshModels` returns mixed-operation model definitions. Pi replaces that registration’s live models with the returned list and applies any requested persistence.
Publish persisted catalog data only when it should survive across runs. A live service such as llama.cpp can update its in-memory list without persisting it; a remote catalog can retain a snapshot for offline startup.

View file

@ -16,18 +16,23 @@ import type {
ToolExecutionMode,
} from "@earendil-works/pi-agent-core";
import type {
AnyModel,
Api,
AssistantMessageEvent,
AssistantMessageEventStream,
ClassifierApi,
ConstrainedSamplingConfig,
ImageApi,
ImageContent,
Message,
Model,
OAuthCredentials,
OAuthLoginCallbacks,
Provider,
ProviderClassifier,
ProviderHeaders,
ProviderId,
ProviderImages,
RefreshModelsContext,
SimpleStreamOptions,
TextContent,
@ -1664,6 +1669,10 @@ export interface ProviderConfig {
context: TranscriptContext,
options?: SimpleStreamOptions,
) => AssistantMessageEventStream;
/** Image-generation implementations keyed by image API. */
images?: Partial<Record<ImageApi, ProviderImages>>;
/** Classifier implementations keyed by classifier API. */
classifiers?: Partial<Record<ClassifierApi, ProviderClassifier>>;
/** Custom headers to include in requests. */
headers?: Record<string, string>;
/** If true, adds Authorization: Bearer header with the resolved API key. */
@ -1694,38 +1703,61 @@ export interface ProviderConfig {
};
}
/** Configuration for a model within a provider. */
export interface ProviderModelConfig {
/** Model ID (e.g., "claude-sonnet-4-20250514"). */
interface ProviderModelConfigBase {
/** Model ID. */
id: string;
/** Display name (e.g., "Claude 4 Sonnet"). */
/** Display name. */
name: string;
/** API type override for this model. */
api?: Api;
api?: string;
/** API endpoint URL override for this model. */
baseUrl?: string;
/** Supported input types. */
input: ("text" | "image")[];
/** Provider input limits and cache-safe image preprocessing metadata. */
inputLimits?: AnyModel["inputLimits"];
/** Per-million-token cost rates and optional request-wide input pricing tiers. */
cost: AnyModel["cost"];
/** Custom headers for this model. */
headers?: Record<string, string>;
}
/** Chat model configuration. Omitted `type` is normalized to `"chat"`. */
export interface ProviderChatModelConfig extends ProviderModelConfigBase {
type?: "chat";
api?: Api;
/** Whether the model supports extended thinking. */
reasoning: boolean;
/** Maps pi thinking levels to provider/model-specific values; null marks a level unsupported. */
thinkingLevelMap?: Model<Api>["thinkingLevelMap"];
/** Supported input types. */
input: ("text" | "image")[];
/** Provider input limits and cache-safe image preprocessing metadata. */
inputLimits?: Model<Api>["inputLimits"];
/** Per-million-token cost rates and optional request-wide input pricing tiers. */
cost: Model<Api>["cost"];
/** Best-effort prompt cache lifetime in seconds per retention tier. Unset disables cache warming. */
promptCache?: Model<Api>["promptCache"];
/** Maximum context window size in tokens. */
contextWindow: number;
/** Maximum output tokens. */
maxTokens: number;
/** Custom headers for this model. */
headers?: Record<string, string>;
samplingParams?: Record<string, unknown>;
/** OpenAI compatibility settings. */
compat?: Model<Api>["compat"];
}
/** Image-generation model configuration. */
export interface ProviderImageModelConfig extends ProviderModelConfigBase {
type: "image";
api?: ImageApi;
output: ("text" | "image")[];
}
/** Structured classifier model configuration. */
export interface ProviderClassifierModelConfig extends ProviderModelConfigBase {
type: "classifier";
api?: ClassifierApi;
contextWindow: number;
}
/** Configuration for a model within a provider. */
export type ProviderModelConfig = ProviderChatModelConfig | ProviderImageModelConfig | ProviderClassifierModelConfig;
/** Extension factory function type. Supports both sync and async initialization. */
export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;

View file

@ -16,8 +16,8 @@ import { isValidThinkingLevel } from "../cli/args.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ModelRuntime } from "./model-runtime.ts";
/** Default model IDs for each known provider */
export const defaultModelPerProvider: Record<KnownProvider, string> = {
/** Default chat model IDs for providers with built-in chat models. */
export const defaultModelPerProvider: Partial<Record<KnownProvider, string>> = {
"amazon-bedrock": "us.anthropic.claude-opus-4-6-v1",
"ant-ling": "Ring-2.6-1T",
anthropic: "claude-opus-4-8",

View file

@ -1,7 +1,9 @@
import { dirname, join } from "node:path";
import {
type AnyModel,
type Api,
type ApiStreamOptions,
type AssistantImages,
type AssistantMessage,
type AssistantMessageEventStream,
type AuthCheck,
@ -9,6 +11,11 @@ import {
type AuthOperationOptions,
type AuthResult,
type AuthType,
type ClassifierApi,
type ClassifierContext,
type ClassifierModel,
type ClassifierOptions,
type ClassifierResult,
type Context,
type Credential,
type CredentialInfo,
@ -17,18 +24,26 @@ import {
type DeferredCancelOptions,
type DeferredFetchOptions,
type DeferredHandle,
type ImageApi,
type ImageModel,
type ImagesContext,
type ImagesOptions,
lazyStream,
type Model,
type Models,
type ModelsApiStreamOptions,
type ModelsClassifierOptions,
type ModelsDeferredCancelOptions,
type ModelsDeferredFetchOptions,
ModelsError,
type ModelsImagesOptions,
type ModelsRefreshOptions,
type ModelsRefreshResult,
type ModelsRequestTransforms,
type ModelsSimpleStreamOptions,
type ModelsStore,
type ModelType,
type ModelTypeMap,
type MutableModels,
normalizeContext,
type Provider,
@ -38,6 +53,13 @@ import {
type StreamOptions,
} from "@earendil-works/pi-ai";
import * as builtinProviderCatalog from "@earendil-works/pi-ai/providers/all";
import {
assertChatModel,
assertClassifierModel,
assertImageModel,
classifierErrorResult,
imageErrorResult,
} from "@earendil-works/pi-ai/utils/model-operations";
import { getAgentDir } from "../config.ts";
import { operationSignal, raceWithAbortSignal } from "../utils/abort.ts";
import { AuthStorage as DefaultAuthStorage } from "./auth-storage.ts";
@ -398,6 +420,34 @@ export class ModelRuntime implements Models {
return this.models.getModel(providerId, modelId);
}
getModelsOfType<TType extends ModelType>(type: TType, providerId?: string): readonly ModelTypeMap[TType][] {
return this.models.getModelsOfType(type, providerId);
}
getModelOfType<TType extends ModelType>(
type: TType,
providerId: string,
modelId: string,
): ModelTypeMap[TType] | undefined {
return this.models.getModelOfType(type, providerId, modelId);
}
getAllModels(providerId?: string): readonly AnyModel[] {
return this.models.getAllModels(providerId);
}
getAvailableOfType<TType extends ModelType>(
type: TType,
providerId?: string,
options?: AuthOperationOptions,
): Promise<readonly ModelTypeMap[TType][]> {
return this.models.getAvailableOfType(type, providerId, options);
}
getAllAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly AnyModel[]> {
return this.models.getAllAvailable(providerId, options);
}
async checkAuth(providerId: string, options?: AuthOperationOptions): Promise<AuthCheck | undefined> {
return this.models.checkAuth(providerId, options);
}
@ -469,9 +519,9 @@ export class ModelRuntime implements Models {
}
getAuth(providerId: string, overrides?: ModelRuntimeAuthOverrides): Promise<AuthResult | undefined>;
getAuth(model: Model<Api>, overrides?: ModelRuntimeAuthOverrides): Promise<AuthResult | undefined>;
getAuth(model: AnyModel, overrides?: ModelRuntimeAuthOverrides): Promise<AuthResult | undefined>;
async getAuth(
providerOrModel: string | Model<Api>,
providerOrModel: string | AnyModel,
overrides: ModelRuntimeAuthOverrides = {},
): Promise<AuthResult | undefined> {
if (typeof providerOrModel === "string") return this.models.getAuth(providerOrModel, overrides);
@ -571,13 +621,16 @@ export class ModelRuntime implements Models {
return check ? { configured: true, source: "environment", label: check.source } : { configured: false };
}
private async prepareRequest<TOptions extends ProviderRequestOptions & ModelsRequestTransforms>(
model: Model<Api>,
private async prepareRequest<
TModel extends AnyModel,
TOptions extends ProviderRequestOptions<TModel> & ModelsRequestTransforms,
>(
model: TModel,
options: TOptions | undefined,
): Promise<{
provider: Provider;
model: Model<Api>;
options: Omit<TOptions, "transformHeaders"> & ProviderRequestOptions;
model: TModel;
options: Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;
}> {
const provider = this.models.getProvider(model.provider);
if (!provider) throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
@ -589,22 +642,23 @@ export class ModelRuntime implements Models {
if (!resolution) throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);
const { transformHeaders, ...rawProviderOptions } = options ?? {};
const providerOptions = rawProviderOptions as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions;
const providerOptions = rawProviderOptions as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;
let headers = mergeHeaders(resolution.auth.headers, providerOptions.headers);
if (transformHeaders) headers = await transformHeaders(headers ?? {});
const env =
resolution.env || providerOptions.env
? { ...(resolution.env ?? {}), ...(providerOptions.env ?? {}) }
: undefined;
const requestModel: TModel = resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model;
return {
provider,
model: resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model,
model: requestModel,
options: {
...providerOptions,
apiKey: providerOptions.apiKey ?? resolution.auth.apiKey,
headers,
env,
} as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions,
} as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>,
};
}
@ -615,15 +669,12 @@ export class ModelRuntime implements Models {
): AssistantMessageEventStream {
const transcript = normalizeContext(context);
return lazyStream(model, async () => {
assertChatModel(model);
const prepared = await this.prepareRequest(
model,
options as (StreamOptions & ModelsRequestTransforms) | undefined,
);
return prepared.provider.stream(
prepared.model as Model<TApi>,
transcript,
prepared.options as ApiStreamOptions<TApi>,
);
return prepared.provider.stream(prepared.model, transcript, prepared.options as ApiStreamOptions<TApi>);
});
}
@ -638,6 +689,7 @@ export class ModelRuntime implements Models {
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream {
const transcript = normalizeContext(context);
return lazyStream(model, async () => {
assertChatModel(model);
const prepared = await this.prepareRequest(model, options);
return prepared.provider.streamSimple(prepared.model, transcript, prepared.options as SimpleStreamOptions);
});
@ -653,6 +705,7 @@ export class ModelRuntime implements Models {
options?: ModelsDeferredFetchOptions,
): AssistantMessageEventStream {
return lazyStream(model, async () => {
assertChatModel(model);
const prepared = await this.prepareRequest(model, options);
if (!prepared.provider.fetchDeferred) {
throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);
@ -674,6 +727,7 @@ export class ModelRuntime implements Models {
handle: DeferredHandle,
options?: ModelsDeferredCancelOptions,
): Promise<void> {
assertChatModel(model);
const prepared = await this.prepareRequest(model, options);
if (!prepared.provider.cancelDeferred) {
throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);
@ -681,6 +735,40 @@ export class ModelRuntime implements Models {
await prepared.provider.cancelDeferred(prepared.model, handle, prepared.options as DeferredCancelOptions);
}
async generateImages(
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: ModelsImagesOptions,
): Promise<AssistantImages> {
try {
assertImageModel(model);
const prepared = await this.prepareRequest(model, options);
if (!prepared.provider.generateImages) {
throw new ModelsError("provider", `Provider ${model.provider} does not support image generation`);
}
return await prepared.provider.generateImages(prepared.model, context, prepared.options as ImagesOptions);
} catch (error) {
return imageErrorResult(model, error, options?.signal?.aborted);
}
}
async classify(
model: ClassifierModel<ClassifierApi>,
context: ClassifierContext,
options?: ModelsClassifierOptions,
): Promise<ClassifierResult> {
try {
assertClassifierModel(model);
const prepared = await this.prepareRequest(model, options);
if (!prepared.provider.classify) {
throw new ModelsError("provider", `Provider ${model.provider} does not support classification`);
}
return await prepared.provider.classify(prepared.model, context, prepared.options as ClassifierOptions);
} catch (error) {
return classifierErrorResult(model, error, options?.signal?.aborted);
}
}
login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
const signal = operationSignal(interaction.signal);
return this.enqueueCredentialOperation(providerId, signal, async () => {

View file

@ -1,11 +1,15 @@
import {
type AnyModel,
type Api,
type ApiKeyAuth,
type AssistantMessageEventStream,
type AuthContext,
type AuthInteraction,
type AuthResult,
type ClassifierApi,
type Credential,
type ImageApi,
isModelType,
lazyStream,
type Model,
type ModelAuth,
@ -13,13 +17,16 @@ import {
type OAuthCredentials,
type OAuthLoginCallbacks,
type Provider,
type ProviderClassifier,
type ProviderHeaders,
type ProviderImages,
type RefreshModelsContext,
type SimpleStreamOptions,
type StreamOptions,
type TranscriptContext,
} from "@earendil-works/pi-ai";
import { getApiProvider } from "@earendil-works/pi-ai/compat";
import { classifierErrorResult, imageErrorResult } from "@earendil-works/pi-ai/utils/model-operations";
import type { ModelConfig, ModelsJsonModel, ModelsJsonModelOverride, ModelsJsonProvider } from "./model-config.ts";
import {
clearConfigValueCache,
@ -42,6 +49,43 @@ export interface ExtensionOAuthConfig {
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
}
interface ProviderModelConfigBase {
id: string;
name: string;
api?: string;
baseUrl?: string;
input: ("text" | "image")[];
inputLimits?: AnyModel["inputLimits"];
cost: AnyModel["cost"];
headers?: Record<string, string>;
}
export interface ProviderChatModelConfig extends ProviderModelConfigBase {
type?: "chat";
api?: Api;
reasoning: boolean;
thinkingLevelMap?: Model<Api>["thinkingLevelMap"];
promptCache?: Model<Api>["promptCache"];
contextWindow: number;
maxTokens: number;
samplingParams?: Record<string, unknown>;
compat?: Model<Api>["compat"];
}
export interface ProviderImageModelConfig extends ProviderModelConfigBase {
type: "image";
api?: ImageApi;
output: ("text" | "image")[];
}
export interface ProviderClassifierModelConfig extends ProviderModelConfigBase {
type: "classifier";
api?: ClassifierApi;
contextWindow: number;
}
export type ProviderModelConfig = ProviderChatModelConfig | ProviderImageModelConfig | ProviderClassifierModelConfig;
/** Input type for the extension registerProvider API. */
export interface ProviderConfigInput {
name?: string;
@ -53,27 +97,13 @@ export interface ProviderConfigInput {
context: TranscriptContext,
options?: SimpleStreamOptions,
) => AssistantMessageEventStream;
images?: Partial<Record<ImageApi, ProviderImages>>;
classifiers?: Partial<Record<ClassifierApi, ProviderClassifier>>;
headers?: Record<string, string>;
authHeader?: boolean;
oauth?: ExtensionOAuthConfig;
models?: Array<{
id: string;
name: string;
api?: Api;
baseUrl?: string;
reasoning: boolean;
thinkingLevelMap?: Model<Api>["thinkingLevelMap"];
input: ("text" | "image")[];
inputLimits?: Model<Api>["inputLimits"];
cost: Model<Api>["cost"];
promptCache?: Model<Api>["promptCache"];
contextWindow: number;
maxTokens: number;
samplingParams?: Record<string, unknown>;
headers?: Record<string, string>;
compat?: Model<Api>["compat"];
}>;
refreshModels?(context: RefreshModelsContext): Promise<NonNullable<ProviderConfigInput["models"]>>;
models?: ProviderModelConfig[];
refreshModels?(context: RefreshModelsContext): Promise<ProviderModelConfig[]>;
}
export type AuthStatus = {
@ -84,6 +114,10 @@ export type AuthStatus = {
export const clearApiKeyCache = clearConfigValueCache;
function getAllProviderModels(provider: Provider | undefined): readonly AnyModel[] {
return provider ? (provider.getAllModels?.() ?? provider.getModels()) : [];
}
function mergeCompat(
base: Model<Api>["compat"],
override: Model<Api>["compat"] | ModelsJsonModelOverride["compat"],
@ -195,20 +229,60 @@ function modelFromJson(
};
}
function findModelDefaults(models: readonly Model<Api>[], modelId: string, api?: Api): Model<Api> | undefined {
function findModelDefaults(models: readonly AnyModel[], modelId: string, api?: Api): Model<Api> | undefined {
const chatModels = models.filter((model) => isModelType(model, "chat"));
return (
models.find((model) => model.id === modelId) ??
(api ? models.find((model) => model.api === api) : undefined) ??
models.find((model) => model.api === "openai-completions") ??
models[0]
chatModels.find((model) => model.id === modelId) ??
(api ? chatModels.find((model) => model.api === api) : undefined) ??
chatModels.find((model) => model.api === "openai-completions") ??
chatModels[0]
);
}
function findExtensionModelDefaults(
models: readonly AnyModel[],
definition: ProviderModelConfig,
): AnyModel | undefined {
const type = definition.type ?? "chat";
const candidates = models.filter((model) => isModelType(model, type));
return (
candidates.find((model) => model.id === definition.id) ??
(definition.api ? candidates.find((model) => model.api === definition.api) : undefined) ??
(type === "chat" ? candidates.find((model) => model.api === "openai-completions") : undefined) ??
candidates[0]
);
}
function extensionModelFromDefinition(
providerId: string,
models: readonly AnyModel[],
config: ProviderConfigInput,
definition: ProviderModelConfig,
): AnyModel {
const type = definition.type ?? "chat";
const defaults = findExtensionModelDefaults(models, definition);
const api = definition.api ?? (type === "chat" ? config.api : undefined) ?? defaults?.api;
if (!api) {
throw new Error(
`Provider ${providerId}, model ${definition.id}: no "api" specified. Set it at model level${type === "chat" ? " or provider level" : ""}.`,
);
}
const baseUrl = definition.baseUrl ?? config.baseUrl ?? defaults?.baseUrl;
if (!baseUrl) throw new Error(`Provider ${providerId}: "baseUrl" is required when defining custom models.`);
if (definition.type === "image") {
return { ...definition, api: api as ImageApi, provider: providerId, baseUrl, headers: undefined };
}
if (definition.type === "classifier") {
return { ...definition, api: api as ClassifierApi, provider: providerId, baseUrl, headers: undefined };
}
return { ...definition, api: api as Api, provider: providerId, baseUrl, headers: undefined };
}
function applyModelsJson(
providerId: string,
baseModels: readonly Model<Api>[],
baseModels: readonly AnyModel[],
config: ModelsJsonProvider | undefined,
): Model<Api>[] {
): AnyModel[] {
if (!config) return [...baseModels];
if (config.oauth && !config.baseUrl) {
throw new Error(`Provider ${providerId}: "baseUrl" is required when "oauth" is set.`);
@ -229,13 +303,14 @@ function applyModelsJson(
);
}
const models: Model<Api>[] = baseModels.map((model) => ({
...model,
baseUrl: config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl),
compat: mergeCompat(model.compat, config.compat),
}));
const models: AnyModel[] = baseModels.map((model) => {
const baseUrl = config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl);
return isModelType(model, "chat")
? { ...model, baseUrl, compat: mergeCompat(model.compat, config.compat) }
: { ...model, baseUrl };
});
for (const definition of config.models ?? []) {
const existingIndex = models.findIndex((model) => model.id === definition.id);
const existingIndex = models.findIndex((model) => isModelType(model, "chat") && model.id === definition.id);
const defaults = findModelDefaults(models, definition.id, definition.api ?? config.api);
const model = modelFromJson(providerId, definition, config, defaults);
if (existingIndex >= 0) models[existingIndex] = model;
@ -246,31 +321,14 @@ function applyModelsJson(
function applyExtension(
providerId: string,
models: readonly Model<Api>[],
models: readonly AnyModel[],
config: ProviderConfigInput | undefined,
): Model<Api>[] {
): AnyModel[] {
if (!config) return [...models];
if (!config.models) {
return config.baseUrl ? models.map((model) => ({ ...model, baseUrl: config.baseUrl! })) : [...models];
}
return config.models.map((definition) => {
const defaults = findModelDefaults(models, definition.id, definition.api ?? config.api);
const api = definition.api ?? config.api ?? defaults?.api;
if (!api) {
throw new Error(
`Provider ${providerId}, model ${definition.id}: no "api" specified. Set at provider or model level.`,
);
}
const baseUrl = definition.baseUrl ?? config.baseUrl ?? defaults?.baseUrl;
if (!baseUrl) throw new Error(`Provider ${providerId}: "baseUrl" is required when defining custom models.`);
return {
...definition,
api,
provider: providerId,
baseUrl,
headers: undefined,
};
});
return config.models.map((definition) => extensionModelFromDefinition(providerId, models, config, definition));
}
function adaptOAuth(config: ExtensionOAuthConfig): OAuthAuth {
@ -429,15 +487,21 @@ function composeOAuthAuth(
}
function rawModelHeaders(
model: Model<Api>,
model: AnyModel,
config: ModelsJsonProvider | undefined,
extension: ProviderConfigInput | undefined,
): Record<string, string> | undefined {
const definition = config?.models?.find((entry) => entry.id === model.id);
const extensionModel = extension?.models?.find((entry) => entry.id === model.id);
// models.json definitions and overrides are chat-only. Extension definitions
// are matched by operation and id so colliding models cannot share headers.
const chatDefinition = isModelType(model, "chat")
? config?.models?.find((entry) => entry.id === model.id)
: undefined;
const extensionModel = extension?.models?.find(
(entry) => (entry.type ?? "chat") === (model.type ?? "chat") && entry.id === model.id,
);
const headers = {
...config?.modelOverrides?.[model.id]?.headers,
...definition?.headers,
...(isModelType(model, "chat") ? config?.modelOverrides?.[model.id]?.headers : undefined),
...chatDefinition?.headers,
...extensionModel?.headers,
};
return Object.keys(headers).length > 0 ? headers : undefined;
@ -452,7 +516,7 @@ export function validateExtensionProvider(
if (extension.streamSimple && !extension.api) {
throw new Error(`Provider ${providerId}: "api" is required when registering streamSimple.`);
}
applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], modelsConfig), extension);
applyExtension(providerId, applyModelsJson(providerId, getAllProviderModels(base), modelsConfig), extension);
}
/** Compose built-in, models.json, and extension layers without reading credentials. */
@ -469,22 +533,29 @@ export function composeModelProvider(
extension && refreshedExtensionModels ? { ...extension, models: refreshedExtensionModels } : extension;
// models.json modelOverrides are the topmost user-config layer: they apply once,
// after custom-model upserts, extension model replacement, and legacy OAuth projection.
const getModels = () => {
const getAllModels = (): AnyModel[] => {
let models = applyExtension(
providerId,
applyModelsJson(providerId, base?.getModels() ?? [], config),
applyModelsJson(providerId, getAllProviderModels(base), config),
currentExtension(),
);
if (extensionOAuthCredential && extension?.oauth?.modifyModels) {
models = extension.oauth.modifyModels(models, extensionOAuthCredential);
// The extension hook is chat-only; other model types pass through untouched.
models = [
...extension.oauth.modifyModels(
models.filter((model) => isModelType(model, "chat")),
extensionOAuthCredential,
),
...models.filter((model) => !isModelType(model, "chat")),
];
}
return models.map((model) => {
const override = config?.modelOverrides?.[model.id];
return override ? applyModelOverride(model, override) : model;
return override && isModelType(model, "chat") ? applyModelOverride(model, override) : model;
});
};
// Validate eagerly so registration/reload reports structural errors immediately.
getModels();
getAllModels();
const apiKey = composeApiKeyAuth(providerId, base, config, extension);
const oauth = composeOAuthAuth(providerId, base, config, extension);
if (!apiKey && !oauth) throw new Error(`Provider ${providerId}: no authentication method configured.`);
@ -518,7 +589,8 @@ export function composeModelProvider(
baseUrl: extension?.baseUrl ?? config?.baseUrl ?? base?.baseUrl,
headers: base?.headers,
auth: { ...(apiKey ? { apiKey } : {}), ...(oauth ? { oauth } : {}) },
getModels,
getModels: () => getAllModels().filter((model) => isModelType(model, "chat")),
getAllModels,
refreshModels:
base?.refreshModels || extension?.refreshModels || extension?.oauth?.modifyModels
? async (context) => {
@ -531,7 +603,7 @@ export function composeModelProvider(
update: () => {
if (refreshed) {
// Validate before publishing the new synchronous list.
applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), {
applyExtension(providerId, applyModelsJson(providerId, getAllProviderModels(base), config), {
...extension,
models: refreshed,
});
@ -545,6 +617,9 @@ export function composeModelProvider(
filterModels: base?.filterModels
? (models, credential: Credential | undefined) => base.filterModels!(models, credential)
: undefined,
filterAllModels: base?.filterAllModels
? (models, credential: Credential | undefined) => base.filterAllModels!(models, credential)
: undefined,
stream: (model, context, options) => streamWith(model, context, options, false),
streamSimple: (model, context, options) => streamWith(model, context, options, true),
};
@ -557,12 +632,39 @@ export function composeModelProvider(
if (cancelDeferred) {
provider.cancelDeferred = (model, handle, options) => cancelDeferred(model, handle, options);
}
const extensionImages = extension?.images;
const generateImages = base?.generateImages;
if (generateImages || Object.keys(extensionImages ?? {}).length > 0) {
provider.generateImages = (model, context, options) => {
const implementation = extensionImages?.[model.api];
if (implementation) return implementation.generateImages(model, context, options);
if (generateImages) return generateImages(model, context, options);
return Promise.resolve(
imageErrorResult(model, new Error(`Provider ${providerId} has no image implementation for "${model.api}"`)),
);
};
}
const extensionClassifiers = extension?.classifiers;
const classify = base?.classify;
if (classify || Object.keys(extensionClassifiers ?? {}).length > 0) {
provider.classify = (model, context, options) => {
const implementation = extensionClassifiers?.[model.api];
if (implementation) return implementation.classify(model, context, options);
if (classify) return classify(model, context, options);
return Promise.resolve(
classifierErrorResult(
model,
new Error(`Provider ${providerId} has no classifier implementation for "${model.api}"`),
),
);
};
}
return provider;
}
export function resolveConfiguredModelHeaders(
model: Model<Api>,
model: AnyModel,
config: ModelsJsonProvider | undefined,
extension: ProviderConfigInput | undefined,
env?: Record<string, string>,
@ -580,7 +682,7 @@ export interface CompatibilityRequestConfig {
}
export function resolveCompatibilityRequestConfig(
model: Model<Api>,
model: AnyModel,
config: ModelsJsonProvider | undefined,
extension: ProviderConfigInput | undefined,
): CompatibilityRequestConfig {

View file

@ -1,4 +1,11 @@
import type { Api, Model, ModelsStoreEntry, Provider } from "@earendil-works/pi-ai";
import {
type AnyModel,
getModelType,
isModelType,
type ModelsStoreEntry,
type ModelType,
type Provider,
} from "@earendil-works/pi-ai";
import { VERSION } from "../config.ts";
import { fetchWithRetry } from "../utils/management-http.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
@ -6,18 +13,32 @@ import { getPiUserAgent } from "../utils/pi-user-agent.ts";
const DEFAULT_CATALOG_BASE_URL = "https://pi.dev";
const REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS = 4_000;
export const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;
/**
* Model types this client can consume. Sent as `?types=` so the catalog server
* returns the full-type shard instead of the chat-only one served to clients
* that predate model types. A server that ignores the parameter still returns
* the chat-only shard, which this client handles unchanged.
*/
export const REMOTE_CATALOG_MODEL_TYPES: readonly ModelType[] = ["chat", "image", "classifier"];
function mergeModels(baseline: readonly Model<Api>[], dynamic: readonly Model<Api>[]): Model<Api>[] {
function isSupportedModelType(model: { type?: unknown }): boolean {
return (
model.type === undefined ||
(typeof model.type === "string" && REMOTE_CATALOG_MODEL_TYPES.includes(model.type as ModelType))
);
}
function mergeModels<TModel extends AnyModel>(baseline: readonly TModel[], dynamic: readonly TModel[]): TModel[] {
const merged = [...baseline];
for (const model of dynamic) {
const index = merged.findIndex((entry) => entry.id === model.id);
const index = merged.findIndex((entry) => getModelType(entry) === getModelType(model) && entry.id === model.id);
if (index >= 0) merged[index] = model;
else merged.push(model);
}
return merged;
}
function parseCatalog(providerId: string, value: unknown): Model<Api>[] {
function parseCatalog(providerId: string, value: unknown): AnyModel[] {
const entries = Array.isArray(value)
? value
: typeof value === "object" && value !== null && "models" in value && Array.isArray(value.models)
@ -27,14 +48,12 @@ function parseCatalog(providerId: string, value: unknown): Model<Api>[] {
: undefined;
if (!entries) throw new Error(`Invalid model catalog for provider "${providerId}"`);
return entries
.filter((entry): entry is Model<Api> => typeof entry === "object" && entry !== null && "id" in entry)
.map((model) => ({ ...model, provider: providerId }));
.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null && "id" in entry)
.filter(isSupportedModelType)
.map((model) => ({ ...model, provider: providerId }) as AnyModel);
}
function remoteModels(
entry: ModelsStoreEntry | undefined,
localGeneratedAt: number | undefined,
): readonly Model<Api>[] {
function remoteModels(entry: ModelsStoreEntry | undefined, localGeneratedAt: number | undefined): readonly AnyModel[] {
if (!entry) return [];
if (localGeneratedAt !== undefined && (entry.lastModified === undefined || entry.lastModified <= localGeneratedAt)) {
return [];
@ -48,11 +67,16 @@ export function withRemoteCatalog(
catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL,
localGeneratedAt?: number,
): Provider {
let dynamicModels: readonly Model<Api>[] = [];
let dynamicModels: readonly AnyModel[] = [];
return {
...provider,
getModels: () => mergeModels(provider.getModels(), dynamicModels),
getModels: () =>
mergeModels(
provider.getModels(),
dynamicModels.filter((model) => isModelType(model, "chat")),
),
getAllModels: () => mergeModels(provider.getAllModels?.() ?? provider.getModels(), dynamicModels),
refreshModels: async (context) => {
const stored = context.stored;
const restored = remoteModels(stored, localGeneratedAt).filter((model) => model.provider === provider.id);
@ -77,8 +101,9 @@ export function withRemoteCatalog(
// Only revalidate when a cached body backs the validator, so a 304 can never
// leave the overlay empty.
const validator = stored?.models.length ? stored.etag : undefined;
const validator = stored && stored.models.length > 0 ? stored.etag : undefined;
const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl);
url.searchParams.set("types", REMOTE_CATALOG_MODEL_TYPES.join(","));
const response = await fetchWithRetry(
url,
{
@ -119,7 +144,7 @@ export function withRemoteCatalog(
const refreshed = parseCatalog(provider.id, await response.json());
const lastModified = Date.parse(response.headers.get("last-modified") ?? "");
if (context.signal.aborted) return;
const entry = {
const entry: ModelsStoreEntry = {
models: refreshed,
checkedAt,
lastModified: Number.isNaN(lastModified) ? 0 : lastModified,

View file

@ -720,11 +720,16 @@ describe("default model selection", () => {
expect(defaultModelPerProvider["ant-ling"]).toBe("Ring-2.6-1T");
});
test("built-in defaults exist in generated provider catalogs", () => {
test("built-in chat providers have defaults in their generated catalogs", () => {
for (const provider of getBuiltinProviders()) {
const chatModels = getBuiltinModels(provider);
const defaultId = defaultModelPerProvider[provider];
if (chatModels.length === 0) {
expect(defaultId, `${provider} has no chat models and should have no chat default`).toBeUndefined();
continue;
}
expect(
getBuiltinModels(provider).some((model) => model.id === defaultId),
chatModels.some((model) => model.id === defaultId),
`${provider} default ${defaultId} should exist in its generated catalog`,
).toBe(true);
}

View file

@ -0,0 +1,42 @@
import { InMemoryModelsStore } from "@earendil-works/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRuntime } from "../src/core/model-runtime.ts";
const context = {
state: { text: "Looks good" },
questions: {
approved: {
type: "bool" as const,
instructions: "Does this express approval?",
criteria: { true: "Approval", false: "No approval" },
},
},
};
describe("ModelRuntime classifiers", () => {
it("lists Jev separately and classifies with runtime-resolved auth", async () => {
const runtime = await ModelRuntime.create({
credentials: AuthStorage.inMemory(),
modelsStore: new InMemoryModelsStore(),
modelsPath: null,
allowModelNetwork: false,
});
const jev = runtime.getModelOfType("classifier", "typesafe", "jev-latest")!;
expect(jev.type).toBe("classifier");
expect(runtime.getModel("typesafe", "jev-latest")).toBeUndefined();
const unconfigured = await runtime.classify(jev, context);
expect(unconfigured.stopReason).toBe("error");
expect(unconfigured.errorMessage).toContain("not configured");
await runtime.setRuntimeApiKey("typesafe", "sk-typesafe");
expect(await runtime.getAvailableOfType("classifier", "typesafe")).toEqual([jev]);
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer sk-typesafe");
return Response.json({ answers: { approved: { type: "noul", noul: 0.8 } } });
});
const result = await runtime.classify(jev, context, { fetch });
expect(result.answers.approved).toEqual({ type: "bool", probability: 0.8 });
});
});

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