1
import { lazyStream } from "./api/lazy.ts";2
import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";3
import { InMemoryCredentialStore } from "./auth/credential-store.ts";4
import { type AuthResolutionOverrides, ModelsError, resolveProviderAuth } from "./auth/resolve.ts";5
import type {6
AuthCheck,7
AuthContext,8
AuthInteraction,9
AuthOperationOptions,10
AuthResult,11
AuthType,12
Credential,13
CredentialStore,14
LoginOptions,15
ProviderAuth,16
} from "./auth/types.ts";17
import { InMemoryModelsStore, type ModelsStore, type ModelsStoreEntry } from "./models-store.ts";18
import type {19
AnyModel,20
Api,21
ApiStreamOptions,22
AssistantImages,23
AssistantMessage,24
AssistantMessageEventStream,25
ClassifierApi,26
ClassifierContext,27
ClassifierModel,28
ClassifierOptions,29
ClassifierResult,30
Context,31
DeferredCancelOptions,32
DeferredFetchOptions,33
DeferredHandle,34
ImageApi,35
ImageModel,36
ImagesContext,37
ImagesOptions,38
Model,39
ModelCostRates,40
ModelThinkingLevel,41
ModelType,42
ModelTypeMap,43
ProviderClassifier,44
ProviderHeaders,45
ProviderImages,46
ProviderRequestOptions,47
ProviderStreams,48
SimpleStreamOptions,49
TranscriptContext,50
Usage,51
} from "./types.ts";52
import { operationSignal, raceWithAbortSignal } from "./utils/abort.ts";53
import {54
assertChatModel,55
assertClassifierModel,56
assertImageModel,57
classifierErrorResult,58
getModelType,59
imageErrorResult,60
isModelType,61
} from "./utils/model-operations.ts";62
import { normalizeContext } from "./utils/transcript.ts";64
export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";65
export { getModelType, isModelType } from "./utils/model-operations.ts";67
export interface ModelsPublication {68
/** Provider-selected persisted catalog. Omit to leave storage unchanged; null deletes it. */69
persist?: ModelsStoreEntry | null;70
/** Optional synchronous update of provider-private in-memory catalog state. */71
update?: () => void;72
}74
export interface RefreshModelsContext {75
/** Effective configured credential. OAuth credentials are refreshed before network access. */76
credential?: Credential;77
/** Immutable provider-scoped catalog snapshot captured before this refresh phase. */78
stored?: Readonly<ModelsStoreEntry>;79
/**80
* Generation-checked publication. Persistence policy remains provider-owned;81
* the update runs synchronously only after the selected persistence mutation.82
*/83
publish(publication: ModelsPublication): Promise<boolean>;84
/** False during offline/cache-only initialization. */85
allowNetwork: boolean;86
/** Bypass provider freshness checks and fetch immediately when network access is allowed. */87
force?: boolean;88
/** Always present, including when the public refresh caller omits its optional signal. */89
signal: AbortSignal;90
}92
export interface ModelsRefreshOptions {93
allowNetwork?: boolean;94
/** Restrict refresh to these provider IDs. Unknown and static providers are ignored. */95
providers?: readonly string[];96
/** Bypass provider freshness checks and fetch immediately when network access is allowed. */97
force?: boolean;98
signal?: AbortSignal;99
}101
export interface ModelsRefreshResult {102
aborted: boolean;103
errors: ReadonlyMap<string, Error>;104
}106
export interface ModelsRequestTransforms {107
/** Transform fully assembled model/auth/request headers before provider dispatch. */108
transformHeaders?: (headers: ProviderHeaders) => ProviderHeaders | Promise<ProviderHeaders>;109
}111
export type ModelsApiStreamOptions<TApi extends Api> = ApiStreamOptions<TApi> & ModelsRequestTransforms;112
export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsRequestTransforms;113
export type ModelsDeferredFetchOptions = DeferredFetchOptions & ModelsRequestTransforms;114
export type ModelsDeferredCancelOptions = DeferredCancelOptions & ModelsRequestTransforms;115
export type ModelsImagesOptions = ImagesOptions & ModelsRequestTransforms;116
export type ModelsClassifierOptions = ClassifierOptions & ModelsRequestTransforms;118
const KNOWN_MODEL_TYPES: Record<ModelType, true> = { chat: true, image: true, classifier: true };120
/** Models from stores and remote sources may have types that only newer versions know. */121
function hasKnownModelType(model: AnyModel): boolean {122
return Object.hasOwn(KNOWN_MODEL_TYPES, getModelType(model));123
}125
/** Drops stored models whose type this version does not know. */126
function withKnownModelTypes(entry: ModelsStoreEntry): ModelsStoreEntry {127
return { ...entry, models: entry.models.filter(hasKnownModelType) };128
}130
/** Any model a provider with chat APIs `TApi` can list. */131
type ProviderModel<TApi extends Api> = Model<TApi> | ImageModel<ImageApi> | ClassifierModel<ClassifierApi>;133
/**134
* A provider is the concrete runtime unit. It owns id/name/base metadata,135
* auth methods, model listing, and the operations its models support136
* (streaming, image generation, classification).137
*138
* `TApi` lets concrete provider factories declare which chat APIs their models139
* use (e.g. `openaiProvider(): Provider<"openai-responses" | "openai-completions">`),140
* giving typed chat model lists to direct factory users. Other model types use141
* their operation-specific API unions. Inside a `Models` collection providers142
* are held as `Provider<Api>`.143
*/144
export interface Provider<TApi extends Api = Api> {145
readonly id: string;146
readonly name: string;148
readonly baseUrl?: string;149
readonly headers?: ProviderHeaders;151
/**152
* Required: at least one of `apiKey`/`oauth`. Every provider has auth153
* semantics — even providers with only ambient credentials (env vars, AWS154
* profiles, ADC files) and keyless local servers provide `apiKey` auth155
* whose `resolve()` reports whether the provider is configured.156
* `Models.getAuth()` returns undefined when the provider is unconfigured.157
*/158
readonly auth: ProviderAuth;160
/**161
* Current known chat models, sync. Static providers return their catalog;162
* dynamic providers return the list as of the last `refreshModels()` (empty163
* before the first). Must not throw; `Models` treats a throwing164
* implementation as having no models.165
*/166
getModels(): readonly Model<TApi>[];168
/**169
* Current known models of every type, sync, with the same contract as170
* `getModels()`. Providers with only chat models may omit it; `Models` then171
* uses `getModels()`. Model ids are unique within each type; one upstream172
* model may have separate entries for different operations.173
*/174
getAllModels?(): readonly ProviderModel<TApi>[];176
/**177
* Dynamic providers only: restore `context.stored` and optionally fetch a newer list using178
* the effective credential. Implementations retain their previous list on failure, publish179
* persistence and synchronous state changes through `context.publish()`, and honor the180
* shared abort signal for blocking work.181
*/182
refreshModels?(context: RefreshModelsContext): Promise<void>;184
/**185
* Optional provider policy for credential-specific model availability.186
* `getModels()` remains the complete synchronous chat catalog; `Models.getAvailable()`187
* applies this filter after confirming that provider auth is configured.188
*/189
filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];191
/**192
* Optional credential-specific availability policy across every model type.193
* Without it, `Models.getAllAvailable()` applies `filterModels` to chat models194
* and keeps every other model.195
*/196
filterAllModels?(197
models: readonly ProviderModel<TApi>[],198
credential: Credential | undefined,199
): readonly ProviderModel<TApi>[];201
/** Stream a normalized transcript. `Models` normalizes the caller's `Context` before dispatching here. */202
stream<T extends TApi>(203
model: Model<T>,204
context: TranscriptContext,205
options?: ApiStreamOptions<T>,206
): AssistantMessageEventStream;208
streamSimple(209
model: Model<TApi>,210
context: TranscriptContext,211
options?: SimpleStreamOptions,212
): AssistantMessageEventStream;213
fetchDeferred?(214
model: Model<TApi>,215
handle: DeferredHandle,216
options?: DeferredFetchOptions,217
): AssistantMessageEventStream;218
cancelDeferred?(model: Model<TApi>, handle: DeferredHandle, options?: DeferredCancelOptions): Promise<void>;220
/** Present when the provider supports dedicated image models. Never rejects. */221
generateImages?(222
model: ImageModel<ImageApi>,223
context: ImagesContext,224
options?: ImagesOptions,225
): Promise<AssistantImages>;227
/** Present when the provider supports structured classifier models. Never rejects. */228
classify?(229
model: ClassifierModel<ClassifierApi>,230
context: ClassifierContext,231
options?: ClassifierOptions,232
): Promise<ClassifierResult>;233
}235
/**236
* Runtime collection of providers plus auth application and request237
* convenience. Providers own request behavior; `Models` resolves auth and238
* delegates each request to the provider that owns the model.239
*240
* Read accessors come in three flavors: the unqualified ones (`getModels`,241
* `getModel`, `getAvailable`) return chat models, the `*OfType` accessors242
* return one model type, and `getAllModels`/`getAllAvailable` return every type.243
*/244
export interface Models {245
getProviders(): readonly Provider[];246
getProvider(id: string): Provider | undefined;248
/**249
* Sync read of last-known chat models from one provider or all providers.250
* Best-effort: a provider whose `getModels()` throws yields no models.251
*/252
getModels(provider?: string): readonly Model<Api>[];254
/**255
* Sync runtime chat model lookup against last-known lists. Dynamic model lists256
* are typed as `Model<Api>`; narrow with the `hasApi()` type guard.257
*/258
getModel(provider: string, id: string): Model<Api> | undefined;260
/** Sync read of last-known models of one type from one provider or all providers. */261
getModelsOfType<TType extends ModelType>(type: TType, provider?: string): readonly ModelTypeMap[TType][];263
/** Sync runtime lookup of a model of one type against last-known lists. */264
getModelOfType<TType extends ModelType>(type: TType, provider: string, id: string): ModelTypeMap[TType] | undefined;266
/** Sync read of last-known models of every type from one provider or all providers. */267
getAllModels(provider?: string): readonly AnyModel[];269
/**270
* Refresh selected configured dynamic providers concurrently (all when `providers` is omitted).271
* Provider errors and cancellation are returned without rejecting; static, unknown, and272
* unconfigured providers are skipped.273
*/274
refresh(options?: ModelsRefreshOptions): Promise<ModelsRefreshResult>;276
/** Check whether a provider has complete auth configuration without refreshing OAuth. */277
checkAuth(providerId: string, options?: AuthOperationOptions): Promise<AuthCheck | undefined>;279
/** Return chat models whose providers have complete auth configuration. */280
getAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly Model<Api>[]>;282
/** Return models of one type whose providers have complete auth configuration. */283
getAvailableOfType<TType extends ModelType>(284
type: TType,285
providerId?: string,286
options?: AuthOperationOptions,287
): Promise<readonly ModelTypeMap[TType][]>;289
/** Return models of every type whose providers have complete auth configuration. */290
getAllAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly AnyModel[]>;292
/**293
* Resolve provider-scoped auth by provider id, or provider auth plus static294
* model headers when passed a model. Includes a source label for status UI.295
* Resolves `undefined` when the provider is unknown or unconfigured.296
* Rejects with `ModelsError`: code "oauth" when a token refresh fails (the297
* stored credential is preserved for retry; re-login fixes it), code "auth"298
* when api-key resolution or the credential store fails. Request paths299
* surface rejections as stream errors.300
*/301
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;302
getAuth(model: AnyModel, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;304
/** Run a provider-owned login flow and persist its returned credential. */305
login(providerId: string, type: AuthType, interaction: AuthInteraction, options?: LoginOptions): Promise<Credential>;307
/** Remove the stored credential for a provider. */308
logout(providerId: string, options?: AuthOperationOptions): Promise<void>;310
stream<TApi extends Api>(311
model: Model<TApi>,312
context: Context,313
options?: ModelsApiStreamOptions<TApi>,314
): AssistantMessageEventStream;316
complete<TApi extends Api>(317
model: Model<TApi>,318
context: Context,319
options?: ModelsApiStreamOptions<TApi>,320
): Promise<AssistantMessage>;322
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream;323
completeSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): Promise<AssistantMessage>;324
streamDeferred(325
model: Model<Api>,326
handle: DeferredHandle,327
options?: ModelsDeferredFetchOptions,328
): AssistantMessageEventStream;329
fetchDeferred(330
model: Model<Api>,331
handle: DeferredHandle,332
options?: ModelsDeferredFetchOptions,333
): Promise<AssistantMessage>;334
cancelDeferred(model: Model<Api>, handle: DeferredHandle, options?: ModelsDeferredCancelOptions): Promise<void>;336
/**337
* Generate images through the owning provider with auth resolved like338
* `stream()`. Never rejects: unknown providers, unconfigured auth, and339
* providers without `generateImages` return an error `AssistantImages`.340
*/341
generateImages(342
model: ImageModel<ImageApi>,343
context: ImagesContext,344
options?: ModelsImagesOptions,345
): Promise<AssistantImages>;347
/** Classify structured state through the owning provider. Never rejects. */348
classify(349
model: ClassifierModel<ClassifierApi>,350
context: ClassifierContext,351
options?: ModelsClassifierOptions,352
): Promise<ClassifierResult>;353
}355
export interface MutableModels extends Models {356
/** Upsert/replace by provider.id. Provider ids are unique. */357
setProvider(provider: Provider): void;358
deleteProvider(id: string): void;359
clearProviders(): void;360
}362
export interface CreateModelsOptions {363
credentials?: CredentialStore;364
modelsStore?: ModelsStore;365
authContext?: AuthContext;366
}368
function mergeHeaders(369
base: ProviderHeaders | undefined,370
override: ProviderHeaders | undefined,371
): ProviderHeaders | undefined {372
if (!base && !override) return undefined;373
const merged = { ...base };374
for (const [name, value] of Object.entries(override ?? {})) {375
const lowerName = name.toLowerCase();376
for (const existingName of Object.keys(merged)) {377
if (existingName.toLowerCase() === lowerName) delete merged[existingName];378
}379
merged[name] = value;380
}381
return merged;382
}384
class ModelsImpl implements MutableModels {385
private providers = new Map<string, Provider>();386
private credentials: CredentialStore;387
private modelsStore: ModelsStore;388
private authContext: AuthContext;389
private refreshGenerations = new Map<string, number>();390
private refreshControllers = new Map<string, AbortController>();391
private publicationChains = new Map<string, Promise<unknown>>();393
constructor(options?: CreateModelsOptions) {394
this.credentials = options?.credentials ?? new InMemoryCredentialStore();395
this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore();396
this.authContext = options?.authContext ?? defaultAuthContext();397
}399
setProvider(provider: Provider): void {400
this.supersedeProviderRefresh(provider.id);401
this.providers.set(provider.id, provider);402
}404
deleteProvider(id: string): void {405
this.supersedeProviderRefresh(id);406
this.providers.delete(id);407
}409
clearProviders(): void {410
for (const id of new Set([...this.providers.keys(), ...this.refreshControllers.keys()])) {411
this.supersedeProviderRefresh(id);412
}413
this.providers.clear();414
}416
getProviders(): readonly Provider[] {417
return Array.from(this.providers.values());418
}420
getProvider(id: string): Provider | undefined {421
return this.providers.get(id);422
}424
getModels(provider?: string): readonly Model<Api>[] {425
if (provider !== undefined) {426
const entry = this.providers.get(provider);427
if (!entry) return [];428
try {429
return entry.getModels();430
} catch {431
return [];432
}433
}435
const models: Model<Api>[] = [];436
for (const entry of this.providers.values()) {437
try {438
models.push(...entry.getModels());439
} catch {440
// Best-effort: ill-behaved providers yield no models.441
}442
}443
return models;444
}446
getAllModels(provider?: string): readonly AnyModel[] {447
if (provider !== undefined) {448
const entry = this.providers.get(provider);449
if (!entry) return [];450
try {451
return entry.getAllModels?.() ?? entry.getModels();452
} catch {453
return [];454
}455
}457
const models: AnyModel[] = [];458
for (const entry of this.providers.values()) {459
try {460
models.push(...(entry.getAllModels?.() ?? entry.getModels()));461
} catch {462
// Best-effort: ill-behaved providers yield no models.463
}464
}465
return models;466
}468
getModelsOfType<TType extends ModelType>(type: TType, provider?: string): readonly ModelTypeMap[TType][] {469
return this.getAllModels(provider).filter((model): model is ModelTypeMap[TType] => isModelType(model, type));470
}472
getModel(provider: string, id: string): Model<Api> | undefined {473
return this.getModels(provider).find((model) => model.id === id);474
}476
getModelOfType<TType extends ModelType>(type: TType, provider: string, id: string): ModelTypeMap[TType] | undefined {477
return this.getModelsOfType(type, provider).find((model) => model.id === id);478
}480
private supersedeProviderRefresh(providerId: string): number {481
const generation = (this.refreshGenerations.get(providerId) ?? 0) + 1;482
this.refreshGenerations.set(providerId, generation);483
const previous = this.refreshControllers.get(providerId);484
if (previous) {485
this.refreshControllers.delete(providerId);486
previous.abort();487
}488
return generation;489
}491
private beginProviderRefresh(providerId: string): { generation: number; controller: AbortController } {492
const generation = this.supersedeProviderRefresh(providerId);493
const controller = new AbortController();494
this.refreshControllers.set(providerId, controller);495
return { generation, controller };496
}498
private publishProviderModels(499
providerId: string,500
generation: number,501
signal: AbortSignal,502
publication: ModelsPublication,503
): Promise<boolean> {504
const previous = this.publicationChains.get(providerId) ?? Promise.resolve();505
const queued = (async () => {506
await previous.catch(() => {});507
if (signal.aborted || this.refreshGenerations.get(providerId) !== generation) return false;509
if (publication.persist === null) {510
await this.modelsStore.delete(providerId, { signal });511
} else if (publication.persist !== undefined) {512
await this.modelsStore.write(providerId, structuredClone(publication.persist), { signal });513
}515
if (signal.aborted || this.refreshGenerations.get(providerId) !== generation) return false;516
publication.update?.();517
return true;518
})();519
const tail = queued.catch(() => {});520
this.publicationChains.set(providerId, tail);521
void tail.then(() => {522
if (this.publicationChains.get(providerId) === tail) this.publicationChains.delete(providerId);523
});524
return raceWithAbortSignal(queued, signal);525
}527
private async runProviderRefreshPhase(528
provider: Provider & Required<Pick<Provider, "refreshModels">>,529
credential: Credential | undefined,530
allowNetwork: boolean,531
force: boolean | undefined,532
generation: number,533
signal: AbortSignal,534
): Promise<void> {535
const stored = await this.modelsStore.read(provider.id, { signal });536
await provider.refreshModels({537
credential,538
stored: stored ? withKnownModelTypes(structuredClone(stored)) : undefined,539
publish: (publication) => this.publishProviderModels(provider.id, generation, signal, publication),540
allowNetwork,541
force: allowNetwork ? force : undefined,542
signal,543
});544
}546
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {547
const allowNetwork = options.allowNetwork ?? true;548
const callerSignal = operationSignal(options.signal);549
const errors = new Map<string, Error>();550
if (callerSignal.aborted) return { aborted: true, errors };551
const selected = options.providers ? new Set(options.providers) : undefined;552
const refreshable = Array.from(this.providers.values()).filter(553
(provider): provider is Provider & Required<Pick<Provider, "refreshModels">> =>554
provider.refreshModels !== undefined && (!selected || selected.has(provider.id)),555
);557
const refresh = Promise.all(558
refreshable.map(async (provider) => {559
const { generation, controller } = this.beginProviderRefresh(provider.id);560
const signal = AbortSignal.any([callerSignal, controller.signal]);561
const operation = (async () => {562
let storedCredential: Credential | undefined;563
let credentialError: unknown;564
try {565
storedCredential = await this.readCredential(provider.id, signal);566
} catch (error) {567
credentialError = error;568
}570
// Restore cached provider state before auth resolution or network access.571
await this.runProviderRefreshPhase(provider, storedCredential, false, undefined, generation, signal);572
if (credentialError !== undefined) throw credentialError;573
if (!allowNetwork || signal.aborted) return;575
const credential = await this.resolveRefreshCredential(provider, storedCredential, signal);576
if (!credential) return;577
await this.runProviderRefreshPhase(provider, credential, true, options.force, generation, signal);578
})();580
try {581
await raceWithAbortSignal(operation, signal);582
} catch (error) {583
if (!signal.aborted) {584
errors.set(585
provider.id,586
error instanceof Error587
? error588
: new ModelsError("model_source", `Model refresh failed for ${provider.id}`, { cause: error }),589
);590
}591
} finally {592
if (this.refreshControllers.get(provider.id) === controller) {593
this.refreshControllers.delete(provider.id);594
}595
}596
}),597
);599
try {600
await raceWithAbortSignal(refresh, callerSignal);601
} catch (error) {602
if (!callerSignal.aborted) throw error;603
}605
return { aborted: callerSignal.aborted, errors: new Map(errors) };606
}608
private async resolveRefreshCredential(609
provider: Provider,610
stored: Credential | undefined,611
signal: AbortSignal,612
): Promise<Credential | undefined> {613
if (stored?.type === "oauth") {614
const oauth = provider.auth.oauth;615
if (!oauth) return undefined;616
if (Date.now() < stored.expires) return stored;617
if (signal.aborted) return undefined;618
const post = await this.credentials.modify(619
provider.id,620
async (current) => {621
if (current?.type !== "oauth" || Date.now() < current.expires) return undefined;622
return oauth.refresh(current, signal);623
},624
{ signal },625
);626
return post?.type === "oauth" ? post : undefined;627
}629
const apiKey = provider.auth.apiKey;630
if (!apiKey) return undefined;631
const credential = stored?.type === "api_key" ? stored : undefined;632
const result = await apiKey.resolve({ ctx: this.authContext, credential, signal });633
if (!result) return undefined;634
return { type: "api_key", key: result.auth.apiKey, env: result.env };635
}637
private async readCredential(providerId: string, signal: AbortSignal): Promise<Credential | undefined> {638
try {639
return await this.credentials.read(providerId, { signal });640
} catch (error) {641
throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error });642
}643
}645
private async checkProviderAuth(646
provider: Provider,647
credential: Credential | undefined,648
signal: AbortSignal,649
): Promise<AuthCheck | undefined> {650
if (credential?.type === "oauth") {651
return provider.auth.oauth ? { source: "OAuth", type: "oauth" } : undefined;652
}653
const apiKey = provider.auth.apiKey;654
if (!apiKey) return undefined;655
if (apiKey.check) {656
try {657
return await apiKey.check({658
ctx: this.authContext,659
credential: credential?.type === "api_key" ? credential : undefined,660
signal,661
});662
} catch (error) {663
throw new ModelsError("auth", `API key auth check failed for provider ${provider.id}`, { cause: error });664
}665
}667
const resolution = await resolveProviderAuth(provider, this.credentials, this.authContext, { signal });668
return resolution ? { source: resolution.source, type: "api_key" } : undefined;669
}671
checkAuth(providerId: string, options?: AuthOperationOptions): Promise<AuthCheck | undefined> {672
const signal = operationSignal(options?.signal);673
const check = (async () => {674
signal.throwIfAborted();675
const provider = this.providers.get(providerId);676
if (!provider) return undefined;677
return this.checkProviderAuth(provider, await this.readCredential(providerId, signal), signal);678
})();679
return raceWithAbortSignal(check, signal);680
}682
private async getAuthenticatedProviders(providerId: string | undefined, signal: AbortSignal) {683
signal.throwIfAborted();684
const providers = providerId685
? [this.providers.get(providerId)].filter((entry) => entry !== undefined)686
: this.getProviders();687
const checks = await Promise.all(688
providers.map(async (provider) => {689
const credential = await this.readCredential(provider.id, signal);690
return { provider, credential, auth: await this.checkProviderAuth(provider, credential, signal) };691
}),692
);693
return checks.filter((entry) => entry.auth !== undefined);694
}696
getAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly Model<Api>[]> {697
const signal = operationSignal(options?.signal);698
const available = (async () => {699
const providers = await this.getAuthenticatedProviders(providerId, signal);700
return providers.flatMap(({ provider, credential }) => {701
const models = provider.getModels();702
return provider.filterModels?.(models, credential) ?? models;703
});704
})();705
return raceWithAbortSignal(available, signal);706
}708
async getAvailableOfType<TType extends ModelType>(709
type: TType,710
providerId?: string,711
options?: AuthOperationOptions,712
): Promise<readonly ModelTypeMap[TType][]> {713
return (await this.getAllAvailable(providerId, options)).filter((model): model is ModelTypeMap[TType] =>714
isModelType(model, type),715
);716
}718
getAllAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly AnyModel[]> {719
const signal = operationSignal(options?.signal);720
const available = (async () => {721
const providers = await this.getAuthenticatedProviders(providerId, signal);722
return providers.flatMap(({ provider, credential }) => {723
const models = provider.getAllModels?.() ?? provider.getModels();724
if (provider.filterAllModels) return provider.filterAllModels(models, credential);725
if (!provider.filterModels) return models;726
const availableChatIds = new Set(727
provider.filterModels(provider.getModels(), credential).map((model) => model.id),728
);729
return models.filter((model) => !isModelType(model, "chat") || availableChatIds.has(model.id));730
});731
})();732
return raceWithAbortSignal(available, signal);733
}735
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;736
getAuth(model: AnyModel, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;737
async getAuth(738
providerOrModel: string | AnyModel,739
overrides?: AuthResolutionOverrides,740
): Promise<AuthResult | undefined> {741
const signal = operationSignal(overrides?.signal);742
const providerId = typeof providerOrModel === "string" ? providerOrModel : providerOrModel.provider;743
const provider = this.providers.get(providerId);744
if (!provider) return undefined;745
const result = await resolveProviderAuth(provider, this.credentials, this.authContext, { ...overrides, signal });746
if (!result || typeof providerOrModel === "string" || !providerOrModel.headers) return result;747
return {748
...result,749
auth: {750
...result.auth,751
headers: mergeHeaders(result.auth.headers, providerOrModel.headers),752
},753
};754
}756
async login(757
providerId: string,758
type: AuthType,759
interaction: AuthInteraction,760
options?: LoginOptions,761
): Promise<Credential> {762
const signal = operationSignal(interaction.signal);763
signal.throwIfAborted();764
const provider = this.providers.get(providerId);765
if (!provider) throw new ModelsError("provider", `Unknown provider: ${providerId}`);766
const method = type === "oauth" ? provider.auth.oauth : provider.auth.apiKey;767
if (!method?.login) {768
throw new ModelsError("auth", `${provider.name} does not support ${type} login`);769
}770
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, options);771
const credential = await raceWithAbortSignal(loginOperation, signal);772
let mutationStarted = false;773
let markMutationStarted: (() => void) | undefined;774
const started = new Promise<void>((resolve) => {775
markMutationStarted = resolve;776
});777
const mutation = this.credentials.modify(778
providerId,779
async () => {780
mutationStarted = true;781
markMutationStarted?.();782
return credential;783
},784
{ signal },785
);786
void mutation.catch(() => {});787
try {788
await new Promise<void>((resolve, reject) => {789
const onAbort = () => {790
if (!mutationStarted) reject(signal.reason);791
};792
signal.addEventListener("abort", onAbort, { once: true });793
void Promise.race([started, mutation]).then(794
() => {795
signal.removeEventListener("abort", onAbort);796
resolve();797
},798
(error: unknown) => {799
signal.removeEventListener("abort", onAbort);800
reject(error);801
},802
);803
if (signal.aborted) onAbort();804
});805
await mutation;806
} catch (error) {807
signal.throwIfAborted();808
throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });809
}810
return credential;811
}813
async logout(providerId: string, options?: AuthOperationOptions): Promise<void> {814
const signal = operationSignal(options?.signal);815
signal.throwIfAborted();816
try {817
await this.credentials.delete(providerId, { signal });818
} catch (error) {819
signal.throwIfAborted();820
throw new ModelsError("auth", `Credential store delete failed for ${providerId}`, { cause: error });821
}822
}824
private requireProvider(model: AnyModel): Provider {825
const provider = this.providers.get(model.provider);826
if (!provider) {827
throw new ModelsError("provider", `Unknown provider: ${model.provider}`);828
}829
return provider;830
}832
private requireChatProvider(model: Model<Api>): Provider {833
assertChatModel(model);834
return this.requireProvider(model);835
}837
private async applyAuth<838
TModel extends AnyModel,839
TOptions extends ProviderRequestOptions<TModel> & ModelsRequestTransforms,840
>(841
model: TModel,842
options: TOptions | undefined,843
): Promise<{844
requestModel: TModel;845
requestOptions: Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;846
}> {847
this.requireProvider(model);848
const resolution = await this.getAuth(model, {849
apiKey: options?.apiKey,850
env: options?.env,851
signal: options?.signal,852
});853
if (!resolution) {854
throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);855
}856
const auth = resolution.auth;858
// Explicit request options win per-field; the Models-only transform runs last.859
const apiKey = options?.apiKey ?? auth.apiKey;860
let headers = mergeHeaders(auth.headers, options?.headers);861
if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});862
const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;863
const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;864
const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};865
const requestOptions = { ...providerOptions, apiKey, headers, env } as Omit<TOptions, "transformHeaders"> &866
ProviderRequestOptions<TModel>;868
return { requestModel, requestOptions };869
}871
stream<TApi extends Api>(872
model: Model<TApi>,873
context: Context,874
options?: ModelsApiStreamOptions<TApi>,875
): AssistantMessageEventStream {876
const transcript = normalizeContext(context);877
return lazyStream(model, async () => {878
const provider = this.requireChatProvider(model);879
const { requestModel, requestOptions } = await this.applyAuth(880
model,881
options as ModelsApiStreamOptions<Api> | undefined,882
);883
return provider.stream(requestModel, transcript, requestOptions as ApiStreamOptions<TApi>);884
});885
}887
async complete<TApi extends Api>(888
model: Model<TApi>,889
context: Context,890
options?: ModelsApiStreamOptions<TApi>,891
): Promise<AssistantMessage> {892
return this.stream(model, context, options).result();893
}895
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream {896
const transcript = normalizeContext(context);897
return lazyStream(model, async () => {898
const provider = this.requireChatProvider(model);899
const { requestModel, requestOptions } = await this.applyAuth(model, options);900
return provider.streamSimple(requestModel, transcript, requestOptions as SimpleStreamOptions);901
});902
}904
async completeSimple(905
model: Model<Api>,906
context: Context,907
options?: ModelsSimpleStreamOptions,908
): Promise<AssistantMessage> {909
return this.streamSimple(model, context, options).result();910
}912
streamDeferred(913
model: Model<Api>,914
handle: DeferredHandle,915
options?: ModelsDeferredFetchOptions,916
): AssistantMessageEventStream {917
return lazyStream(model, async () => {918
const provider = this.requireChatProvider(model);919
if (!provider.fetchDeferred) {920
throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);921
}922
const { requestModel, requestOptions } = await this.applyAuth(model, options);923
return provider.fetchDeferred(requestModel, handle, requestOptions as DeferredFetchOptions);924
});925
}927
async fetchDeferred(928
model: Model<Api>,929
handle: DeferredHandle,930
options?: ModelsDeferredFetchOptions,931
): Promise<AssistantMessage> {932
return this.streamDeferred(model, handle, options).result();933
}935
async cancelDeferred(936
model: Model<Api>,937
handle: DeferredHandle,938
options?: ModelsDeferredCancelOptions,939
): Promise<void> {940
const provider = this.requireChatProvider(model);941
if (!provider.cancelDeferred) {942
throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);943
}944
const { requestModel, requestOptions } = await this.applyAuth(model, options);945
await provider.cancelDeferred(requestModel, handle, requestOptions);946
}948
async generateImages(949
model: ImageModel<ImageApi>,950
context: ImagesContext,951
options?: ModelsImagesOptions,952
): Promise<AssistantImages> {953
try {954
assertImageModel(model);955
const provider = this.requireProvider(model);956
if (!provider.generateImages) {957
throw new ModelsError("provider", `Provider ${model.provider} does not support image generation`);958
}959
const { requestModel, requestOptions } = await this.applyAuth(model, options);960
return await provider.generateImages(requestModel, context, requestOptions);961
} catch (error) {962
return imageErrorResult(model, error, options?.signal?.aborted);963
}964
}966
async classify(967
model: ClassifierModel<ClassifierApi>,968
context: ClassifierContext,969
options?: ModelsClassifierOptions,970
): Promise<ClassifierResult> {971
try {972
assertClassifierModel(model);973
const provider = this.requireProvider(model);974
if (!provider.classify) {975
throw new ModelsError("provider", `Provider ${model.provider} does not support classification`);976
}977
const { requestModel, requestOptions } = await this.applyAuth(model, options);978
return await provider.classify(requestModel, context, requestOptions);979
} catch (error) {980
return classifierErrorResult(model, error, options?.signal?.aborted);981
}982
}983
}985
export function createModels(options?: CreateModelsOptions): MutableModels {986
return new ModelsImpl(options);987
}989
export interface CreateProviderOptions<TApi extends Api = Api> {990
id: string;991
/** Display name. Default: `id`. */992
name?: string;993
baseUrl?: string;994
headers?: ProviderHeaders;995
/** Required — every provider has auth semantics, even ambient/keyless ones. */996
auth: ProviderAuth;997
/**998
* Static baseline models of every type (empty for purely dynamic providers).999
* Models without `type` are chat models.1000
*/1001
models: readonly ProviderModel<TApi>[];1002
/**1003
* Fetch a dynamic model overlay of every type. createProvider restores and1004
* publishes it transactionally and drops models of unknown types.1005
*/1006
fetchModels?: (context: RefreshModelsContext) => Promise<readonly ProviderModel<TApi>[]>;1007
/** Credential-specific chat model availability. See `Provider.filterModels`. */1008
filterModels?: (models: readonly Model<TApi>[], credential: Credential | undefined) => readonly Model<TApi>[];1009
/** Credential-specific availability across every model type. See `Provider.filterAllModels`. */1010
filterAllModels?: (1011
models: readonly ProviderModel<TApi>[],1012
credential: Credential | undefined,1013
) => readonly ProviderModel<TApi>[];1014
/**1015
* Chat implementation: a single one for all chat models, or a map keyed by1016
* `model.api` for mixed-API providers. Optional when `images` or1017
* `classifiers` is given.1018
*/1019
api?: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;1020
/** Image-generation implementations keyed by `model.api`. */1021
images?: Partial<Record<ImageApi, ProviderImages>>;1022
/** Classifier implementations keyed by `model.api`. */1023
classifiers?: Partial<Record<ClassifierApi, ProviderClassifier>>;1024
}1026
/**1027
* Builds a provider from parts. Built-in provider factories and models.json1028
* custom providers both go through this. A single `api` streams all chat1029
* models; an `api` map dispatches on `model.api`, and a model whose api has1030
* no entry produces a stream error. One-shot operation maps dispatch on1031
* `model.api` the same way. At least one concrete implementation across1032
* `api`/`images`/`classifiers` is required; empty maps are rejected.1033
*/1034
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {1035
const single =1036
input.api && typeof (input.api as ProviderStreams).stream === "function"1037
? (input.api as ProviderStreams)1038
: undefined;1039
const byApi = single || !input.api ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);1040
const images = input.images as Partial<Record<string, ProviderImages>> | undefined;1041
const classifiers = input.classifiers as Partial<Record<string, ProviderClassifier>> | undefined;1042
const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry) => entry !== undefined);1043
const imageImplementations = Object.values(images ?? {}).filter((entry) => entry !== undefined);1044
const classifierImplementations = Object.values(classifiers ?? {}).filter((entry) => entry !== undefined);1045
if (streams.length === 0 && imageImplementations.length === 0 && classifierImplementations.length === 0) {1046
throw new Error(`Provider ${input.id}: at least one of "api", "images", or "classifiers" is required.`);1047
}1049
const baselineModels = input.models;1050
let dynamicModels: readonly ProviderModel<TApi>[] = [];1051
const fetchModels = input.fetchModels;1052
const currentModels = (): readonly ProviderModel<TApi>[] => {1053
const merged = [...baselineModels];1054
for (const model of dynamicModels) {1055
const index = merged.findIndex(1056
(entry) => getModelType(entry) === getModelType(model) && entry.id === model.id,1057
);1058
if (index >= 0) merged[index] = model;1059
else merged.push(model);1060
}1061
return merged;1062
};1063
const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];1065
const dispatch = (1066
model: Model<Api>,1067
run: (streams: ProviderStreams) => AssistantMessageEventStream,1068
): AssistantMessageEventStream => {1069
const streams = apiFor(model);1070
if (!streams) {1071
return lazyStream(model, async () => {1072
throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`);1073
});1074
}1075
return run(streams);1076
};1078
const provider: Provider<TApi> = {1079
id: input.id,1080
name: input.name ?? input.id,1081
baseUrl: input.baseUrl,1082
headers: input.headers,1083
auth: input.auth,1084
getModels: () => currentModels().filter((model): model is Model<TApi> => isModelType(model, "chat")),1085
getAllModels: currentModels,1086
refreshModels: fetchModels1087
? async (context) => {1088
if (context.stored) {1089
const restored = context.stored.models1090
.filter((model) => model.provider === input.id)1091
.map((model) => model as ProviderModel<TApi>);1092
if (1093
!(await context.publish({1094
update: () => {1095
dynamicModels = restored;1096
},1097
}))1098
) {1099
return;1100
}1101
}1102
if (!context.allowNetwork || context.signal.aborted) return;1103
const fetched = await fetchModels(context);1104
if (context.signal.aborted) return;1105
const refreshed = fetched.filter(hasKnownModelType);1106
await context.publish({1107
persist: { models: refreshed, checkedAt: Date.now() },1108
update: () => {1109
dynamicModels = refreshed;1110
},1111
});1112
}1113
: undefined,1114
filterModels: input.filterModels,1115
filterAllModels: input.filterAllModels,1116
stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),1117
streamSimple: (model, context, options) =>1118
dispatch(model, (streams) => streams.streamSimple(model, context, options)),1119
};1121
if (streams.some((entry) => entry.fetchDeferred !== undefined)) {1122
provider.fetchDeferred = (model, handle, options) =>1123
lazyStream(model, async () => {1124
const implementation = apiFor(model);1125
if (!implementation?.fetchDeferred) {1126
throw new ModelsError(1127
"provider",1128
`Provider ${input.id} does not support deferred responses for "${model.api}"`,1129
);1130
}1131
return implementation.fetchDeferred(model, handle, options);1132
});1133
}1134
if (streams.some((entry) => entry.cancelDeferred !== undefined)) {1135
provider.cancelDeferred = async (model, handle, options) => {1136
const implementation = apiFor(model);1137
if (!implementation?.cancelDeferred) {1138
throw new ModelsError(1139
"provider",1140
`Provider ${input.id} cannot cancel deferred responses for "${model.api}"`,1141
);1142
}1143
await implementation.cancelDeferred(model, handle, options);1144
};1145
}1146
if (images && imageImplementations.length > 0) {1147
provider.generateImages = async (model, context, options) => {1148
const implementation = images[model.api];1149
if (!implementation) {1150
return imageErrorResult(1151
model,1152
new ModelsError(1153
"provider",1154
`Provider ${input.id} has no image generation implementation for "${model.api}"`,1155
),1156
);1157
}1158
return implementation.generateImages(model, context, options);1159
};1160
}1161
if (classifiers && classifierImplementations.length > 0) {1162
provider.classify = async (model, context, options) => {1163
const implementation = classifiers[model.api];1164
if (!implementation) {1165
return classifierErrorResult(1166
model,1167
new ModelsError("provider", `Provider ${input.id} has no classifier implementation for "${model.api}"`),1168
);1169
}1170
return implementation.classify(model, context, options);1171
};1172
}1174
return provider;1175
}1177
/**1178
* Runtime-checked narrowing for dynamically looked-up models:1179
*1180
* ```ts1181
* const model = models.getModel("anthropic", "claude-opus-4-7");1182
* if (model && hasApi(model, "anthropic-messages")) {1183
* // model: Model<"anthropic-messages">, stream options fully typed1184
* }1185
* ```1186
*1187
* Non-chat models never match, even when their api id equals `api`.1188
*/1189
export function hasApi<TApi extends Api>(model: AnyModel, api: TApi): model is Model<TApi> {1190
return isModelType(model, "chat") && model.api === api;1191
}1193
export function calculateCost(model: AnyModel, usage: Usage): Usage["cost"] {1194
const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;1195
let rates: ModelCostRates = model.cost;1196
let matchedThreshold = -1;1197
for (const tier of model.cost.tiers ?? []) {1198
if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) {1199
rates = tier;1200
matchedThreshold = tier.inputTokensAbove;1201
}1202
}1204
// Anthropic charges 2x base input for 1h cache writes.1205
const longWrite = usage.cacheWrite1h ?? 0;1206
const shortWrite = usage.cacheWrite - longWrite;1207
usage.cost.input = (rates.input / 1000000) * usage.input;1208
usage.cost.output = (rates.output / 1000000) * usage.output;1209
usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead;1210
usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000;1211
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;1212
return usage.cost;1213
}1215
const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];1217
export function getSupportedThinkingLevels<TApi extends Api>(model: Model<TApi>): ModelThinkingLevel[] {1218
if (!model.reasoning) return ["off"];1220
return EXTENDED_THINKING_LEVELS.filter((level) => {1221
const mapped = model.thinkingLevelMap?.[level];1222
if (mapped === null) return false;1223
if (level === "xhigh" || level === "max") return mapped !== undefined;1224
return true;1225
});1226
}1228
export function clampThinkingLevel<TApi extends Api>(1229
model: Model<TApi>,1230
level: ModelThinkingLevel,1231
): ModelThinkingLevel {1232
const availableLevels = getSupportedThinkingLevels(model);1233
if (availableLevels.includes(level)) return level;1235
const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level);1236
if (requestedIndex === -1) return availableLevels[0] ?? "off";1238
for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) {1239
const candidate = EXTENDED_THINKING_LEVELS[i];1240
if (availableLevels.includes(candidate)) return candidate;1241
}1242
for (let i = requestedIndex - 1; i >= 0; i--) {1243
const candidate = EXTENDED_THINKING_LEVELS[i];1244
if (availableLevels.includes(candidate)) return candidate;1245
}1246
return availableLevels[0] ?? "off";1247
}1249
/**1250
* Check if two models are equal by comparing their type, id, and provider.1251
* Returns false if either model is null or undefined.1252
*/1253
export function modelsAreEqual(a: AnyModel | null | undefined, b: AnyModel | null | undefined): boolean {1254
if (!a || !b) return false;1255
return getModelType(a) === getModelType(b) && a.id === b.id && a.provider === b.provider;1256
}