feat(ai): ImagesModels collections mirroring the chat-side design

createImagesModels()/ImagesProvider/createImagesProvider() give image
generation the same shape as chat: sync model reads, explicit async
refresh(provider?) with in-flight dedupe, provider-resolved auth, and
never-rejecting generateImages() (failures return AssistantImages with
stopReason error). Auth resolution is shared with the chat side via the
free-standing resolveProviderAuth() in auth/resolve.ts, which also owns
ModelsError; both collections pass their store/context as arguments.

The OpenRouter implementation moves to api/openrouter-images.ts with a
lazy wrapper; openrouterImagesProvider() factory plus
builtinImagesProviders()/builtinImagesModels() land in providers/all.
The ImagesProvider id type alias is renamed to ImagesProviderId
(mirror of Provider -> ProviderId). The old global image API
(getImageModel*, generateImages, registerImagesApiProvider) stays on
/compat, its registration shim repointed at the moved implementation.

README: Quick Start uses builtinModels(), the full streaming event
switch, image generation and the development checklist are restored in
full, image generation documents the new collections with compat noted
for the old API, plus the review fixes (builtinModels options,
credential-store mention for browsers, ImagesModels notes).
This commit is contained in:
Mario Zechner
2026-06-11 00:27:43 +02:00
Unverified
parent e1283fc17a
commit 827fe1e2c4
15 changed files with 804 additions and 149 deletions
+10 -6
View File
@@ -18,8 +18,8 @@ Non-goals for the immediate `pi-ai` pass:
- Do not migrate coding-agent `ModelRegistry` yet.
- Do not keep the stream/API registry inside `Models`.
- Do not implement web OAuth flows yet (the factory option is reserved).
- Images (`images.ts`, `images-api-registry.ts`) are out of scope; leave untouched.
- Do not implement web OAuth flows yet.
- Image generation mirrors the chat-side design (`ImagesModels`/`ImagesProvider` in `images-models.ts`); the old global image API (`images.ts`, `images-api-registry.ts`) lives on compat.
## Package layout
@@ -28,9 +28,10 @@ Target source layout:
```txt
packages/ai/src/
index.ts # core exports only; no built-in provider imports
models.ts # Models runtime, Provider, auth types
models.ts # Models runtime, Provider
images-models.ts # ImagesModels runtime, ImagesProvider (mirrors models.ts)
compat.ts # temporary old-API compatibility entrypoint
auth/ # auth method types, helpers, login callbacks
auth/ # auth method types, helpers, shared resolveProviderAuth(), login callbacks
api/ # API implementations and lazy wrappers
openai-completions.ts # real implementation, imports SDKs, exports stream/streamSimple
openai-completions.lazy.ts
@@ -50,6 +51,8 @@ packages/ai/src/
mistral-conversations.lazy.ts
bedrock-converse-stream.ts
bedrock-converse-stream.lazy.ts
openrouter-images.ts # image-generation API implementation
openrouter-images.lazy.ts
lazy.ts # lazyStream()/lazyApi() helpers
(shared helpers: openai-responses-shared, google-shared, transform-messages, ...)
providers/ # concrete provider factories and per-provider catalogs
@@ -62,8 +65,9 @@ packages/ai/src/
google.ts
google.models.ts
...one pair per built-in provider...
openrouter-images.ts # image-generation provider factory
faux.ts # test provider factory
all.ts # explicit aggregate: builtinModels(), getBuiltin*()
all.ts # explicit aggregate: builtinModels(), builtinImagesModels(), getBuiltin*()
utils/oauth/ # OAuth flow implementations (node), lazy-loaded
```
@@ -892,7 +896,7 @@ Ordering:
### Deferred / follow-ups
- [ ] Web OAuth implementations (sitegeist-style) as an alternative `OAuthAuth`.
- [ ] Images API registry redesign (untouched in this pass).
- [x] Images API redesign: `ImagesModels`/`ImagesProvider`/`createImagesProvider` mirror the chat-side design (sync reads, explicit refresh, never-reject generation); auth resolution shared with the chat side via the free-standing `resolveProviderAuth()` in `auth/resolve.ts` (which also owns `ModelsError`; both collections pass their store/context as arguments — no resolver object). `openrouterImagesProvider()` factory + `builtinImagesProviders()`/`builtinImagesModels()` in `providers/all`; impl moved to `api/openrouter-images.ts` with a lazy wrapper. The old global image API (registry + `getImageModel*` + `generateImages`) stays on compat; `ImagesProvider` id alias in types.ts renamed to `ImagesProviderId` (mirror of `Provider` -> `ProviderId`).
## Error behavior
+1
View File
@@ -15,6 +15,7 @@
- One provider factory per built-in provider under `@earendil-works/pi-ai/providers/*` (e.g. `anthropicProvider()`, `openrouterProvider()`), plus `@earendil-works/pi-ai/providers/all` with `builtinProviders()`/`builtinModels()` and typed `getBuiltin*` catalog reads. Generated catalogs are split per provider, so importing one provider pulls one catalog; `sideEffects` metadata makes the package tree-shakeable.
- OAuth flows (Anthropic, OpenAI Codex, GitHub Copilot) gained `OAuthAuth` adapters (`login`/`refresh`/`toAuth`) on unified `prompt()`/`notify()` login callbacks; Copilot's per-credential base URL is derived in `toAuth()`.
- `fauxProvider()` returns a faux `Provider` for tests built on explicit `Models` collections.
- Image generation mirrors the chat-side design: `createImagesModels()`/`ImagesProvider`/`createImagesProvider()` with sync model reads, explicit `refresh()`, provider-resolved auth, and never-rejecting `generateImages()`; `openrouterImagesProvider()` factory plus `builtinImagesProviders()`/`builtinImagesModels()` in `providers/all`. The `ImagesProvider` id type alias is renamed to `ImagesProviderId`; the old global image API stays on `/compat`.
- When Amazon Bedrock rejects an unsupported data retention mode, the error now links the AWS data retention documentation ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)).
+172 -29
View File
@@ -93,14 +93,14 @@ TypeBox exports are re-exported from `@earendil-works/pi-ai`: `Type`, `Static`,
## Quick Start
You build a `Models` collection, register the providers you want, and stream through it. Importing a provider pulls only that provider's catalog; SDKs load lazily on first request.
You build a `Models` collection of providers and stream through it. The quickest start registers every built-in provider; apps that care about bundle size register individual providers instead (see [Provider Factories](#provider-factories)). Either way, provider SDKs load lazily on first request.
```typescript
import { Type, createModels, type Context, type Tool } from '@earendil-works/pi-ai';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
import { Type, type Context, type Tool } from '@earendil-works/pi-ai';
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
const models = createModels();
models.setProvider(openaiProvider());
// A Models collection with every built-in provider registered
const models = builtinModels();
// Sync lookup against the collection
const model = models.getModel('openai', 'gpt-4o-mini')!;
@@ -130,12 +130,34 @@ for await (const event of s) {
case 'start':
console.log(`Starting with ${event.partial.model}`);
break;
case 'text_start':
console.log('\n[Text started]');
break;
case 'text_delta':
process.stdout.write(event.delta);
break;
case 'text_end':
console.log('\n[Text ended]');
break;
case 'thinking_start':
console.log('[Model is thinking...]');
break;
case 'thinking_delta':
process.stdout.write(event.delta);
break;
case 'thinking_end':
console.log('[Thinking complete]');
break;
case 'toolcall_start':
console.log(`\n[Tool call started: index ${event.contentIndex}]`);
break;
case 'toolcall_delta':
// Partial tool arguments are being streamed
const partialCall = event.partial.content[event.contentIndex];
if (partialCall.type === 'toolCall') {
console.log(`[Streaming args for ${partialCall.name}]`);
}
break;
case 'toolcall_end':
console.log(`\nTool called: ${event.toolCall.name}`);
console.log(`Arguments: ${JSON.stringify(event.toolCall.arguments)}`);
@@ -197,7 +219,7 @@ for (const block of response.content) {
}
```
Snippets in the rest of this README assume a `models` collection set up like this (with the relevant provider registered).
Snippets in the rest of this README assume a `models` collection set up like this (with the relevant providers registered).
## Providers and Models
@@ -207,7 +229,7 @@ Providers internally share **API implementations** (the wire protocols): Anthrop
### Provider Factories
One factory per built-in provider, each a subpath import that pulls only that provider's catalog:
For apps that only need specific providers, there is one factory per built-in provider, each a subpath import that pulls only that provider's catalog:
```typescript
import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
@@ -225,7 +247,7 @@ Provider SDKs (`@anthropic-ai/sdk`, `openai`, `@google/genai`, AWS) are **not**
### All Built-in Providers
For apps that want everything:
For apps that want everything (as in Quick Start):
```typescript
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
@@ -233,7 +255,7 @@ import { builtinModels } from '@earendil-works/pi-ai/providers/all';
const models = builtinModels(); // a Models collection with every built-in provider registered
```
This imports all catalogs (it is the heavy, explicit entrypoint) but still no SDKs.
This imports all catalogs (it is the heavy, explicit entrypoint) but still no SDKs. `builtinModels()` accepts the same options as `createModels()` (`credentials`, `authContext`); `builtinProviders()` returns the provider array if you want to register them on your own collection.
### Querying Models
@@ -330,6 +352,8 @@ Stored credentials (API keys entered interactively, OAuth tokens) live in a `Cre
import { createModels, type CredentialStore } from '@earendil-works/pi-ai';
const models = createModels({ credentials: myFileBackedStore });
// builtinModels() takes the same options:
// const models = builtinModels({ credentials: myFileBackedStore });
```
The contract is small: `read(providerId)`, `modify(providerId, fn)` (the only write path — a serialized read-modify-write), and `delete(providerId)`. OAuth token refresh runs inside `modify`, so concurrent requests and processes cannot double-refresh a rotated token. A stored credential *owns* its provider: environment variables are only consulted when nothing is stored, and a failed refresh never silently falls back to an env key.
@@ -590,17 +614,21 @@ for (const block of response.content) {
## Image Generation
Image generation uses a separate API surface from text/chat generation and currently lives on the [compat entrypoint](#migrating-from-the-old-global-api). Use `getImageModel()` / `getImageModels()` / `getImageProviders()` to discover image-generation models, and `generateImages()` to get the final result.
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.
### Basic Image Generation
```typescript
import { getImageModel, generateImages } from '@earendil-works/pi-ai/compat';
import { builtinImagesModels } from '@earendil-works/pi-ai/providers/all';
const model = getImageModel('openrouter', 'google/gemini-2.5-flash-image');
// Every built-in image-generation provider; accepts the same options as createModels()
const imagesModels = builtinImagesModels();
const result = await generateImages(model, {
const model = imagesModels.getModel('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, {
input: [{ type: 'text', text: 'Generate a red circle on a plain white background.' }]
}, {
apiKey: process.env.OPENROUTER_API_KEY
});
for (const block of result.output) {
@@ -613,11 +641,52 @@ for (const block of result.output) {
}
```
Notes:
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 `getAuth(model)` works exactly like the chat-side one.
- Use `getImageModel(...)` and `generateImages()`; image-generation models do not work with the chat/stream APIs and do not participate in tool calling.
- Outputs are returned in `AssistantImages.output` and can include both base64-encoded `ImageContent` blocks and `TextContent` blocks. Check `model.output` and `model.input` for capabilities.
- Options such as `apiKey`, `signal`, `headers`, `onPayload`, and `onResponse` are supported; results may include `stopReason`, `responseId`, and `usage`.
The old global API (`getImageModel()` / `getImageModels()` / `getImageProviders()` / `generateImages()`) remains available on the [compat entrypoint](#migrating-from-the-old-global-api):
```typescript
import { getImageModel, generateImages } from '@earendil-works/pi-ai/compat';
const model = getImageModel('openrouter', 'google/gemini-2.5-flash-image');
const result = await generateImages(model, {
input: [{ type: 'text', text: 'Generate a red circle on a plain white background.' }]
}, {
apiKey: process.env.OPENROUTER_API_KEY
});
```
Some models also support image input:
```typescript
import { readFileSync } from 'fs';
const imageBuffer = readFileSync('input.png');
const result = await imagesModels.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' }
]
});
```
Check capabilities on the model metadata:
```typescript
console.log(model.input); // ['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-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`.
- Some models accept image input, others are text-to-image only. Check `model.input`.
- Like the streaming APIs, image generation supports options such as `apiKey`, `signal`, `headers`, `onPayload`, and `onResponse`, and results may include `stopReason`, `responseId`, and `usage`.
- 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.
## Thinking/Reasoning
@@ -1171,7 +1240,7 @@ Models are plain serializable data too — no functions or implementations attac
## Browser Usage
The library supports browser environments. The core entrypoint and provider factories are side-effect free and bundle cleanly. Pass API keys explicitly since environment variables are not available in browsers:
The library supports browser environments. The core entrypoint and provider factories are side-effect free and bundle cleanly. Environment variables are not available in browsers, so pass API keys explicitly — or inject a `CredentialStore` (e.g. localStorage-backed) and let provider auth resolve from stored credentials:
```typescript
import { createModels } from '@earendil-works/pi-ai';
@@ -1312,16 +1381,90 @@ Compat is a strict superset of the root entrypoint, so a file can switch its imp
### Adding a New Provider
The layered layout: API implementations live in `src/api/`, provider factories in `src/providers/`, generated catalogs in `src/providers/<id>.models.ts`.
Adding a new LLM provider requires changes across multiple files. The layered layout: API implementations live in `src/api/`, provider factories in `src/providers/`, generated catalogs in `src/providers/<id>.models.ts`. This checklist covers all necessary steps:
1. **Core types** (`src/types.ts`): add the API id to `KnownApi` (if it is a new API), the provider id to `KnownProvider`, and the options type to `ApiOptionsMap`.
2. **API implementation** (`src/api/<api-id>.ts`, only for a new API): export exactly `stream` and `streamSimple`, plus the options interface extending `StreamOptions`. Add a lazy wrapper `src/api/<api-id>.lazy.ts` (`<name>Api()` via `lazyApi()`).
3. **Catalog** (`scripts/generate-models.ts`): add fetching/mapping for the provider's models (e.g. from models.dev); regeneration emits `src/providers/<id>.models.ts` and the aggregator.
4. **Provider factory** (`src/providers/<id>.ts`): `createProvider()` wiring catalog + auth (`envApiKeyAuth` for standard key providers, custom `ApiKeyAuth` for ambient auth, `lazyOAuth` where OAuth exists) + the lazy API wrapper. Register it in `src/providers/all.ts`.
5. **Compat**: if it is a new API, register it in the builtin list in `src/compat.ts` and add the legacy subpath in `package.json` if warranted.
6. **Tests** (`test/`): cover streaming/tools/abort/tokens for new APIs (`stream.test.ts` and friends, env-gated), `cross-provider-handoff.test.ts` pairs, and provider listing/auth in `providers.test.ts`.
7. **Docs**: this README (Supported Providers, env var table) and `CHANGELOG.md` under `## [Unreleased]`.
8. **coding-agent**: default model id in `src/core/model-resolver.ts`, env var docs in `src/cli/args.ts`.
#### 1. Core Types (`src/types.ts`)
- Add the API identifier to `KnownApi` (for example `"bedrock-converse-stream"`), if it is a new API
- Add the provider name to `KnownProvider` (for example `"amazon-bedrock"`)
- Add the options type to `ApiOptionsMap`
#### 2. API Implementation (`src/api/<api-id>.ts`, only for a new API)
Create a new API implementation file (for example `bedrock-converse-stream.ts`) that exports exactly `stream` and `streamSimple`, plus:
- An options interface extending `StreamOptions` (for example `BedrockOptions`)
- Message conversion functions to transform `Context` to provider format
- Tool conversion if the provider supports tools
- Response parsing to emit standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`)
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`)
- 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`; regeneration emits `src/providers/<id>.models.ts` and the aggregator
- Map image-generation provider model data to the standardized `ImagesModel` interface via `scripts/generate-image-models.ts`
- Handle provider-specific quirks (pricing format, capability flags, model ID transformations)
#### 4. Provider Factory (`src/providers/<id>.ts`)
- `createProvider()` wiring catalog + auth + the lazy API wrapper
- Auth: `envApiKeyAuth` for standard key providers, a custom `ApiKeyAuth` for ambient auth (AWS profiles, ADC), `lazyOAuth` where an OAuth flow exists
- Register the factory in `src/providers/all.ts`
- If it is a new API: register it in the builtin list in `src/compat.ts` and add the package subpath export in `package.json`
#### 5. Tests (`test/`)
Create or update test files to cover the new provider:
- `stream.test.ts` - Basic streaming and tool use
- `tokens.test.ts` - Token usage reporting
- `abort.test.ts` - Request cancellation
- `empty.test.ts` - Empty message handling
- `context-overflow.test.ts` - Context limit errors
- `image-limits.test.ts` - Image support (if applicable)
- `unicode-surrogate.test.ts` - Unicode handling
- `tool-call-without-result.test.ts` - Orphaned tool calls
- `image-tool-result.test.ts` - Images in tool results
- `total-tokens.test.ts` - Token counting accuracy
- `cross-provider-handoff.test.ts` - Cross-provider context replay
- `providers.test.ts` - Provider listing and auth resolution
For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (for example GPT and Claude), add at least one pair per family.
For providers with non-standard auth (AWS, Google Vertex), create a utility like `bedrock-utils.ts` with credential detection helpers.
#### 6. Coding Agent Integration (`../coding-agent/`)
Update `src/core/model-resolver.ts`:
- Add a default model ID for the provider in `DEFAULT_MODELS`
Update `src/cli/args.ts`:
- Add environment variable documentation in the help text
Update `README.md`:
- Add the provider to the providers section with setup instructions
#### 7. Documentation
Update `packages/ai/README.md`:
- Add to the Supported Providers table
- Document any provider-specific options or authentication requirements
- Add environment variable to the Environment Variables section
#### 8. Changelog
Add an entry to `packages/ai/CHANGELOG.md` under `## [Unreleased]`:
```markdown
### Added
- Added support for [Provider Name] provider ([#PR](link) by [@author](link))
```
## License
@@ -0,0 +1,10 @@
import type { ImagesModel, 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,
),
});
@@ -14,9 +14,9 @@ import type {
ImagesModel,
ImagesOptions,
TextContent,
} from "../../types.ts";
import { headersToRecord } from "../../utils/headers.ts";
import { sanitizeSurrogates } from "../../utils/sanitize-unicode.ts";
} from "../types.ts";
import { headersToRecord } from "../utils/headers.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
interface OpenRouterGeneratedImage {
image_url?: string | { url?: string };
@@ -34,7 +34,7 @@ type OpenRouterImageGenerationResponse = ChatCompletion & {
choices: OpenRouterImageGenerationChoice[];
};
export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", ImagesOptions> = async (
export const generateImages: ImagesFunction<"openrouter-images", ImagesOptions> = async (
model: ImagesModel<"openrouter-images">,
context: ImagesContext,
options?: ImagesOptions,
+117
View File
@@ -0,0 +1,117 @@
import type { Api, ImagesApi, ImagesModel, Model } from "../types.ts";
import type {
ApiKeyAuth,
ApiKeyCredential,
AuthContext,
AuthResult,
Credential,
CredentialStore,
OAuthAuth,
OAuthCredential,
ProviderAuth,
} from "./types.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(message, options);
this.name = "ModelsError";
this.code = code;
}
}
/** Model shape auth resolution receives: chat or image-generation models. */
export type AuthModel = Model<Api> | ImagesModel<ImagesApi>;
/**
* Auth resolution shared by the `Models` and `ImagesModels` collections.
* 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.
*/
export async function resolveProviderAuth(
provider: { id: string; auth: ProviderAuth },
model: AuthModel,
credentials: CredentialStore,
authContext: AuthContext,
): Promise<AuthResult | undefined> {
const stored = await readCredential(credentials, provider.id);
if (stored) {
if (stored.type === "oauth" && provider.auth.oauth) {
return resolveStoredOAuth(credentials, provider.id, provider.auth.oauth, stored);
}
if (stored.type === "api-key" && provider.auth.apiKey) {
return resolveApiKey(authContext, provider.auth.apiKey, model, stored);
}
return undefined;
}
// Ambient (env vars, AWS profiles, ADC files).
return provider.auth.apiKey ? resolveApiKey(authContext, provider.auth.apiKey, model, undefined) : undefined;
}
/**
* OAuth resolution with double-checked locking (same pattern as today's
* AuthStorage): valid tokens cost zero locks; expired tokens lock, re-check
* expiry under the lock, refresh once globally, and persist the rotated
* credential before release.
*/
async function resolveStoredOAuth(
credentials: CredentialStore,
providerId: string,
oauth: OAuthAuth,
stored: OAuthCredential,
): Promise<AuthResult | undefined> {
let credential = stored;
if (Date.now() >= credential.expires) {
// Optimistic check said expired; the authoritative check runs under the lock.
let post: Credential | undefined;
try {
post = await credentials.modify(providerId, async (current) => {
if (current?.type !== "oauth") return undefined; // logged out meanwhile
if (Date.now() < current.expires) return undefined; // another process/request refreshed
try {
return await oauth.refresh(current);
} catch (error) {
throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error });
}
});
} catch (error) {
if (error instanceof ModelsError) throw error;
throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
}
if (post?.type !== "oauth") return undefined; // logged out meanwhile
credential = post;
}
try {
return { auth: await oauth.toAuth(credential), source: "OAuth" };
} catch (error) {
throw new ModelsError("oauth", `OAuth auth derivation failed for ${providerId}`, { cause: error });
}
}
async function resolveApiKey(
authContext: AuthContext,
apiKey: ApiKeyAuth,
model: AuthModel,
credential: ApiKeyCredential | undefined,
): Promise<AuthResult | undefined> {
try {
return await apiKey.resolve({ model, ctx: authContext, credential });
} catch (error) {
throw new ModelsError("auth", `API key auth failed for provider ${model.provider}`, { cause: error });
}
}
async function readCredential(credentials: CredentialStore, providerId: string): Promise<Credential | undefined> {
try {
return await credentials.read(providerId);
} catch (error) {
throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error });
}
}
+4 -3
View File
@@ -1,4 +1,4 @@
import type { Api, Model } from "../types.ts";
import type { Api, ImagesApi, ImagesModel, Model } from "../types.ts";
import type { OAuthCredentials } from "../utils/oauth/types.ts";
/**
@@ -134,10 +134,11 @@ export interface ApiKeyAuth {
/**
* Resolve auth from the stored credential and/or ambient sources, merging
* per field (`credential.key ?? env("...")`, `metadata.accountId ?? env("...")`).
* undefined = not configured.
* undefined = not configured. Receives the chat or image-generation model
* the request is for (both carry `provider` and `baseUrl`).
*/
resolve(input: {
model: Model<Api>;
model: Model<Api> | ImagesModel<ImagesApi>;
ctx: AuthContext;
credential?: ApiKeyCredential;
}): Promise<AuthResult | undefined>;
+262
View File
@@ -0,0 +1,262 @@
import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";
import { InMemoryCredentialStore } from "./auth/credential-store.ts";
import { 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 for an image model. Same contract as
* `Models.getAuth()`: undefined when unknown/unconfigured, rejects with
* `ModelsError` ("oauth"/"auth") on real failures.
*/
getAuth(model: ImagesModel<ImagesApi>): 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?.()));
}
async getAuth(model: ImagesModel<ImagesApi>): Promise<AuthResult | undefined> {
const provider = this.providers.get(model.provider);
if (!provider) return undefined;
return resolveProviderAuth(provider, model, this.credentials, this.authContext);
}
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);
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 merge per header.
const apiKey = options?.apiKey ?? auth.apiKey;
const headers = auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined;
return await provider.generateImages(requestModel, context, { ...options, apiKey, headers });
} 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),
};
}
+1
View File
@@ -21,6 +21,7 @@ export * from "./auth/context.ts";
export * from "./auth/credential-store.ts";
export * from "./auth/helpers.ts";
export * from "./auth/types.ts";
export * from "./images-models.ts";
export * from "./models.ts";
export * from "./providers/faux.ts";
export * from "./session-resources.ts";
+4 -100
View File
@@ -1,17 +1,8 @@
import { lazyStream } from "./api/lazy.ts";
import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";
import { InMemoryCredentialStore } from "./auth/credential-store.ts";
import type {
ApiKeyAuth,
ApiKeyCredential,
AuthContext,
AuthResult,
Credential,
CredentialStore,
OAuthAuth,
OAuthCredential,
ProviderAuth,
} from "./auth/types.ts";
import { ModelsError, resolveProviderAuth } from "./auth/resolve.ts";
import type { AuthContext, AuthResult, CredentialStore, ProviderAuth } from "./auth/types.ts";
import type {
Api,
ApiStreamOptions,
@@ -26,17 +17,7 @@ import type {
Usage,
} from "./types.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(message, options);
this.name = "ModelsError";
this.code = code;
}
}
export { type AuthModel, ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";
/**
* A provider is the concrete runtime unit. It owns id/name/base metadata,
@@ -234,84 +215,7 @@ class ModelsImpl implements MutableModels {
async getAuth(model: Model<Api>): Promise<AuthResult | undefined> {
const provider = this.providers.get(model.provider);
if (!provider) return undefined;
// 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.
const stored = await this.readCredential(provider.id);
if (stored) {
if (stored.type === "oauth" && provider.auth.oauth) {
return this.resolveOAuth(provider.id, provider.auth.oauth, stored);
}
if (stored.type === "api-key" && provider.auth.apiKey) {
return this.resolveApiKey(provider.auth.apiKey, model, stored);
}
return undefined;
}
// Ambient (env vars, AWS profiles, ADC files).
return provider.auth.apiKey ? this.resolveApiKey(provider.auth.apiKey, model, undefined) : undefined;
}
/**
* OAuth resolution with double-checked locking (same pattern as today's
* AuthStorage): valid tokens cost zero locks; expired tokens lock,
* re-check expiry under the lock, refresh once globally, and persist the
* rotated credential before release.
*/
private async resolveOAuth(
providerId: string,
oauth: OAuthAuth,
stored: OAuthCredential,
): Promise<AuthResult | undefined> {
let credential = stored;
if (Date.now() >= credential.expires) {
// Optimistic check said expired; the authoritative check runs under the lock.
let post: Credential | undefined;
try {
post = await this.credentials.modify(providerId, async (current) => {
if (current?.type !== "oauth") return undefined; // logged out meanwhile
if (Date.now() < current.expires) return undefined; // another process/request refreshed
try {
return await oauth.refresh(current);
} catch (error) {
throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error });
}
});
} catch (error) {
if (error instanceof ModelsError) throw error;
throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
}
if (post?.type !== "oauth") return undefined; // logged out meanwhile
credential = post;
}
try {
return { auth: await oauth.toAuth(credential), source: "OAuth" };
} catch (error) {
throw new ModelsError("oauth", `OAuth auth derivation failed for ${providerId}`, { cause: error });
}
}
private async resolveApiKey(
apiKey: ApiKeyAuth,
model: Model<Api>,
credential: ApiKeyCredential | undefined,
): Promise<AuthResult | undefined> {
try {
return await apiKey.resolve({ model, ctx: this.authContext, credential });
} catch (error) {
throw new ModelsError("auth", `API key auth failed for provider ${model.provider}`, { cause: error });
}
}
private async readCredential(providerId: string): Promise<Credential | undefined> {
try {
return await this.credentials.read(providerId);
} catch (error) {
throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error });
}
return resolveProviderAuth(provider, model, this.credentials, this.authContext);
}
private requireProvider(model: Model<Api>): Provider {
+16
View File
@@ -1,3 +1,4 @@
import { createImagesModels, type ImagesProvider, type MutableImagesModels } from "../images-models.ts";
import { MODELS } from "../models.generated.ts";
import { type CreateModelsOptions, createModels, type MutableModels, type Provider } from "../models.ts";
import type { Api, KnownProvider, Model } from "../types.ts";
@@ -27,6 +28,7 @@ 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 { togetherProvider } from "./together.ts";
import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts";
import { xaiProvider } from "./xai.ts";
@@ -113,3 +115,17 @@ 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;
}
@@ -1,9 +1,9 @@
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 { generateImagesOpenRouter as generateImagesOpenRouterFunction } from "./openrouter.ts";
interface OpenRouterImagesProviderModule {
generateImagesOpenRouter: typeof generateImagesOpenRouterFunction;
generateImages: typeof generateImagesOpenRouterFunction;
}
let openRouterImagesProviderModulePromise: Promise<OpenRouterImagesProviderModule> | undefined;
@@ -21,7 +21,7 @@ function createLazyLoadErrorImages(model: ImagesModel<"openrouter-images">, erro
}
function loadOpenRouterImagesProviderModule(): Promise<OpenRouterImagesProviderModule> {
openRouterImagesProviderModulePromise ||= import("./openrouter.ts").then(
openRouterImagesProviderModulePromise ||= import("../../api/openrouter-images.ts").then(
(module) => module as OpenRouterImagesProviderModule,
);
return openRouterImagesProviderModulePromise;
@@ -34,7 +34,7 @@ export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", Image
) => {
try {
const module = await loadOpenRouterImagesProviderModule();
return await module.generateImagesOpenRouter(model, context, options);
return await module.generateImages(model, context, options);
} catch (error) {
return createLazyLoadErrorImages(model, error);
}
@@ -0,0 +1,14 @@
import { openrouterImagesApi } from "../api/openrouter-images.lazy.ts";
import { envApiKeyAuth } from "../auth/helpers.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"]) },
models: Object.values(IMAGE_MODELS.openrouter),
api: openrouterImagesApi(),
});
}
+17 -3
View File
@@ -69,7 +69,7 @@ export type ProviderId = KnownProvider | string;
export type KnownImagesProvider = "openrouter";
export type ImagesProvider = KnownImagesProvider | string;
export type ImagesProviderId = KnownImagesProvider | string;
export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh";
export type ModelThinkingLevel = "off" | ThinkingLevel;
@@ -204,6 +204,20 @@ export interface ProviderStreams {
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
}
/**
* 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.
*/
export interface ProviderImages {
generateImages(
model: ImagesModel<ImagesApi>,
context: ImagesContext,
options?: ImagesOptions,
): Promise<AssistantImages>;
}
export interface ImagesOptions {
signal?: AbortSignal;
apiKey?: string;
@@ -370,7 +384,7 @@ export type ImagesStopReason = "stop" | "error" | "aborted";
export interface AssistantImages {
api: ImagesApi;
provider: ImagesProvider;
provider: ImagesProviderId;
model: string;
output: ImagesOutputContent[];
responseId?: string;
@@ -647,6 +661,6 @@ export interface Model<TApi extends Api> {
export interface ImagesModel<TApi extends ImagesApi>
extends Omit<Model<Api>, "api" | "provider" | "reasoning" | "contextWindow" | "maxTokens" | "compat"> {
api: TApi;
provider: ImagesProvider;
provider: ImagesProviderId;
output: ("text" | "image")[];
}
+168
View File
@@ -0,0 +1,168 @@
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";
function fakeAuthContext(env: Record<string, string>): AuthContext {
return {
env: async (name) => env[name],
fileExists: async () => false,
};
}
function testImageModel(provider: string, id: string): ImagesModel<ImagesApi> {
return {
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 },
};
}
function okResult(model: ImagesModel<ImagesApi>): AssistantImages {
return {
api: model.api,
provider: model.provider,
model: model.id,
output: [{ type: "image", data: "aGk=", mimeType: "image/png" }],
stopReason: "stop",
timestamp: Date.now(),
};
}
interface GenerateCall {
model: ImagesModel<ImagesApi>;
options: ImagesOptions | undefined;
}
function testProvider(input: {
id: string;
models?: ImagesModel<ImagesApi>[];
envVar?: string;
calls?: GenerateCall[];
}): ImagesProvider {
return createImagesProvider({
id: input.id,
auth: {
apiKey: {
name: "Test key",
resolve: async ({ ctx }) => {
if (!input.envVar) return { auth: {} };
const key = await ctx.env(input.envVar);
return key ? { auth: { apiKey: key }, source: input.envVar } : undefined;
},
},
},
models: input.models ?? [testImageModel(input.id, "model-a")],
api: {
generateImages: async (model, _context, options) => {
input.calls?.push({ model, options });
return okResult(model);
},
},
});
}
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")] }));
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();
models.deleteProvider("p1");
expect(models.getProvider("p1")).toBeUndefined();
});
it("resolves auth through the provider and merges it into requests; explicit options win", async () => {
const calls: GenerateCall[] = [];
const models = createImagesModels({ authContext: fakeAuthContext({ TEST_KEY: "env-key" }) });
models.setProvider(testProvider({ id: "p1", envVar: "TEST_KEY", calls }));
const model = models.getModel("p1", "model-a")!;
expect((await models.getAuth(model))?.auth.apiKey).toBe("env-key");
const result = await models.generateImages(model, context);
expect(result.stopReason).toBe("stop");
expect(calls[0].options?.apiKey).toBe("env-key");
await models.generateImages(model, context, { apiKey: "explicit" });
expect(calls[1].options?.apiKey).toBe("explicit");
});
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);
expect(ghost.stopReason).toBe("error");
expect(ghost.errorMessage).toContain("Unknown provider: ghost");
// unconfigured (resolve -> undefined) still dispatches; provider decides what to do
const calls: GenerateCall[] = [];
models.setProvider(testProvider({ id: "p1", envVar: "MISSING", calls }));
const model = models.getModel("p1", "model-a")!;
expect(await models.getAuth(model)).toBeUndefined();
await models.generateImages(model, context);
expect(calls[0].options?.apiKey).toBeUndefined();
});
it("supports dynamic providers via refresh with in-flight dedupe", async () => {
let fetches = 0;
const provider = createImagesProvider({
id: "dyn",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
refreshModels: async () => {
fetches++;
await new Promise((resolve) => setTimeout(resolve, 5));
return [testImageModel("dyn", "listed")];
},
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) },
}),
);
await expect(models.refresh("flaky")).rejects.toMatchObject({ code: "model_source" });
await expect(models.refresh()).resolves.toBeUndefined();
});
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"]);
const list = models.getModels("openrouter");
expect(list.length).toBeGreaterThan(0);
expect(list.every((m) => m.api === "openrouter-images")).toBe(true);
expect((await models.getAuth(list[0]))?.auth.apiKey).toBe("or-key");
});
});