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:
@@ -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,
|
||||
),
|
||||
});
|
||||
+4
-4
@@ -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,
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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")[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user