import { lazyStream } from "./api/lazy.ts"; import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts"; import { InMemoryCredentialStore } from "./auth/credential-store.ts"; import { type AuthResolutionOverrides, ModelsError, resolveProviderAuth } from "./auth/resolve.ts"; import type { AuthCheck, AuthContext, AuthInteraction, AuthResult, AuthType, Credential, CredentialStore, ProviderAuth, } from "./auth/types.ts"; import { InMemoryModelsStore, type ModelsStore, type ProviderModelsStore } from "./models-store.ts"; import type { Api, ApiStreamOptions, AssistantMessage, AssistantMessageEventStream, Context, Model, ModelCostRates, ModelThinkingLevel, ProviderHeaders, ProviderStreams, SimpleStreamOptions, StreamOptions, Usage, } from "./types.ts"; export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts"; export interface RefreshModelsContext { /** Effective configured credential. OAuth credentials are refreshed before network access. */ credential?: Credential; /** Persistent model storage scoped to this provider ID. */ store: ProviderModelsStore; /** False during offline/cache-only initialization. */ allowNetwork: boolean; /** Bypass provider freshness checks and fetch immediately when network access is allowed. */ force?: boolean; signal?: AbortSignal; } export interface ModelsRefreshOptions { allowNetwork?: boolean; /** Bypass provider freshness checks and fetch immediately when network access is allowed. */ force?: boolean; signal?: AbortSignal; } export interface ModelsRefreshResult { aborted: boolean; errors: ReadonlyMap; } export interface ModelsStreamTransforms { /** Transform fully assembled model/auth/request headers before provider dispatch. */ transformHeaders?: (headers: ProviderHeaders) => ProviderHeaders | Promise; } export type ModelsApiStreamOptions = ApiStreamOptions & ModelsStreamTransforms; export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsStreamTransforms; /** * 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: restore the provider-scoped stored catalog and optionally fetch * a newer list using the effective credential. Implementations must retain their previous * list on failure and honor the shared abort signal for network requests. */ refreshModels?(context: RefreshModelsContext): Promise; /** * Optional provider policy for credential-specific model availability. * `getModels()` remains the complete synchronous catalog; `Models.getAvailable()` * applies this filter after confirming that provider auth is configured. */ filterModels?(models: readonly Model[], credential: Credential | undefined): readonly Model[]; 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; /** * Refresh every configured dynamic provider concurrently. Provider errors and cancellation * are returned without rejecting; static and unconfigured providers are skipped. */ refresh(options?: ModelsRefreshOptions): Promise; /** Check whether a provider has complete auth configuration without refreshing OAuth. */ checkAuth(providerId: string): Promise; /** Return models whose providers have complete auth configuration. */ getAvailable(providerId?: string): Promise[]>; /** * Resolve provider-scoped auth by provider id, or provider auth plus static * model headers when passed 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. */ getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise; getAuth(model: Model, overrides?: AuthResolutionOverrides): Promise; /** Run a provider-owned login flow and persist its returned credential. */ login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise; /** Remove the stored credential for a provider. */ logout(providerId: string): Promise; stream( model: Model, context: Context, options?: ModelsApiStreamOptions, ): AssistantMessageEventStream; complete( model: Model, context: Context, options?: ModelsApiStreamOptions, ): Promise; streamSimple(model: Model, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream; completeSimple(model: Model, context: Context, options?: ModelsSimpleStreamOptions): 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; modelsStore?: ModelsStore; authContext?: AuthContext; } function mergeHeaders( base: ProviderHeaders | undefined, override: ProviderHeaders | undefined, ): ProviderHeaders | undefined { if (!base && !override) return undefined; const merged = { ...base }; for (const [name, value] of Object.entries(override ?? {})) { const lowerName = name.toLowerCase(); for (const existingName of Object.keys(merged)) { if (existingName.toLowerCase() === lowerName) delete merged[existingName]; } merged[name] = value; } return merged; } class ModelsImpl implements MutableModels { private providers = new Map(); private credentials: CredentialStore; private modelsStore: ModelsStore; private authContext: AuthContext; constructor(options?: CreateModelsOptions) { this.credentials = options?.credentials ?? new InMemoryCredentialStore(); this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore(); 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(options: ModelsRefreshOptions = {}): Promise { const allowNetwork = options.allowNetwork ?? true; const errors = new Map(); const refreshable = Array.from(this.providers.values()).filter( (provider): provider is Provider & Required> => provider.refreshModels !== undefined, ); await Promise.all( refreshable.map(async (provider) => { if (options.signal?.aborted) return; const store: ProviderModelsStore = { read: () => this.modelsStore.read(provider.id), write: (entry) => this.modelsStore.write(provider.id, entry), delete: () => this.modelsStore.delete(provider.id), }; let stored: Credential | undefined; try { stored = await this.readCredential(provider.id); const credential = await this.resolveRefreshCredential(provider, stored, allowNetwork, options.signal); if (!credential) return; await provider.refreshModels({ credential, store, allowNetwork, force: options.force, signal: options.signal, }); } catch (error) { if (!options.signal?.aborted) { errors.set( provider.id, error instanceof Error ? error : new ModelsError("model_source", `Model refresh failed for ${provider.id}`, { cause: error }), ); } try { await provider.refreshModels({ credential: stored, store, allowNetwork: false, signal: options.signal, }); } catch { // Preserve the original auth/network error; cache restoration is best-effort here. } } }), ); return { aborted: options.signal?.aborted ?? false, errors }; } private async resolveRefreshCredential( provider: Provider, stored: Credential | undefined, allowNetwork: boolean, signal?: AbortSignal, ): Promise { if (stored?.type === "oauth") { const oauth = provider.auth.oauth; if (!oauth) return undefined; if (!allowNetwork || Date.now() < stored.expires) return stored; if (signal?.aborted) return undefined; const post = await this.credentials.modify(provider.id, async (current) => { if (current?.type !== "oauth" || Date.now() < current.expires) return undefined; return oauth.refresh(current, signal); }); return post?.type === "oauth" ? post : undefined; } const apiKey = provider.auth.apiKey; if (!apiKey) return undefined; const credential = stored?.type === "api_key" ? stored : undefined; const result = await apiKey.resolve({ ctx: this.authContext, credential }); if (!result) return undefined; return { type: "api_key", key: result.auth.apiKey, env: result.env }; } 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 }); } } private async checkProviderAuth( provider: Provider, credential: Credential | undefined, ): Promise { if (credential?.type === "oauth") { return provider.auth.oauth ? { source: "OAuth", type: "oauth" } : undefined; } const apiKey = provider.auth.apiKey; if (!apiKey) return undefined; if (apiKey.check) { try { return await apiKey.check({ ctx: this.authContext, credential: credential?.type === "api_key" ? credential : undefined, }); } catch (error) { throw new ModelsError("auth", `API key auth check failed for provider ${provider.id}`, { cause: error }); } } const resolution = await resolveProviderAuth(provider, this.credentials, this.authContext); return resolution ? { source: resolution.source, type: "api_key" } : undefined; } async checkAuth(providerId: string): Promise { const provider = this.providers.get(providerId); if (!provider) return undefined; return this.checkProviderAuth(provider, await this.readCredential(providerId)); } async getAvailable(providerId?: string): Promise[]> { const providers = providerId ? [this.providers.get(providerId)].filter((entry) => entry !== undefined) : this.getProviders(); const checks = await Promise.all( providers.map(async (provider) => { const credential = await this.readCredential(provider.id); return { provider, credential, auth: await this.checkProviderAuth(provider, credential) }; }), ); return checks.flatMap(({ provider, credential, auth }) => { if (!auth) return []; const models = provider.getModels(); return provider.filterModels?.(models, credential) ?? models; }); } getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise; getAuth(model: Model, overrides?: AuthResolutionOverrides): Promise; async getAuth( providerOrModel: string | Model, overrides?: AuthResolutionOverrides, ): Promise { const providerId = typeof providerOrModel === "string" ? providerOrModel : providerOrModel.provider; const provider = this.providers.get(providerId); if (!provider) return undefined; const result = await resolveProviderAuth(provider, this.credentials, this.authContext, overrides); if (!result || typeof providerOrModel === "string" || !providerOrModel.headers) return result; return { ...result, auth: { ...result.auth, headers: mergeHeaders(result.auth.headers, providerOrModel.headers), }, }; } async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise { const provider = this.providers.get(providerId); if (!provider) throw new ModelsError("provider", `Unknown provider: ${providerId}`); const method = type === "oauth" ? provider.auth.oauth : provider.auth.apiKey; if (!method?.login) { throw new ModelsError("auth", `${provider.name} does not support ${type} login`); } const credential = await method.login(interaction); try { await this.credentials.modify(providerId, async () => credential); } catch (error) { throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error }); } return credential; } async logout(providerId: string): Promise { try { await this.credentials.delete(providerId); } catch (error) { throw new ModelsError("auth", `Credential store delete failed for ${providerId}`, { cause: error }); } } 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: StreamOptions | undefined }> { this.requireProvider(model); const resolution = await this.getAuth(model, { apiKey: options?.apiKey, env: options?.env, }); if (!resolution) { throw new ModelsError("auth", `Provider is not configured: ${model.provider}`); } const auth = resolution.auth; // Explicit request options win per-field; the Models-only transform runs last. const apiKey = options?.apiKey ?? auth.apiKey; let headers = mergeHeaders(auth.headers, options?.headers); if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {}); const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined; const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {}; const requestOptions = { ...providerOptions, apiKey, headers, env } as StreamOptions; return { requestModel, requestOptions }; } stream( model: Model, context: Context, options?: ModelsApiStreamOptions, ): AssistantMessageEventStream { return lazyStream(model, async () => { const provider = this.requireProvider(model); const { requestModel, requestOptions } = await this.applyAuth( model, options as ModelsApiStreamOptions | undefined, ); return provider.stream(requestModel as Model, context, requestOptions as ApiStreamOptions); }); } async complete( model: Model, context: Context, options?: ModelsApiStreamOptions, ): Promise { return this.stream(model, context, options).result(); } streamSimple(model: Model, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream { return lazyStream(model, async () => { const provider = this.requireProvider(model); const { requestModel, requestOptions } = await this.applyAuth(model, options); return provider.streamSimple(requestModel, context, requestOptions as SimpleStreamOptions); }); } async completeSimple( model: Model, context: Context, options?: ModelsSimpleStreamOptions, ): 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; /** Static baseline model list (empty for purely dynamic providers). */ models: readonly Model[]; /** Fetch a dynamic model overlay. createProvider restores/persists it through ModelsStore. */ fetchModels?: (context: RefreshModelsContext) => Promise[]>; filterModels?: (models: readonly Model[], credential: Credential | undefined) => readonly Model[]; /** 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 { const baselineModels = input.models; let dynamicModels: readonly Model[] = []; let inflightRefresh: Promise | undefined; const fetchModels = input.fetchModels; const currentModels = (): readonly Model[] => { const merged = [...baselineModels]; for (const model of dynamicModels) { const index = merged.findIndex((entry) => entry.id === model.id); if (index >= 0) merged[index] = model; else merged.push(model); } return merged; }; 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: currentModels, refreshModels: fetchModels ? (context) => { inflightRefresh ??= (async () => { try { const stored = await context.store.read(); if (stored) { dynamicModels = stored.models .filter((model) => model.provider === input.id) .map((model) => model as Model); } if (!context.allowNetwork || context.signal?.aborted) return; const refreshed = await fetchModels(context); if (context.signal?.aborted) return; dynamicModels = refreshed; await context.store.write({ models: refreshed, checkedAt: Date.now() }); } finally { inflightRefresh = undefined; } })(); return inflightRefresh; } : undefined, filterModels: input.filterModels, 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"] { const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite; let rates: ModelCostRates = model.cost; let matchedThreshold = -1; for (const tier of model.cost.tiers ?? []) { if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) { rates = tier; matchedThreshold = tier.inputTokensAbove; } } // Anthropic charges 2x base input for 1h cache writes. const longWrite = usage.cacheWrite1h ?? 0; const shortWrite = usage.cacheWrite - longWrite; usage.cost.input = (rates.input / 1000000) * usage.input; usage.cost.output = (rates.output / 1000000) * usage.output; usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead; usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.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", "max"]; 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" || level === "max") 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; }