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
parent e1283fc17a
commit 827fe1e2c4
15 changed files with 804 additions and 149 deletions
+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),
};
}