import { lazyStream } from "./api/lazy.ts"; 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 { Api, ApiStreamOptions, AssistantMessage, AssistantMessageEventStream, Context, Model, ModelThinkingLevel, ProviderHeaders, ProviderStreams, SimpleStreamOptions, StreamOptions, Usage, } from "./types.ts"; export { type AuthModel, ModelsError, type ModelsErrorCode } from "./auth/resolve.ts"; /** * A provider is the concrete runtime unit. It owns id/name/base metadata, * auth methods, model listing, and stream behavior. * * `TApi` lets concrete provider factories declare which APIs their models * use (e.g. `openaiProvider(): Provider<"openai-responses" | "openai-completions">`), * giving typed model lists to direct factory users. Inside a `Models` * collection providers are held as `Provider`. */ export interface Provider { readonly id: string; readonly name: string; readonly baseUrl?: string; readonly headers?: ProviderHeaders; /** * Required: at least one of `apiKey`/`oauth`. Every provider has auth * semantics — even providers with only ambient credentials (env vars, AWS * profiles, ADC files) and keyless local servers provide `apiKey` auth * whose `resolve()` reports whether the provider is configured. * `Models.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; `Models` treats a throwing * implementation as having no models. */ getModels(): readonly Model[]; /** * Dynamic providers only: fetch and update the model list. Side-effect-free * discovery (no loading/downloading); provider-specific model lifecycle * belongs in app commands. Concurrent calls share one in-flight fetch. * May reject (network); on rejection the model list stays at its last-known * state and a later call retries. */ refreshModels?(): Promise; stream( model: Model, context: Context, options?: ApiStreamOptions, ): AssistantMessageEventStream; streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; } /** * Runtime collection of providers plus auth application and stream * convenience. Providers own stream behavior; `Models` resolves auth and * delegates each request to the provider that owns the model. */ export interface Models { getProviders(): readonly Provider[]; getProvider(id: string): Provider | 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 Model[]; /** * Sync runtime model lookup against last-known lists. Dynamic model lists * are typed as `Model`; narrow with the `hasApi()` type guard. */ getModel(provider: string, id: string): Model | 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 a model. Includes a source label for status UI. * Resolves `undefined` when the provider is unknown or unconfigured. * Rejects with `ModelsError`: code "oauth" when a token refresh fails (the * stored credential is preserved for retry; re-login fixes it), code "auth" * when api-key resolution or the credential store fails. Request paths * surface rejections as stream errors; status/availability UIs catch them * and render "needs re-login" instead of treating them as unconfigured. */ getAuth(model: Model): Promise; stream( model: Model, context: Context, options?: ApiStreamOptions, ): AssistantMessageEventStream; complete( model: Model, context: Context, options?: ApiStreamOptions, ): Promise; streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; completeSimple(model: Model, context: Context, options?: SimpleStreamOptions): Promise; } export interface MutableModels extends Models { /** Upsert/replace by provider.id. Provider ids are unique. */ setProvider(provider: Provider): void; deleteProvider(id: string): void; clearProviders(): void; } export interface CreateModelsOptions { credentials?: CredentialStore; authContext?: AuthContext; } class ModelsImpl implements MutableModels { 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: Provider): void { this.providers.set(provider.id, provider); } deleteProvider(id: string): void { this.providers.delete(id); } clearProviders(): void { this.providers.clear(); } getProviders(): readonly Provider[] { return Array.from(this.providers.values()); } getProvider(id: string): Provider | undefined { return this.providers.get(id); } getModels(provider?: string): readonly Model[] { if (provider !== undefined) { const entry = this.providers.get(provider); if (!entry) return []; try { return entry.getModels(); } catch { return []; } } const models: Model[] = []; 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): Model | 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: Model): Promise { const provider = this.providers.get(model.provider); if (!provider) return undefined; return resolveProviderAuth(provider, model, this.credentials, this.authContext); } private requireProvider(model: Model): Provider { const provider = this.providers.get(model.provider); if (!provider) { throw new ModelsError("provider", `Unknown provider: ${model.provider}`); } return provider; } private async applyAuth( model: Model, options: TOptions | undefined, ): Promise<{ requestModel: Model; requestOptions: TOptions | undefined }> { const resolution = await resolveProviderAuth( this.requireProvider(model), model, this.credentials, this.authContext, { apiKey: options?.apiKey, env: options?.env, }, ); const auth = resolution?.auth; if (!auth) return { requestModel: model, requestOptions: options }; const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; // Explicit request options win per-field; headers/env merge per key. const apiKey = options?.apiKey ?? auth.apiKey; const headers = auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined; const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined; const requestOptions = { ...options, apiKey, headers, env } as TOptions; return { requestModel, requestOptions }; } stream( model: Model, context: Context, options?: ApiStreamOptions, ): AssistantMessageEventStream { return lazyStream(model, async () => { const provider = this.requireProvider(model); const { requestModel, requestOptions } = await this.applyAuth(model, options as StreamOptions | undefined); return provider.stream(requestModel as Model, context, requestOptions as ApiStreamOptions); }); } async complete( model: Model, context: Context, options?: ApiStreamOptions, ): Promise { return this.stream(model, context, options).result(); } streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream { return lazyStream(model, async () => { const provider = this.requireProvider(model); const { requestModel, requestOptions } = await this.applyAuth(model, options); return provider.streamSimple(requestModel, context, requestOptions); }); } async completeSimple(model: Model, context: Context, options?: SimpleStreamOptions): Promise { return this.streamSimple(model, context, options).result(); } } export function createModels(options?: CreateModelsOptions): MutableModels { return new ModelsImpl(options); } export interface CreateProviderOptions { id: string; /** Display name. Default: `id`. */ name?: string; baseUrl?: string; headers?: ProviderHeaders; /** Required — every provider has auth semantics, even ambient/keyless ones. */ auth: ProviderAuth; /** Initial model list (empty for purely dynamic providers). */ models: readonly Model[]; /** * 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 * `Models.refresh(provider)`), and a later call retries. */ refreshModels?: () => Promise[]>; /** Single implementation, or map keyed by `model.api` for mixed-API providers. */ api: ProviderStreams | Partial>; } /** * Builds a provider from parts. Built-in provider factories and models.json * custom providers both go through this. A single `api` streams all models; * an `api` map dispatches on `model.api`, and a model whose api has no entry * produces a stream error. */ export function createProvider(input: CreateProviderOptions): Provider { let models = input.models; let inflightRefresh: Promise | undefined; const refreshModels = input.refreshModels; const single = typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined; const byApi = single ? undefined : (input.api as Partial>); const apiFor = (model: Model): ProviderStreams | undefined => single ?? byApi?.[model.api]; const dispatch = ( model: Model, run: (streams: ProviderStreams) => AssistantMessageEventStream, ): AssistantMessageEventStream => { const streams = apiFor(model); if (!streams) { return lazyStream(model, async () => { throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`); }); } return run(streams); }; return { id: input.id, name: input.name ?? input.id, baseUrl: input.baseUrl, headers: input.headers, auth: input.auth, getModels: () => models, refreshModels: refreshModels ? () => { inflightRefresh ??= (async () => { try { models = await refreshModels(); } finally { inflightRefresh = undefined; } })(); return inflightRefresh; } : undefined, stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)), streamSimple: (model, context, options) => dispatch(model, (streams) => streams.streamSimple(model, context, options)), }; } /** * Runtime-checked narrowing for dynamically looked-up models: * * ```ts * const model = models.getModel("anthropic", "claude-opus-4-7"); * if (model && hasApi(model, "anthropic-messages")) { * // model: Model<"anthropic-messages">, stream options fully typed * } * ``` */ export function hasApi(model: Model, api: TApi): model is Model { return model.api === api; } export function calculateCost(model: Model, usage: Usage): Usage["cost"] { // Anthropic charges 2x base input for 1h cache writes. const longWrite = usage.cacheWrite1h ?? 0; const shortWrite = usage.cacheWrite - longWrite; usage.cost.input = (model.cost.input / 1000000) * usage.input; usage.cost.output = (model.cost.output / 1000000) * usage.output; usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead; usage.cost.cacheWrite = (model.cost.cacheWrite * shortWrite + model.cost.input * 2 * longWrite) / 1000000; usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; return usage.cost; } const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; export function getSupportedThinkingLevels(model: Model): ModelThinkingLevel[] { if (!model.reasoning) return ["off"]; return EXTENDED_THINKING_LEVELS.filter((level) => { const mapped = model.thinkingLevelMap?.[level]; if (mapped === null) return false; if (level === "xhigh") return mapped !== undefined; return true; }); } export function clampThinkingLevel( model: Model, level: ModelThinkingLevel, ): ModelThinkingLevel { const availableLevels = getSupportedThinkingLevels(model); if (availableLevels.includes(level)) return level; const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level); if (requestedIndex === -1) return availableLevels[0] ?? "off"; for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) { const candidate = EXTENDED_THINKING_LEVELS[i]; if (availableLevels.includes(candidate)) return candidate; } for (let i = requestedIndex - 1; i >= 0; i--) { const candidate = EXTENDED_THINKING_LEVELS[i]; if (availableLevels.includes(candidate)) return candidate; } return availableLevels[0] ?? "off"; } /** * Check if two models are equal by comparing both their id and provider. * Returns false if either model is null or undefined. */ export function modelsAreEqual( a: Model | null | undefined, b: Model | null | undefined, ): boolean { if (!a || !b) return false; return a.id === b.id && a.provider === b.provider; }