diff --git a/packages/agent/docs/models.md b/packages/agent/docs/models.md index cfa524e0a..05307be5a 100644 --- a/packages/agent/docs/models.md +++ b/packages/agent/docs/models.md @@ -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 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0c0d3bd6b..c450e028d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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)). diff --git a/packages/ai/README.md b/packages/ai/README.md index 72ab339bc..735e6c186 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -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/.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/.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/.ts`, only for a new API): export exactly `stream` and `streamSimple`, plus the options interface extending `StreamOptions`. Add a lazy wrapper `src/api/.lazy.ts` (`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/.models.ts` and the aggregator. -4. **Provider factory** (`src/providers/.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/.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/.lazy.ts` (`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/.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/.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 diff --git a/packages/ai/src/api/openrouter-images.lazy.ts b/packages/ai/src/api/openrouter-images.lazy.ts new file mode 100644 index 000000000..362d50a00 --- /dev/null +++ b/packages/ai/src/api/openrouter-images.lazy.ts @@ -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, + ), +}); diff --git a/packages/ai/src/providers/images/openrouter.ts b/packages/ai/src/api/openrouter-images.ts similarity index 95% rename from packages/ai/src/providers/images/openrouter.ts rename to packages/ai/src/api/openrouter-images.ts index 54caeaf05..c96be7c8b 100644 --- a/packages/ai/src/providers/images/openrouter.ts +++ b/packages/ai/src/api/openrouter-images.ts @@ -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, diff --git a/packages/ai/src/auth/resolve.ts b/packages/ai/src/auth/resolve.ts new file mode 100644 index 000000000..3adec6b4b --- /dev/null +++ b/packages/ai/src/auth/resolve.ts @@ -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 | ImagesModel; + +/** + * 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 { + 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 { + 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 { + 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 { + try { + return await credentials.read(providerId); + } catch (error) { + throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error }); + } +} diff --git a/packages/ai/src/auth/types.ts b/packages/ai/src/auth/types.ts index b308b90d4..74bb45bfa 100644 --- a/packages/ai/src/auth/types.ts +++ b/packages/ai/src/auth/types.ts @@ -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; + model: Model | ImagesModel; ctx: AuthContext; credential?: ApiKeyCredential; }): Promise; diff --git a/packages/ai/src/images-models.ts b/packages/ai/src/images-models.ts new file mode 100644 index 000000000..67449b035 --- /dev/null +++ b/packages/ai/src/images-models.ts @@ -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[]; + + /** + * 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; + + generateImages( + model: ImagesModel, + context: ImagesContext, + options?: ImagesOptions, + ): Promise; +} + +/** + * 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[]; + + /** Sync runtime model lookup against last-known lists. */ + getModel(provider: string, id: string): ImagesModel | 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; + + /** + * 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): Promise; + + /** + * 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, + context: ImagesContext, + options?: ImagesOptions, + ): Promise; +} + +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(); + 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[] { + if (provider !== undefined) { + const entry = this.providers.get(provider); + if (!entry) return []; + try { + return entry.getModels(); + } catch { + return []; + } + } + + const models: ImagesModel[] = []; + 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 | undefined { + return this.getModels(provider).find((model) => model.id === id); + } + + async refresh(provider?: string): Promise { + 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): Promise { + const provider = this.providers.get(model.provider); + if (!provider) return undefined; + return resolveProviderAuth(provider, model, this.credentials, this.authContext); + } + + async generateImages( + model: ImagesModel, + context: ImagesContext, + options?: ImagesOptions, + ): Promise { + 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[]; + /** + * 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[]>; + api: ProviderImages; +} + +/** Builds an image-generation provider from parts. */ +export function createImagesProvider(input: CreateImagesProviderOptions): ImagesProvider { + let models = input.models; + let inflightRefresh: Promise | 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), + }; +} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 53a6f7191..6c3f9675c 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -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"; diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 37d338411..e540db692 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -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): Promise { 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 { - 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, - credential: ApiKeyCredential | undefined, - ): Promise { - 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 { - 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): Provider { diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index 1c4c70b1c..85ba03013 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -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; +} diff --git a/packages/ai/src/providers/images/register-builtins.ts b/packages/ai/src/providers/images/register-builtins.ts index e3decbb99..a5c901faf 100644 --- a/packages/ai/src/providers/images/register-builtins.ts +++ b/packages/ai/src/providers/images/register-builtins.ts @@ -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 | undefined; @@ -21,7 +21,7 @@ function createLazyLoadErrorImages(model: ImagesModel<"openrouter-images">, erro } function loadOpenRouterImagesProviderModule(): Promise { - 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); } diff --git a/packages/ai/src/providers/openrouter-images.ts b/packages/ai/src/providers/openrouter-images.ts new file mode 100644 index 000000000..7047cf0e6 --- /dev/null +++ b/packages/ai/src/providers/openrouter-images.ts @@ -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(), + }); +} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 2bce49bc9..bbfbd5d6e 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -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, 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, + context: ImagesContext, + options?: ImagesOptions, + ): Promise; +} + 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 { export interface ImagesModel extends Omit, "api" | "provider" | "reasoning" | "contextWindow" | "maxTokens" | "compat"> { api: TApi; - provider: ImagesProvider; + provider: ImagesProviderId; output: ("text" | "image")[]; } diff --git a/packages/ai/test/images-models.test.ts b/packages/ai/test/images-models.test.ts new file mode 100644 index 000000000..1b2e08dab --- /dev/null +++ b/packages/ai/test/images-models.test.ts @@ -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): AuthContext { + return { + env: async (name) => env[name], + fileExists: async () => false, + }; +} + +function testImageModel(provider: string, id: string): ImagesModel { + 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): 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; + options: ImagesOptions | undefined; +} + +function testProvider(input: { + id: string; + models?: ImagesModel[]; + 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"); + }); +});