chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -1,7 +1,12 @@
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 AuthResolutionOverrides,
ModelsError,
refreshStoredOAuthCredential,
resolveProviderAuth,
} from "./auth/resolve.ts";
import type {
AuthCheck,
AuthContext,
@@ -11,30 +16,59 @@ import type {
AuthType,
Credential,
CredentialStore,
LoginOptions,
ProviderAuth,
} from "./auth/types.ts";
import { InMemoryModelsStore, type ModelsStore, type ModelsStoreEntry } from "./models-store.ts";
import type {
AnyModel,
Api,
ApiStreamOptions,
AssistantImages,
AssistantMessage,
AssistantMessageEventStream,
ClassifierApi,
ClassifierContext,
ClassifierModel,
ClassifierOptions,
ClassifierResult,
Context,
DeferredCancelOptions,
DeferredFetchOptions,
DeferredHandle,
ImageApi,
ImageModel,
ImagesContext,
ImagesOptions,
Model,
ModelCostRates,
ModelThinkingLevel,
ModelType,
ModelTypeMap,
ProviderClassifier,
ProviderHeaders,
ProviderImages,
ProviderRequestOptions,
ProviderStreams,
SimpleStreamOptions,
TranscriptContext,
Usage,
} from "./types.ts";
import { operationSignal, raceWithAbortSignal } from "./utils/abort.ts";
import {
assertChatModel,
assertClassifierInputSupported,
assertClassifierModel,
assertImageModel,
classifierErrorResult,
getModelType,
imageErrorResult,
isModelType,
} from "./utils/model-operations.ts";
import { normalizeContext } from "./utils/transcript.ts";
export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";
export { getModelType, isModelType } from "./utils/model-operations.ts";
export interface ModelsPublication {
/** Provider-selected persisted catalog. Omit to leave storage unchanged; null deletes it. */
@@ -59,6 +93,11 @@ export interface RefreshModelsContext {
force?: boolean;
/** Always present, including when the public refresh caller omits its optional signal. */
signal: AbortSignal;
/**
* Base URL of the registered provider, including configuration overrides that wrappers apply
* outside the provider receiving this context.
*/
baseUrl?: string;
}
export interface ModelsRefreshOptions {
@@ -84,15 +123,34 @@ export type ModelsApiStreamOptions<TApi extends Api> = ApiStreamOptions<TApi> &
export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsRequestTransforms;
export type ModelsDeferredFetchOptions = DeferredFetchOptions & ModelsRequestTransforms;
export type ModelsDeferredCancelOptions = DeferredCancelOptions & ModelsRequestTransforms;
export type ModelsImagesOptions = ImagesOptions & ModelsRequestTransforms;
export type ModelsClassifierOptions = ClassifierOptions & ModelsRequestTransforms;
const KNOWN_MODEL_TYPES: Record<ModelType, true> = { chat: true, image: true, classifier: true };
/** Models from stores and remote sources may have types that only newer versions know. */
function hasKnownModelType(model: AnyModel): boolean {
return Object.hasOwn(KNOWN_MODEL_TYPES, getModelType(model));
}
/** Drops stored models whose type this version does not know. */
function withKnownModelTypes(entry: ModelsStoreEntry): ModelsStoreEntry {
return { ...entry, models: entry.models.filter(hasKnownModelType) };
}
/** Any model a provider with chat APIs `TApi` can list. */
type ProviderModel<TApi extends Api> = Model<TApi> | ImageModel<ImageApi> | ClassifierModel<ClassifierApi>;
/**
* A provider is the concrete runtime unit. It owns id/name/base metadata,
* auth methods, model listing, and stream behavior.
* auth methods, model listing, and the operations its models support
* (streaming, image generation, classification).
*
* `TApi` lets concrete provider factories declare which APIs their models
* `TApi` lets concrete provider factories declare which chat 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<Api>`.
* giving typed chat model lists to direct factory users. Other model types use
* their operation-specific API unions. Inside a `Models` collection providers
* are held as `Provider<Api>`.
*/
export interface Provider<TApi extends Api = Api> {
readonly id: string;
@@ -111,13 +169,21 @@ export interface Provider<TApi extends Api = Api> {
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
* Current known chat 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<TApi>[];
/**
* Current known models of every type, sync, with the same contract as
* `getModels()`. Providers with only chat models may omit it; `Models` then
* uses `getModels()`. Model ids are unique within each type; one upstream
* model may have separate entries for different operations.
*/
getAllModels?(): readonly ProviderModel<TApi>[];
/**
* Dynamic providers only: restore `context.stored` and optionally fetch a newer list using
* the effective credential. Implementations retain their previous list on failure, publish
@@ -128,47 +194,89 @@ export interface Provider<TApi extends Api = Api> {
/**
* Optional provider policy for credential-specific model availability.
* `getModels()` remains the complete synchronous catalog; `Models.getAvailable()`
* `getModels()` remains the complete synchronous chat catalog; `Models.getAvailable()`
* applies this filter after confirming that provider auth is configured.
*/
filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
/**
* Optional credential-specific availability policy across every model type.
* Without it, `Models.getAllAvailable()` applies `filterModels` to chat models
* and keeps every other model.
*/
filterAllModels?(
models: readonly ProviderModel<TApi>[],
credential: Credential | undefined,
): readonly ProviderModel<TApi>[];
/** Stream a normalized transcript. `Models` normalizes the caller's `Context` before dispatching here. */
stream<T extends TApi>(
model: Model<T>,
context: Context,
context: TranscriptContext,
options?: ApiStreamOptions<T>,
): AssistantMessageEventStream;
streamSimple(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
streamSimple(
model: Model<TApi>,
context: TranscriptContext,
options?: SimpleStreamOptions,
): AssistantMessageEventStream;
fetchDeferred?(
model: Model<TApi>,
handle: DeferredHandle,
options?: DeferredFetchOptions,
): AssistantMessageEventStream;
cancelDeferred?(model: Model<TApi>, handle: DeferredHandle, options?: DeferredCancelOptions): Promise<void>;
/** Present when the provider supports dedicated image models. Never rejects. */
generateImages?(
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: ImagesOptions,
): Promise<AssistantImages>;
/** Present when the provider supports structured classifier models. Never rejects. */
classify?(
model: ClassifierModel<ClassifierApi>,
context: ClassifierContext,
options?: ClassifierOptions,
): Promise<ClassifierResult>;
}
/**
* Runtime collection of providers plus auth application and stream
* convenience. Providers own stream behavior; `Models` resolves auth and
* Runtime collection of providers plus auth application and request
* convenience. Providers own request behavior; `Models` resolves auth and
* delegates each request to the provider that owns the model.
*
* Read accessors come in three flavors: the unqualified ones (`getModels`,
* `getModel`, `getAvailable`) return chat models, the `*OfType` accessors
* return one model type, and `getAllModels`/`getAllAvailable` return every type.
*/
export interface Models {
getProviders(): readonly Provider[];
getProvider(id: string): Provider | undefined;
/**
* Sync read of last-known models from one provider or all providers.
* Sync read of last-known chat models from one provider or all providers.
* Best-effort: a provider whose `getModels()` throws yields no models.
*/
getModels(provider?: string): readonly Model<Api>[];
/**
* Sync runtime model lookup against last-known lists. Dynamic model lists
* Sync runtime chat model lookup against last-known lists. Dynamic model lists
* are typed as `Model<Api>`; narrow with the `hasApi()` type guard.
*/
getModel(provider: string, id: string): Model<Api> | undefined;
/** Sync read of last-known models of one type from one provider or all providers. */
getModelsOfType<TType extends ModelType>(type: TType, provider?: string): readonly ModelTypeMap[TType][];
/** Sync runtime lookup of a model of one type against last-known lists. */
getModelOfType<TType extends ModelType>(type: TType, provider: string, id: string): ModelTypeMap[TType] | undefined;
/** Sync read of last-known models of every type from one provider or all providers. */
getAllModels(provider?: string): readonly AnyModel[];
/**
* Refresh selected configured dynamic providers concurrently (all when `providers` is omitted).
* Provider errors and cancellation are returned without rejecting; static, unknown, and
@@ -179,9 +287,19 @@ export interface Models {
/** Check whether a provider has complete auth configuration without refreshing OAuth. */
checkAuth(providerId: string, options?: AuthOperationOptions): Promise<AuthCheck | undefined>;
/** Return models whose providers have complete auth configuration. */
/** Return chat models whose providers have complete auth configuration. */
getAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly Model<Api>[]>;
/** Return models of one type whose providers have complete auth configuration. */
getAvailableOfType<TType extends ModelType>(
type: TType,
providerId?: string,
options?: AuthOperationOptions,
): Promise<readonly ModelTypeMap[TType][]>;
/** Return models of every type whose providers have complete auth configuration. */
getAllAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly AnyModel[]>;
/**
* 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.
@@ -192,10 +310,10 @@ export interface Models {
* surface rejections as stream errors.
*/
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: Model<Api>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: AnyModel, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
/** Run a provider-owned login flow and persist its returned credential. */
login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential>;
login(providerId: string, type: AuthType, interaction: AuthInteraction, options?: LoginOptions): Promise<Credential>;
/** Remove the stored credential for a provider. */
logout(providerId: string, options?: AuthOperationOptions): Promise<void>;
@@ -225,6 +343,24 @@ export interface Models {
options?: ModelsDeferredFetchOptions,
): Promise<AssistantMessage>;
cancelDeferred(model: Model<Api>, handle: DeferredHandle, options?: ModelsDeferredCancelOptions): Promise<void>;
/**
* Generate images through the owning provider with auth resolved like
* `stream()`. Never rejects: unknown providers, unconfigured auth, and
* providers without `generateImages` return an error `AssistantImages`.
*/
generateImages(
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: ModelsImagesOptions,
): Promise<AssistantImages>;
/** Classify structured state through the owning provider. Never rejects. */
classify(
model: ClassifierModel<ClassifierApi>,
context: ClassifierContext,
options?: ModelsClassifierOptions,
): Promise<ClassifierResult>;
}
export interface MutableModels extends Models {
@@ -318,10 +454,40 @@ class ModelsImpl implements MutableModels {
return models;
}
getAllModels(provider?: string): readonly AnyModel[] {
if (provider !== undefined) {
const entry = this.providers.get(provider);
if (!entry) return [];
try {
return entry.getAllModels?.() ?? entry.getModels();
} catch {
return [];
}
}
const models: AnyModel[] = [];
for (const entry of this.providers.values()) {
try {
models.push(...(entry.getAllModels?.() ?? entry.getModels()));
} catch {
// Best-effort: ill-behaved providers yield no models.
}
}
return models;
}
getModelsOfType<TType extends ModelType>(type: TType, provider?: string): readonly ModelTypeMap[TType][] {
return this.getAllModels(provider).filter((model): model is ModelTypeMap[TType] => isModelType(model, type));
}
getModel(provider: string, id: string): Model<Api> | undefined {
return this.getModels(provider).find((model) => model.id === id);
}
getModelOfType<TType extends ModelType>(type: TType, provider: string, id: string): ModelTypeMap[TType] | undefined {
return this.getModelsOfType(type, provider).find((model) => model.id === id);
}
private supersedeProviderRefresh(providerId: string): number {
const generation = (this.refreshGenerations.get(providerId) ?? 0) + 1;
this.refreshGenerations.set(providerId, generation);
@@ -380,11 +546,12 @@ class ModelsImpl implements MutableModels {
const stored = await this.modelsStore.read(provider.id, { signal });
await provider.refreshModels({
credential,
stored: stored ? structuredClone(stored) : undefined,
stored: stored ? withKnownModelTypes(structuredClone(stored)) : undefined,
publish: (publication) => this.publishProviderModels(provider.id, generation, signal, publication),
allowNetwork,
force: allowNetwork ? force : undefined,
signal,
baseUrl: provider.baseUrl,
});
}
@@ -460,15 +627,15 @@ class ModelsImpl implements MutableModels {
if (!oauth) return undefined;
if (Date.now() < stored.expires) return stored;
if (signal.aborted) return undefined;
const post = await this.credentials.modify(
// A refresh that has started survives cancellation or a superseding model refresh, so a
// rotated refresh token is always persisted. A newer refresh then sees the fresh credential.
return refreshStoredOAuthCredential(
this.credentials,
provider.id,
async (current) => {
if (current?.type !== "oauth" || Date.now() < current.expires) return undefined;
return oauth.refresh(current, signal);
},
{ signal },
oauth,
(current) => Date.now() >= current.expires,
signal,
);
return post?.type === "oauth" ? post : undefined;
}
const apiKey = provider.auth.apiKey;
@@ -524,21 +691,25 @@ class ModelsImpl implements MutableModels {
return raceWithAbortSignal(check, signal);
}
private async getAuthenticatedProviders(providerId: string | undefined, signal: AbortSignal) {
signal.throwIfAborted();
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, signal);
return { provider, credential, auth: await this.checkProviderAuth(provider, credential, signal) };
}),
);
return checks.filter((entry) => entry.auth !== undefined);
}
getAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly Model<Api>[]> {
const signal = operationSignal(options?.signal);
const available = (async () => {
signal.throwIfAborted();
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, signal);
return { provider, credential, auth: await this.checkProviderAuth(provider, credential, signal) };
}),
);
return checks.flatMap(({ provider, credential, auth }) => {
if (!auth) return [];
const providers = await this.getAuthenticatedProviders(providerId, signal);
return providers.flatMap(({ provider, credential }) => {
const models = provider.getModels();
return provider.filterModels?.(models, credential) ?? models;
});
@@ -546,10 +717,37 @@ class ModelsImpl implements MutableModels {
return raceWithAbortSignal(available, signal);
}
async getAvailableOfType<TType extends ModelType>(
type: TType,
providerId?: string,
options?: AuthOperationOptions,
): Promise<readonly ModelTypeMap[TType][]> {
return (await this.getAllAvailable(providerId, options)).filter((model): model is ModelTypeMap[TType] =>
isModelType(model, type),
);
}
getAllAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly AnyModel[]> {
const signal = operationSignal(options?.signal);
const available = (async () => {
const providers = await this.getAuthenticatedProviders(providerId, signal);
return providers.flatMap(({ provider, credential }) => {
const models = provider.getAllModels?.() ?? provider.getModels();
if (provider.filterAllModels) return provider.filterAllModels(models, credential);
if (!provider.filterModels) return models;
const availableChatIds = new Set(
provider.filterModels(provider.getModels(), credential).map((model) => model.id),
);
return models.filter((model) => !isModelType(model, "chat") || availableChatIds.has(model.id));
});
})();
return raceWithAbortSignal(available, signal);
}
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: Model<Api>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: AnyModel, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
async getAuth(
providerOrModel: string | Model<Api>,
providerOrModel: string | AnyModel,
overrides?: AuthResolutionOverrides,
): Promise<AuthResult | undefined> {
const signal = operationSignal(overrides?.signal);
@@ -567,7 +765,12 @@ class ModelsImpl implements MutableModels {
};
}
async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
async login(
providerId: string,
type: AuthType,
interaction: AuthInteraction,
options?: LoginOptions,
): Promise<Credential> {
const signal = operationSignal(interaction.signal);
signal.throwIfAborted();
const provider = this.providers.get(providerId);
@@ -576,7 +779,7 @@ class ModelsImpl implements MutableModels {
if (!method?.login) {
throw new ModelsError("auth", `${provider.name} does not support ${type} login`);
}
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal });
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, options);
const credential = await raceWithAbortSignal(loginOperation, signal);
let mutationStarted = false;
let markMutationStarted: (() => void) | undefined;
@@ -630,7 +833,7 @@ class ModelsImpl implements MutableModels {
}
}
private requireProvider(model: Model<Api>): Provider {
private requireProvider(model: AnyModel): Provider {
const provider = this.providers.get(model.provider);
if (!provider) {
throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
@@ -638,12 +841,20 @@ class ModelsImpl implements MutableModels {
return provider;
}
private async applyAuth<TOptions extends ProviderRequestOptions & ModelsRequestTransforms>(
model: Model<Api>,
private requireChatProvider(model: Model<Api>): Provider {
assertChatModel(model);
return this.requireProvider(model);
}
private async applyAuth<
TModel extends AnyModel,
TOptions extends ProviderRequestOptions<TModel> & ModelsRequestTransforms,
>(
model: TModel,
options: TOptions | undefined,
): Promise<{
requestModel: Model<Api>;
requestOptions: Omit<TOptions, "transformHeaders"> & ProviderRequestOptions;
requestModel: TModel;
requestOptions: Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;
}> {
this.requireProvider(model);
const resolution = await this.getAuth(model, {
@@ -661,10 +872,10 @@ class ModelsImpl implements MutableModels {
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 requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};
const requestOptions = { ...providerOptions, apiKey, headers, env } as Omit<TOptions, "transformHeaders"> &
ProviderRequestOptions;
ProviderRequestOptions<TModel>;
return { requestModel, requestOptions };
}
@@ -674,13 +885,14 @@ class ModelsImpl implements MutableModels {
context: Context,
options?: ModelsApiStreamOptions<TApi>,
): AssistantMessageEventStream {
const transcript = normalizeContext(context);
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const provider = this.requireChatProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(
model,
options as ModelsApiStreamOptions<Api> | undefined,
);
return provider.stream(requestModel as Model<TApi>, context, requestOptions as ApiStreamOptions<TApi>);
return provider.stream(requestModel, transcript, requestOptions as ApiStreamOptions<TApi>);
});
}
@@ -693,10 +905,11 @@ class ModelsImpl implements MutableModels {
}
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream {
const transcript = normalizeContext(context);
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const provider = this.requireChatProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return provider.streamSimple(requestModel, context, requestOptions as SimpleStreamOptions);
return provider.streamSimple(requestModel, transcript, requestOptions as SimpleStreamOptions);
});
}
@@ -714,7 +927,7 @@ class ModelsImpl implements MutableModels {
options?: ModelsDeferredFetchOptions,
): AssistantMessageEventStream {
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const provider = this.requireChatProvider(model);
if (!provider.fetchDeferred) {
throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);
}
@@ -736,13 +949,50 @@ class ModelsImpl implements MutableModels {
handle: DeferredHandle,
options?: ModelsDeferredCancelOptions,
): Promise<void> {
const provider = this.requireProvider(model);
const provider = this.requireChatProvider(model);
if (!provider.cancelDeferred) {
throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);
}
const { requestModel, requestOptions } = await this.applyAuth(model, options);
await provider.cancelDeferred(requestModel, handle, requestOptions);
}
async generateImages(
model: ImageModel<ImageApi>,
context: ImagesContext,
options?: ModelsImagesOptions,
): Promise<AssistantImages> {
try {
assertImageModel(model);
const provider = this.requireProvider(model);
if (!provider.generateImages) {
throw new ModelsError("provider", `Provider ${model.provider} does not support image generation`);
}
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return await provider.generateImages(requestModel, context, requestOptions);
} catch (error) {
return imageErrorResult(model, error, options?.signal?.aborted);
}
}
async classify(
model: ClassifierModel<ClassifierApi>,
context: ClassifierContext,
options?: ModelsClassifierOptions,
): Promise<ClassifierResult> {
try {
assertClassifierModel(model);
assertClassifierInputSupported(model, context);
const provider = this.requireProvider(model);
if (!provider.classify) {
throw new ModelsError("provider", `Provider ${model.provider} does not support classification`);
}
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return await provider.classify(requestModel, context, requestOptions);
} catch (error) {
return classifierErrorResult(model, error, options?.signal?.aborted);
}
}
}
export function createModels(options?: CreateModelsOptions): MutableModels {
@@ -757,38 +1007,72 @@ export interface CreateProviderOptions<TApi extends Api = Api> {
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<TApi>[];
/** Fetch a dynamic model overlay. createProvider restores and publishes it transactionally. */
fetchModels?: (context: RefreshModelsContext) => Promise<readonly Model<TApi>[]>;
/**
* Static baseline models of every type (empty for purely dynamic providers).
* Models without `type` are chat models.
*/
models: readonly ProviderModel<TApi>[];
/**
* Fetch a dynamic model overlay of every type. createProvider restores and
* publishes it transactionally and drops models of unknown types.
*/
fetchModels?: (context: RefreshModelsContext) => Promise<readonly ProviderModel<TApi>[]>;
/** Credential-specific chat model availability. See `Provider.filterModels`. */
filterModels?: (models: readonly Model<TApi>[], credential: Credential | undefined) => readonly Model<TApi>[];
/** Single implementation, or map keyed by `model.api` for mixed-API providers. */
api: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;
/** Credential-specific availability across every model type. See `Provider.filterAllModels`. */
filterAllModels?: (
models: readonly ProviderModel<TApi>[],
credential: Credential | undefined,
) => readonly ProviderModel<TApi>[];
/**
* Chat implementation: a single one for all chat models, or a map keyed by
* `model.api` for mixed-API providers. Optional when `images` or
* `classifiers` is given.
*/
api?: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;
/** Image-generation implementations keyed by `model.api`. */
images?: Partial<Record<ImageApi, ProviderImages>>;
/** Classifier implementations keyed by `model.api`. */
classifiers?: Partial<Record<ClassifierApi, ProviderClassifier>>;
}
/**
* 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.
* custom providers both go through this. A single `api` streams all chat
* models; an `api` map dispatches on `model.api`, and a model whose api has
* no entry produces a stream error. One-shot operation maps dispatch on
* `model.api` the same way. At least one concrete implementation across
* `api`/`images`/`classifiers` is required; empty maps are rejected.
*/
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
const single =
input.api && typeof (input.api as ProviderStreams).stream === "function"
? (input.api as ProviderStreams)
: undefined;
const byApi = single || !input.api ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
const images = input.images as Partial<Record<string, ProviderImages>> | undefined;
const classifiers = input.classifiers as Partial<Record<string, ProviderClassifier>> | undefined;
const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry) => entry !== undefined);
const imageImplementations = Object.values(images ?? {}).filter((entry) => entry !== undefined);
const classifierImplementations = Object.values(classifiers ?? {}).filter((entry) => entry !== undefined);
if (streams.length === 0 && imageImplementations.length === 0 && classifierImplementations.length === 0) {
throw new Error(`Provider ${input.id}: at least one of "api", "images", or "classifiers" is required.`);
}
const baselineModels = input.models;
let dynamicModels: readonly Model<TApi>[] = [];
let dynamicModels: readonly ProviderModel<TApi>[] = [];
const fetchModels = input.fetchModels;
const currentModels = (): readonly Model<TApi>[] => {
const currentModels = (): readonly ProviderModel<TApi>[] => {
const merged = [...baselineModels];
for (const model of dynamicModels) {
const index = merged.findIndex((entry) => entry.id === model.id);
const index = merged.findIndex(
(entry) => getModelType(entry) === getModelType(model) && 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<Record<string, ProviderStreams>>);
const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];
const dispatch = (
@@ -810,13 +1094,14 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
baseUrl: input.baseUrl,
headers: input.headers,
auth: input.auth,
getModels: currentModels,
getModels: () => currentModels().filter((model): model is Model<TApi> => isModelType(model, "chat")),
getAllModels: currentModels,
refreshModels: fetchModels
? async (context) => {
if (context.stored) {
const restored = context.stored.models
.filter((model) => model.provider === input.id)
.map((model) => model as Model<TApi>);
.map((model) => model as ProviderModel<TApi>);
if (
!(await context.publish({
update: () => {
@@ -828,8 +1113,9 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
}
}
if (!context.allowNetwork || context.signal.aborted) return;
const refreshed = await fetchModels(context);
const fetched = await fetchModels(context);
if (context.signal.aborted) return;
const refreshed = fetched.filter(hasKnownModelType);
await context.publish({
persist: { models: refreshed, checkedAt: Date.now() },
update: () => {
@@ -839,12 +1125,12 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
}
: undefined,
filterModels: input.filterModels,
filterAllModels: input.filterAllModels,
stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
streamSimple: (model, context, options) =>
dispatch(model, (streams) => streams.streamSimple(model, context, options)),
};
const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry) => entry !== undefined);
if (streams.some((entry) => entry.fetchDeferred !== undefined)) {
provider.fetchDeferred = (model, handle, options) =>
lazyStream(model, async () => {
@@ -870,6 +1156,33 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
await implementation.cancelDeferred(model, handle, options);
};
}
if (images && imageImplementations.length > 0) {
provider.generateImages = async (model, context, options) => {
const implementation = images[model.api];
if (!implementation) {
return imageErrorResult(
model,
new ModelsError(
"provider",
`Provider ${input.id} has no image generation implementation for "${model.api}"`,
),
);
}
return implementation.generateImages(model, context, options);
};
}
if (classifiers && classifierImplementations.length > 0) {
provider.classify = async (model, context, options) => {
const implementation = classifiers[model.api];
if (!implementation) {
return classifierErrorResult(
model,
new ModelsError("provider", `Provider ${input.id} has no classifier implementation for "${model.api}"`),
);
}
return implementation.classify(model, context, options);
};
}
return provider;
}
@@ -883,12 +1196,14 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
* // model: Model<"anthropic-messages">, stream options fully typed
* }
* ```
*
* Non-chat models never match, even when their api id equals `api`.
*/
export function hasApi<TApi extends Api>(model: Model<Api>, api: TApi): model is Model<TApi> {
return model.api === api;
export function hasApi<TApi extends Api>(model: AnyModel, api: TApi): model is Model<TApi> {
return isModelType(model, "chat") && model.api === api;
}
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
export function calculateCost(model: AnyModel, usage: Usage): Usage["cost"] {
const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
let rates: ModelCostRates = model.cost;
let matchedThreshold = -1;
@@ -945,13 +1260,10 @@ export function clampThinkingLevel<TApi extends Api>(
}
/**
* Check if two models are equal by comparing both their id and provider.
* Check if two models are equal by comparing their type, id, and provider.
* Returns false if either model is null or undefined.
*/
export function modelsAreEqual<TApi extends Api>(
a: Model<TApi> | null | undefined,
b: Model<TApi> | null | undefined,
): boolean {
export function modelsAreEqual(a: AnyModel | null | undefined, b: AnyModel | null | undefined): boolean {
if (!a || !b) return false;
return a.id === b.id && a.provider === b.provider;
return getModelType(a) === getModelType(b) && a.id === b.id && a.provider === b.provider;
}