mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
An awaited refresh could resolve before its availability pass saved. Passes ran concurrently, and only the most recently started pass could save its result; a superseded pass returned without saving and refresh() still resolved. At startup, the awaited refresh could lose to a fire-and-forget refresh started by an extension provider registration, so initial model selection read a snapshot without that provider and fell back to another provider's default. A login also cancelled any full pass that was running. Availability passes now run one at a time. A request made while a pass is waiting to start joins it, so a caller always gets a pass that saw its changes. Each pass has a 10 s timeout, so a hung auth check or credential store no longer blocks the caller forever (#7301). A failed or timed-out provider check keeps that provider's previous state and is reported in getError(). The snapshot drops configuredProviders and all; hasConfiguredAuth reads the auth map. Refs #8810, #9884
1018 lines
38 KiB
TypeScript
1018 lines
38 KiB
TypeScript
import { dirname, join } from "node:path";
|
|
import {
|
|
type AnyModel,
|
|
type Api,
|
|
type ApiStreamOptions,
|
|
type AssistantImages,
|
|
type AssistantMessage,
|
|
type AssistantMessageEventStream,
|
|
type AuthCheck,
|
|
type AuthInteraction,
|
|
type AuthOperationOptions,
|
|
type AuthResult,
|
|
type AuthType,
|
|
type ClassifierApi,
|
|
type ClassifierContext,
|
|
type ClassifierModel,
|
|
type ClassifierOptions,
|
|
type ClassifierResult,
|
|
type Context,
|
|
type Credential,
|
|
type CredentialInfo,
|
|
type CredentialStore,
|
|
clampThinkingLevel,
|
|
createModels,
|
|
type DeferredCancelOptions,
|
|
type DeferredFetchOptions,
|
|
type DeferredHandle,
|
|
type ImageApi,
|
|
type ImageModel,
|
|
type ImagesContext,
|
|
type ImagesOptions,
|
|
type LoginOptions,
|
|
lazyStream,
|
|
type Message,
|
|
type Model,
|
|
type Models,
|
|
type ModelsApiStreamOptions,
|
|
type ModelsClassifierOptions,
|
|
type ModelsDeferredCancelOptions,
|
|
type ModelsDeferredFetchOptions,
|
|
ModelsError,
|
|
type ModelsImagesOptions,
|
|
type ModelsRefreshOptions,
|
|
type ModelsRefreshResult,
|
|
type ModelsRequestTransforms,
|
|
type ModelsSimpleStreamOptions,
|
|
type ModelsStore,
|
|
type ModelThinkingLevel,
|
|
type ModelType,
|
|
type ModelTypeMap,
|
|
type MutableModels,
|
|
normalizeContext,
|
|
type Provider,
|
|
type ProviderHeaders,
|
|
type ProviderRequestOptions,
|
|
type SimpleStreamOptions,
|
|
type StreamOptions,
|
|
} from "@earendil-works/pi-ai";
|
|
import * as builtinProviderCatalog from "@earendil-works/pi-ai/providers/all";
|
|
import {
|
|
assertChatModel,
|
|
assertClassifierModel,
|
|
assertImageModel,
|
|
classifierErrorResult,
|
|
imageErrorResult,
|
|
} from "@earendil-works/pi-ai/utils/model-operations";
|
|
import { getAgentDir } from "../config.ts";
|
|
import { operationSignal, raceWithAbortSignal } from "../utils/abort.ts";
|
|
import { AuthStorage as DefaultAuthStorage } from "./auth-storage.ts";
|
|
import { ModelConfig } from "./model-config.ts";
|
|
import { FileModelsStore, InMemoryCodingAgentModelsStore } from "./models-store.ts";
|
|
import {
|
|
type AuthStatus,
|
|
type CompatibilityRequestConfig,
|
|
composeModelProvider,
|
|
configuredRequestAuthStatus,
|
|
type ProviderConfigInput,
|
|
resolveCompatibilityRequestConfig,
|
|
resolveConfiguredModelHeaders,
|
|
validateExtensionProvider,
|
|
} from "./provider-composer.ts";
|
|
import { withRemoteCatalog } from "./remote-catalog-provider.ts";
|
|
import { RuntimeCredentials } from "./runtime-credentials.ts";
|
|
import {
|
|
createVirtualModel,
|
|
findLatestResponse,
|
|
isVirtualModel,
|
|
type ModelRoute,
|
|
type ModelRouteReason,
|
|
type VirtualModelDefinition,
|
|
withVirtualModels,
|
|
} from "./virtual-models.ts";
|
|
|
|
interface RegisteredVirtualModel {
|
|
model: Model<Api>;
|
|
route: VirtualModelDefinition["route"];
|
|
}
|
|
|
|
interface ModelRuntimeSnapshot {
|
|
/** Catalog chat models whose provider has auth. */
|
|
available: readonly Model<Api>[];
|
|
/** Auth check result of every provider that has auth. */
|
|
auth: ReadonlyMap<string, AuthCheck>;
|
|
/** Provider ids with a stored or runtime credential, including providers that are not registered. */
|
|
storedProviders: ReadonlySet<string>;
|
|
}
|
|
|
|
/** Availability check that a later request can still widen because it has not started. */
|
|
interface WaitingAvailabilityPass {
|
|
/** Provider ids to check, or undefined for every provider. */
|
|
providerIds: Set<string> | undefined;
|
|
result: Promise<ReadonlyMap<string, Error>>;
|
|
}
|
|
|
|
/** Upper bound for one availability pass, so a hung auth check or credential store cannot block callers. */
|
|
const AVAILABILITY_PASS_TIMEOUT_MS = 10_000;
|
|
|
|
export interface CreateModelRuntimeOptions {
|
|
/** Credential storage. Defaults to the file at authPath. */
|
|
credentials?: CredentialStore;
|
|
authPath?: string;
|
|
modelsPath?: string | null;
|
|
modelsStore?: ModelsStore;
|
|
modelsStorePath?: string;
|
|
/** Allow create() to refresh model catalogs over the network. Defaults to false. */
|
|
allowModelNetwork?: boolean;
|
|
/** Timeout for the create-time network model refresh. */
|
|
modelRefreshTimeoutMs?: number;
|
|
catalogBaseUrl?: string;
|
|
/** Optional caller cancellation for initial cache restoration and availability checks. */
|
|
signal?: AbortSignal;
|
|
/** Skip initial catalog and availability refresh. Static models remain available. */
|
|
refreshOnCreate?: boolean;
|
|
}
|
|
|
|
export interface ModelRuntimeAuthOverrides extends AuthOperationOptions {
|
|
apiKey?: string;
|
|
env?: Record<string, string>;
|
|
/** Require this much remaining OAuth-token validity; defaults to five minutes. */
|
|
minOAuthValidityMs?: number;
|
|
}
|
|
|
|
export type CredentialSynchronizationOperation = "login" | "logout" | "setRuntimeApiKey" | "removeRuntimeApiKey";
|
|
|
|
/** Credentials changed successfully, but the local model/auth snapshot could not be synchronized. */
|
|
export class CredentialSynchronizationError extends Error {
|
|
readonly providerId: string;
|
|
readonly operation: CredentialSynchronizationOperation;
|
|
readonly credential: Credential | undefined;
|
|
|
|
constructor(
|
|
providerId: string,
|
|
operation: CredentialSynchronizationOperation,
|
|
credential: Credential | undefined,
|
|
options: ErrorOptions,
|
|
) {
|
|
super(`Credential ${operation} committed for ${providerId}, but local synchronization failed`, options);
|
|
this.name = "CredentialSynchronizationError";
|
|
this.providerId = providerId;
|
|
this.operation = operation;
|
|
this.credential = credential;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/** Configured pi-ai Models collection used by coding-agent and SDK consumers. */
|
|
export class ModelRuntime implements Models {
|
|
private readonly models: MutableModels;
|
|
private readonly credentials: RuntimeCredentials;
|
|
private readonly defaultBuiltins: ReadonlyMap<string, Provider>;
|
|
private readonly builtins = new Map<string, Provider>();
|
|
private readonly nativeExtensionProviders = new Map<string, Provider>();
|
|
private readonly extensionProviders = new Map<string, ProviderConfigInput>();
|
|
/** Virtual models by provider id, then model id. */
|
|
private readonly virtualModels = new Map<string, Map<string, RegisteredVirtualModel>>();
|
|
private readonly compositionErrors = new Map<string, string>();
|
|
private readonly modelsPath: string | undefined;
|
|
private readonly modelNetworkEnabled: boolean;
|
|
private config: ModelConfig;
|
|
private snapshot: ModelRuntimeSnapshot = {
|
|
available: [],
|
|
auth: new Map(),
|
|
storedProviders: new Set(),
|
|
};
|
|
/** Settles when the last requested availability pass finishes. Passes run one at a time. */
|
|
private availabilityTail: Promise<unknown> = Promise.resolve();
|
|
private waitingAvailabilityPass: WaitingAvailabilityPass | undefined;
|
|
private availabilityError: string | undefined;
|
|
private readonly credentialOperations = new Map<string, Promise<unknown>>();
|
|
|
|
private constructor(
|
|
credentials: RuntimeCredentials,
|
|
config: ModelConfig,
|
|
modelsPath: string | undefined,
|
|
modelsStore: ModelsStore,
|
|
providers: readonly Provider[],
|
|
modelNetworkEnabled: boolean,
|
|
) {
|
|
this.credentials = credentials;
|
|
this.config = config;
|
|
this.modelsPath = modelsPath;
|
|
this.modelNetworkEnabled = modelNetworkEnabled;
|
|
this.defaultBuiltins = new Map(providers.map((provider) => [provider.id, provider]));
|
|
for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider);
|
|
this.models = createModels({ credentials, modelsStore });
|
|
this.rebuildProviders();
|
|
}
|
|
|
|
static async create(options: CreateModelRuntimeOptions = {}): Promise<ModelRuntime> {
|
|
const credentials = new RuntimeCredentials(options.credentials ?? DefaultAuthStorage.create(options.authPath));
|
|
const modelsPath =
|
|
options.modelsPath === null ? undefined : (options.modelsPath ?? join(getAgentDir(), "models.json"));
|
|
const config = await ModelConfig.load(modelsPath);
|
|
const modelsStore =
|
|
options.modelsStore ??
|
|
(modelsPath
|
|
? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json"))
|
|
: new InMemoryCodingAgentModelsStore());
|
|
const builtinModelDataGeneratedAt = builtinProviderCatalog.getBuiltinModelDataGeneratedAt();
|
|
const providers = builtinProviderCatalog
|
|
.builtinProviders()
|
|
.map((provider) =>
|
|
provider.id === "radius"
|
|
? provider
|
|
: withRemoteCatalog(provider, options.catalogBaseUrl, builtinModelDataGeneratedAt),
|
|
);
|
|
const runtime = new ModelRuntime(
|
|
credentials,
|
|
config,
|
|
modelsPath,
|
|
modelsStore,
|
|
providers,
|
|
process.env.PI_OFFLINE === undefined,
|
|
);
|
|
runtime.configureRadiusProviders();
|
|
runtime.rebuildProviders();
|
|
const refreshFromNetwork = runtime.modelNetworkEnabled && options.allowModelNetwork === true;
|
|
const controller =
|
|
refreshFromNetwork && options.modelRefreshTimeoutMs !== undefined ? new AbortController() : undefined;
|
|
const timeout = controller ? setTimeout(() => controller.abort(), options.modelRefreshTimeoutMs) : undefined;
|
|
const signal = controller
|
|
? options.signal
|
|
? AbortSignal.any([options.signal, controller.signal])
|
|
: controller.signal
|
|
: options.signal;
|
|
try {
|
|
if (options.refreshOnCreate !== false) {
|
|
await runtime.refresh({ allowNetwork: refreshFromNetwork, signal });
|
|
}
|
|
} finally {
|
|
if (timeout) clearTimeout(timeout);
|
|
}
|
|
return runtime;
|
|
}
|
|
|
|
private configureRadiusProviders(): void {
|
|
this.builtins.clear();
|
|
for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider);
|
|
for (const providerId of this.config.getProviderIds()) {
|
|
const config = this.config.getProvider(providerId);
|
|
if (config?.oauth !== "radius" || !config.baseUrl) continue;
|
|
this.builtins.set(
|
|
providerId,
|
|
builtinProviderCatalog.radiusProvider({
|
|
id: providerId,
|
|
name: config.name ?? providerId,
|
|
gateway: config.baseUrl.replace(/\/v1\/?$/u, ""),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
private providerIds(): Set<string> {
|
|
return new Set([
|
|
...this.builtins.keys(),
|
|
...this.nativeExtensionProviders.keys(),
|
|
...this.config.getProviderIds(),
|
|
...this.extensionProviders.keys(),
|
|
...this.virtualModels.keys(),
|
|
]);
|
|
}
|
|
|
|
/** Returns the provider without virtual models, or undefined when only virtual models define it. */
|
|
private recomposeProvider(providerId: string): Provider | undefined {
|
|
const provider = this.composeProvider(providerId);
|
|
const virtualModels = [...(this.virtualModels.get(providerId)?.values() ?? [])].map((entry) => entry.model);
|
|
if (virtualModels.length > 0) this.models.setProvider(withVirtualModels(providerId, provider, virtualModels));
|
|
else if (provider) this.models.setProvider(provider);
|
|
else this.models.deleteProvider(providerId);
|
|
return provider;
|
|
}
|
|
|
|
/** The provider without virtual models, or undefined when nothing defines it. */
|
|
private composeProvider(providerId: string): Provider | undefined {
|
|
const base = this.nativeExtensionProviders.get(providerId) ?? this.builtins.get(providerId);
|
|
const extension = this.extensionProviders.get(providerId);
|
|
if (!this.config.getProvider(providerId) && !extension) {
|
|
// No overlays: use the builtin untouched so its auth/login/stream behavior is exact.
|
|
this.compositionErrors.delete(providerId);
|
|
return base;
|
|
}
|
|
try {
|
|
const provider = composeModelProvider(providerId, base, this.config, extension);
|
|
this.compositionErrors.delete(providerId);
|
|
return provider;
|
|
} catch (error) {
|
|
this.compositionErrors.set(providerId, error instanceof Error ? error.message : String(error));
|
|
return base;
|
|
}
|
|
}
|
|
|
|
private rebuildProviders(): void {
|
|
this.models.clearProviders();
|
|
this.compositionErrors.clear();
|
|
for (const providerId of this.providerIds()) this.recomposeProvider(providerId);
|
|
this.updateModelSnapshot();
|
|
}
|
|
|
|
private updateModelSnapshot(): void {
|
|
this.snapshot = {
|
|
...this.snapshot,
|
|
available: this.models.getModels().filter((model) => this.snapshot.auth.has(model.provider)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check auth for `providerIds`, or for every provider when undefined, and resolve with the checks that
|
|
* failed. Passes run one at a time, so the last pass to save is always the newest. A request joins the
|
|
* waiting pass when there is one: it has not read anything yet, so it will see the requester's changes.
|
|
* The caller's signal only stops its own wait; the shared pass keeps running for the other callers.
|
|
*/
|
|
private requestAvailabilityPass(
|
|
providerIds: readonly string[] | undefined,
|
|
signal?: AbortSignal,
|
|
): Promise<ReadonlyMap<string, Error>> {
|
|
const waiting = this.waitingAvailabilityPass;
|
|
if (waiting) {
|
|
if (!providerIds) waiting.providerIds = undefined;
|
|
else for (const id of providerIds) waiting.providerIds?.add(id);
|
|
return raceWithAbortSignal(waiting.result, signal);
|
|
}
|
|
const pass: WaitingAvailabilityPass = {
|
|
providerIds: providerIds && new Set(providerIds),
|
|
result: this.availabilityTail.then(() => {
|
|
this.waitingAvailabilityPass = undefined;
|
|
return this.runAvailabilityPass(pass.providerIds);
|
|
}),
|
|
};
|
|
this.waitingAvailabilityPass = pass;
|
|
this.availabilityTail = pass.result.catch(() => {});
|
|
return raceWithAbortSignal(pass.result, signal);
|
|
}
|
|
|
|
/**
|
|
* A provider whose check fails or times out keeps its previous entry and is reported in the result.
|
|
* A failed credential list fails the whole pass without saving anything.
|
|
*/
|
|
private async runAvailabilityPass(
|
|
providerIds: ReadonlySet<string> | undefined,
|
|
): Promise<ReadonlyMap<string, Error>> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(
|
|
() => controller.abort(new Error(`Availability check timed out after ${AVAILABILITY_PASS_TIMEOUT_MS} ms`)),
|
|
AVAILABILITY_PASS_TIMEOUT_MS,
|
|
);
|
|
const signal = controller.signal;
|
|
try {
|
|
const ids = providerIds ? [...providerIds] : this.models.getProviders().map((provider) => provider.id);
|
|
const [credentials, results] = await Promise.all([
|
|
raceWithAbortSignal(this.credentials.list({ signal }), signal),
|
|
Promise.all(
|
|
ids.map(async (id) => {
|
|
try {
|
|
// Provider checks run extension code that may ignore the signal.
|
|
const [check, available] = await raceWithAbortSignal(
|
|
Promise.all([this.models.checkAuth(id, { signal }), this.models.getAvailable(id, { signal })]),
|
|
signal,
|
|
);
|
|
return { id, check, available };
|
|
} catch (error) {
|
|
return { id, error: error instanceof Error ? error : new Error(String(error)) };
|
|
}
|
|
}),
|
|
),
|
|
]);
|
|
const auth = new Map(this.snapshot.auth);
|
|
const checkedAvailable = new Map<string, readonly Model<Api>[]>();
|
|
const errors = new Map<string, Error>();
|
|
for (const result of results) {
|
|
if (result.error) {
|
|
errors.set(result.id, result.error);
|
|
continue;
|
|
}
|
|
checkedAvailable.set(result.id, result.available);
|
|
if (result.check) auth.set(result.id, result.check);
|
|
else auth.delete(result.id);
|
|
}
|
|
for (const id of auth.keys()) {
|
|
if (!this.models.getProvider(id)) auth.delete(id);
|
|
}
|
|
const previousAvailable = Map.groupBy(this.snapshot.available, (model) => model.provider);
|
|
this.snapshot = {
|
|
available: this.models
|
|
.getProviders()
|
|
.flatMap(({ id }) =>
|
|
auth.has(id) ? (checkedAvailable.get(id) ?? previousAvailable.get(id) ?? []) : [],
|
|
),
|
|
auth,
|
|
storedProviders: new Set(credentials.map((entry) => entry.providerId)),
|
|
};
|
|
this.availabilityError =
|
|
errors.size > 0 ? [...errors.values()].map((error) => error.message).join("\n") : undefined;
|
|
return errors;
|
|
} catch (error) {
|
|
this.availabilityError = error instanceof Error ? error.message : String(error);
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
getProviders(): readonly Provider[] {
|
|
return this.models.getProviders();
|
|
}
|
|
|
|
getProvider(providerId: string): Provider | undefined {
|
|
return this.models.getProvider(providerId);
|
|
}
|
|
|
|
getModels(providerId?: string): readonly Model<Api>[] {
|
|
return this.models.getModels(providerId);
|
|
}
|
|
|
|
getModel(providerId: string, modelId: string): Model<Api> | undefined {
|
|
return this.models.getModel(providerId, modelId);
|
|
}
|
|
|
|
getModelsOfType<TType extends ModelType>(type: TType, providerId?: string): readonly ModelTypeMap[TType][] {
|
|
return this.models.getModelsOfType(type, providerId);
|
|
}
|
|
|
|
getModelOfType<TType extends ModelType>(
|
|
type: TType,
|
|
providerId: string,
|
|
modelId: string,
|
|
): ModelTypeMap[TType] | undefined {
|
|
return this.models.getModelOfType(type, providerId, modelId);
|
|
}
|
|
|
|
getAllModels(providerId?: string): readonly AnyModel[] {
|
|
return this.models.getAllModels(providerId);
|
|
}
|
|
|
|
getAvailableOfType<TType extends ModelType>(
|
|
type: TType,
|
|
providerId?: string,
|
|
options?: AuthOperationOptions,
|
|
): Promise<readonly ModelTypeMap[TType][]> {
|
|
return this.models.getAvailableOfType(type, providerId, options);
|
|
}
|
|
|
|
getAllAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly AnyModel[]> {
|
|
return this.models.getAllAvailable(providerId, options);
|
|
}
|
|
|
|
async checkAuth(providerId: string, options?: AuthOperationOptions): Promise<AuthCheck | undefined> {
|
|
return this.models.checkAuth(providerId, options);
|
|
}
|
|
|
|
async getAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly Model<Api>[]> {
|
|
const errors = await this.requestAvailabilityPass(providerId ? [providerId] : undefined, options?.signal);
|
|
if (!providerId) return this.snapshot.available;
|
|
const error = errors.get(providerId);
|
|
if (error) throw error;
|
|
return this.snapshot.available.filter((model) => model.provider === providerId);
|
|
}
|
|
|
|
getAvailableSnapshot(): readonly Model<Api>[] {
|
|
return this.snapshot.available;
|
|
}
|
|
|
|
getError(): string | undefined {
|
|
const errors: string[] = [];
|
|
const configError = this.config.getError();
|
|
if (configError) errors.push(configError);
|
|
for (const [providerId, error] of this.compositionErrors) {
|
|
errors.push(`Provider "${providerId}": ${error}`);
|
|
}
|
|
if (this.availabilityError) errors.push(`Availability refresh: ${this.availabilityError}`);
|
|
return errors.length > 0 ? errors.join("\n\n") : undefined;
|
|
}
|
|
|
|
getRegisteredProviderConfig(providerId: string): ProviderConfigInput | undefined {
|
|
return this.extensionProviders.get(providerId);
|
|
}
|
|
|
|
getRegisteredProviderIds(): readonly string[] {
|
|
return [...new Set([...this.extensionProviders.keys(), ...this.nativeExtensionProviders.keys()])];
|
|
}
|
|
|
|
getRegisteredNativeProvider(providerId: string): Provider | undefined {
|
|
return this.nativeExtensionProviders.get(providerId);
|
|
}
|
|
|
|
/** @internal Compatibility fallback for ModelRegistry when provider auth is unconfigured. */
|
|
getCompatibilityRequestConfig(model: Model<Api>): CompatibilityRequestConfig {
|
|
return resolveCompatibilityRequestConfig(
|
|
model,
|
|
this.config.getProvider(model.provider),
|
|
this.extensionProviders.get(model.provider),
|
|
);
|
|
}
|
|
|
|
isUsingOAuth(providerId: string): boolean {
|
|
return this.snapshot.auth.get(providerId)?.type === "oauth";
|
|
}
|
|
|
|
isUsingSubscription(providerId: string): boolean {
|
|
return this.isUsingOAuth(providerId) && this.models.getProvider(providerId)?.auth.oauth?.isSubscription === true;
|
|
}
|
|
|
|
hasConfiguredAuth(providerId: string): boolean {
|
|
return this.snapshot.auth.has(providerId);
|
|
}
|
|
|
|
getAuth(providerId: string, overrides?: ModelRuntimeAuthOverrides): Promise<AuthResult | undefined>;
|
|
getAuth(model: AnyModel, overrides?: ModelRuntimeAuthOverrides): Promise<AuthResult | undefined>;
|
|
async getAuth(
|
|
providerOrModel: string | AnyModel,
|
|
overrides: ModelRuntimeAuthOverrides = {},
|
|
): Promise<AuthResult | undefined> {
|
|
if (typeof providerOrModel === "string") return this.models.getAuth(providerOrModel, overrides);
|
|
const resolution = await this.models.getAuth(providerOrModel, overrides);
|
|
if (!resolution) return undefined;
|
|
const configuredHeaders = resolveConfiguredModelHeaders(
|
|
providerOrModel,
|
|
this.config.getProvider(providerOrModel.provider),
|
|
this.extensionProviders.get(providerOrModel.provider),
|
|
{ ...(resolution.env ?? {}), ...(overrides.env ?? {}) },
|
|
);
|
|
return {
|
|
...resolution,
|
|
auth: {
|
|
...resolution.auth,
|
|
headers: mergeHeaders(resolution.auth.headers, configuredHeaders),
|
|
},
|
|
};
|
|
}
|
|
|
|
private enqueueCredentialOperation<T>(providerId: string, signal: AbortSignal, task: () => Promise<T>): Promise<T> {
|
|
const previous = this.credentialOperations.get(providerId) ?? Promise.resolve();
|
|
let markStarted: (() => void) | undefined;
|
|
const started = new Promise<void>((resolve) => {
|
|
markStarted = resolve;
|
|
});
|
|
const operation = (async () => {
|
|
await previous.catch(() => {});
|
|
signal.throwIfAborted();
|
|
markStarted?.();
|
|
return task();
|
|
})();
|
|
const tail = operation.catch(() => {});
|
|
this.credentialOperations.set(providerId, tail);
|
|
void tail.then(() => {
|
|
if (this.credentialOperations.get(providerId) === tail) this.credentialOperations.delete(providerId);
|
|
});
|
|
return raceWithAbortSignal(started, signal).then(() => operation);
|
|
}
|
|
|
|
private async synchronizeCredentialState(
|
|
providerId: string,
|
|
operation: CredentialSynchronizationOperation,
|
|
credential: Credential | undefined,
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
try {
|
|
signal.throwIfAborted();
|
|
this.recomposeProvider(providerId);
|
|
const compositionError = this.compositionErrors.get(providerId);
|
|
if (compositionError) throw new Error(compositionError);
|
|
const result = await this.models.refresh({ allowNetwork: false, providers: [providerId], signal });
|
|
if (result.aborted) signal.throwIfAborted();
|
|
const refreshError = result.errors.get(providerId);
|
|
if (refreshError) throw refreshError;
|
|
this.updateModelSnapshot();
|
|
const availabilityError = (await this.requestAvailabilityPass([providerId], signal)).get(providerId);
|
|
if (availabilityError) throw availabilityError;
|
|
} catch (cause) {
|
|
throw new CredentialSynchronizationError(providerId, operation, credential, { cause });
|
|
}
|
|
}
|
|
|
|
setRuntimeApiKey(providerId: string, apiKey: string, options: AuthOperationOptions = {}): Promise<void> {
|
|
const signal = operationSignal(options.signal);
|
|
return this.enqueueCredentialOperation(providerId, signal, async () => {
|
|
this.credentials.setRuntimeApiKey(providerId, apiKey);
|
|
await this.synchronizeCredentialState(
|
|
providerId,
|
|
"setRuntimeApiKey",
|
|
{ type: "api_key", key: apiKey },
|
|
signal,
|
|
);
|
|
});
|
|
}
|
|
|
|
removeRuntimeApiKey(providerId: string, options: AuthOperationOptions = {}): Promise<void> {
|
|
const signal = operationSignal(options.signal);
|
|
return this.enqueueCredentialOperation(providerId, signal, async () => {
|
|
this.credentials.removeRuntimeApiKey(providerId);
|
|
await this.synchronizeCredentialState(providerId, "removeRuntimeApiKey", undefined, signal);
|
|
});
|
|
}
|
|
|
|
listCredentials(options?: AuthOperationOptions): Promise<readonly CredentialInfo[]> {
|
|
return this.credentials.list(options);
|
|
}
|
|
|
|
getProviderAuthStatus(providerId: string): AuthStatus {
|
|
if (this.credentials.hasRuntimeApiKey(providerId)) return { configured: true, source: "runtime" };
|
|
if (this.snapshot.storedProviders.has(providerId)) return { configured: true, source: "stored" };
|
|
const configured = configuredRequestAuthStatus(
|
|
this.config.getProvider(providerId),
|
|
this.extensionProviders.get(providerId),
|
|
);
|
|
if (configured) return configured;
|
|
const check = this.snapshot.auth.get(providerId);
|
|
return check ? { configured: true, source: "environment", label: check.source } : { configured: false };
|
|
}
|
|
|
|
private async prepareRequest<
|
|
TModel extends AnyModel,
|
|
TOptions extends ProviderRequestOptions<TModel> & ModelsRequestTransforms,
|
|
>(
|
|
model: TModel,
|
|
options: TOptions | undefined,
|
|
): Promise<{
|
|
provider: Provider;
|
|
model: TModel;
|
|
options: Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;
|
|
}> {
|
|
const provider = this.models.getProvider(model.provider);
|
|
if (!provider) throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
|
|
const resolution = await this.getAuth(model, {
|
|
apiKey: options?.apiKey,
|
|
env: options?.env,
|
|
signal: options?.signal,
|
|
});
|
|
if (!resolution) throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);
|
|
|
|
const { transformHeaders, ...rawProviderOptions } = options ?? {};
|
|
const providerOptions = rawProviderOptions as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;
|
|
let headers = mergeHeaders(resolution.auth.headers, providerOptions.headers);
|
|
if (transformHeaders) headers = await transformHeaders(headers ?? {});
|
|
const env =
|
|
resolution.env || providerOptions.env
|
|
? { ...(resolution.env ?? {}), ...(providerOptions.env ?? {}) }
|
|
: undefined;
|
|
const requestModel: TModel = resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model;
|
|
return {
|
|
provider,
|
|
model: requestModel,
|
|
options: {
|
|
...providerOptions,
|
|
apiKey: providerOptions.apiKey ?? resolution.auth.apiKey,
|
|
headers,
|
|
env,
|
|
} as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>,
|
|
};
|
|
}
|
|
|
|
stream<TApi extends Api>(
|
|
model: Model<TApi>,
|
|
context: Context,
|
|
options?: ModelsApiStreamOptions<TApi>,
|
|
): AssistantMessageEventStream {
|
|
const transcript = normalizeContext(context);
|
|
return lazyStream(model, async () => {
|
|
assertChatModel(model);
|
|
const prepared = await this.prepareRequest(
|
|
model,
|
|
options as (StreamOptions & ModelsRequestTransforms) | undefined,
|
|
);
|
|
return prepared.provider.stream(prepared.model, transcript, prepared.options as ApiStreamOptions<TApi>);
|
|
});
|
|
}
|
|
|
|
complete<TApi extends Api>(
|
|
model: Model<TApi>,
|
|
context: Context,
|
|
options?: ModelsApiStreamOptions<TApi>,
|
|
): Promise<AssistantMessage> {
|
|
return this.stream(model, context, options).result();
|
|
}
|
|
|
|
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream {
|
|
const transcript = normalizeContext(context);
|
|
if (isVirtualModel(model)) {
|
|
// Requests outside the agent loop are routed here. Callers sized them before routing, so
|
|
// cap the output budget to the routed model.
|
|
return lazyStream(model, async () => {
|
|
const route = await this.resolveModel(model, transcript.messages, {
|
|
reason: "direct",
|
|
thinkingLevel: options?.reasoning ?? "off",
|
|
signal: options?.signal,
|
|
});
|
|
const { maxTokens: limit } = route.model;
|
|
const maxTokens = options?.maxTokens && limit > 0 ? Math.min(options.maxTokens, limit) : options?.maxTokens;
|
|
const reasoning = route.thinkingLevel === "off" ? undefined : route.thinkingLevel;
|
|
// Caller credentials were resolved for the virtual model's provider. Another provider
|
|
// resolves its own, so they are not sent to the wrong vendor.
|
|
const { apiKey, headers, env, ...rest } = options ?? {};
|
|
const auth = route.model.provider === model.provider ? { apiKey, headers, env } : {};
|
|
return this.streamSimple(route.model, context, { ...rest, ...auth, maxTokens, reasoning });
|
|
});
|
|
}
|
|
return lazyStream(model, async () => {
|
|
assertChatModel(model);
|
|
const prepared = await this.prepareRequest(model, options);
|
|
return prepared.provider.streamSimple(prepared.model, transcript, prepared.options as SimpleStreamOptions);
|
|
});
|
|
}
|
|
|
|
completeSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): Promise<AssistantMessage> {
|
|
return this.streamSimple(model, context, options).result();
|
|
}
|
|
|
|
streamDeferred(
|
|
model: Model<Api>,
|
|
handle: DeferredHandle,
|
|
options?: ModelsDeferredFetchOptions,
|
|
): AssistantMessageEventStream {
|
|
return lazyStream(model, async () => {
|
|
assertChatModel(model);
|
|
const prepared = await this.prepareRequest(model, options);
|
|
if (!prepared.provider.fetchDeferred) {
|
|
throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);
|
|
}
|
|
return prepared.provider.fetchDeferred(prepared.model, handle, prepared.options as DeferredFetchOptions);
|
|
});
|
|
}
|
|
|
|
async fetchDeferred(
|
|
model: Model<Api>,
|
|
handle: DeferredHandle,
|
|
options?: ModelsDeferredFetchOptions,
|
|
): Promise<AssistantMessage> {
|
|
return this.streamDeferred(model, handle, options).result();
|
|
}
|
|
|
|
async cancelDeferred(
|
|
model: Model<Api>,
|
|
handle: DeferredHandle,
|
|
options?: ModelsDeferredCancelOptions,
|
|
): Promise<void> {
|
|
assertChatModel(model);
|
|
const prepared = await this.prepareRequest(model, options);
|
|
if (!prepared.provider.cancelDeferred) {
|
|
throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);
|
|
}
|
|
await prepared.provider.cancelDeferred(prepared.model, handle, prepared.options as DeferredCancelOptions);
|
|
}
|
|
|
|
async generateImages(
|
|
model: ImageModel<ImageApi>,
|
|
context: ImagesContext,
|
|
options?: ModelsImagesOptions,
|
|
): Promise<AssistantImages> {
|
|
try {
|
|
assertImageModel(model);
|
|
const prepared = await this.prepareRequest(model, options);
|
|
if (!prepared.provider.generateImages) {
|
|
throw new ModelsError("provider", `Provider ${model.provider} does not support image generation`);
|
|
}
|
|
return await prepared.provider.generateImages(prepared.model, context, prepared.options as ImagesOptions);
|
|
} catch (error) {
|
|
return imageErrorResult(model, error, options?.signal?.aborted);
|
|
}
|
|
}
|
|
|
|
async classify(
|
|
model: ClassifierModel<ClassifierApi>,
|
|
context: ClassifierContext,
|
|
options?: ModelsClassifierOptions,
|
|
): Promise<ClassifierResult> {
|
|
try {
|
|
assertClassifierModel(model);
|
|
const prepared = await this.prepareRequest(model, options);
|
|
if (!prepared.provider.classify) {
|
|
throw new ModelsError("provider", `Provider ${model.provider} does not support classification`);
|
|
}
|
|
return await prepared.provider.classify(prepared.model, context, prepared.options as ClassifierOptions);
|
|
} catch (error) {
|
|
return classifierErrorResult(model, error, options?.signal?.aborted);
|
|
}
|
|
}
|
|
|
|
login(
|
|
providerId: string,
|
|
type: AuthType,
|
|
interaction: AuthInteraction,
|
|
options?: LoginOptions,
|
|
): Promise<Credential> {
|
|
const signal = operationSignal(interaction.signal);
|
|
return this.enqueueCredentialOperation(providerId, signal, async () => {
|
|
const credential = await this.models.login(providerId, type, { ...interaction, signal }, options);
|
|
await this.synchronizeCredentialState(providerId, "login", credential, signal);
|
|
return credential;
|
|
});
|
|
}
|
|
|
|
logout(providerId: string, options: AuthOperationOptions = {}): Promise<void> {
|
|
const signal = operationSignal(options.signal);
|
|
return this.enqueueCredentialOperation(providerId, signal, async () => {
|
|
await this.models.logout(providerId, { signal });
|
|
await this.synchronizeCredentialState(providerId, "logout", undefined, signal);
|
|
});
|
|
}
|
|
|
|
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
|
|
this.config = await ModelConfig.load(this.modelsPath);
|
|
this.configureRadiusProviders();
|
|
if (options.providers) {
|
|
for (const providerId of new Set(options.providers)) this.recomposeProvider(providerId);
|
|
this.updateModelSnapshot();
|
|
} else {
|
|
this.rebuildProviders();
|
|
}
|
|
const refreshOptions = {
|
|
...options,
|
|
allowNetwork: options.allowNetwork ?? this.modelNetworkEnabled,
|
|
};
|
|
// Published pi-ai builds before ModelsStore returned void and accepted a provider ID.
|
|
// The fallback keeps source-mode CLI tests working without rebuilding workspace dependencies.
|
|
const result = ((await this.models.refresh(refreshOptions)) as ModelsRefreshResult | undefined) ?? {
|
|
aborted: refreshOptions.signal?.aborted ?? false,
|
|
errors: new Map(),
|
|
};
|
|
const errors = new Map(result.errors);
|
|
this.updateModelSnapshot();
|
|
try {
|
|
const availabilityErrors = await this.requestAvailabilityPass(options.providers, options.signal);
|
|
for (const providerId of options.providers ?? []) {
|
|
const error = availabilityErrors.get(providerId);
|
|
if (error) errors.set(providerId, error);
|
|
}
|
|
} catch (error) {
|
|
// A failed pass is recorded in availabilityError; refreshed models remain usable.
|
|
if (options.providers && !options.signal?.aborted) {
|
|
for (const providerId of options.providers) {
|
|
errors.set(providerId, error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
}
|
|
}
|
|
return { aborted: result.aborted || (options.signal?.aborted ?? false), errors };
|
|
}
|
|
|
|
registerNativeProvider(provider: Provider): void {
|
|
if (!provider.id.trim()) throw new Error("Provider id must not be empty.");
|
|
this.extensionProviders.delete(provider.id);
|
|
this.nativeExtensionProviders.set(provider.id, provider);
|
|
this.recomposeProvider(provider.id);
|
|
this.updateModelSnapshot();
|
|
this.markProvisionallyConfigured(
|
|
provider.id,
|
|
configuredRequestAuthStatus(this.config.getProvider(provider.id), undefined),
|
|
provider.auth.oauth && !provider.auth.apiKey ? "oauth" : "api_key",
|
|
);
|
|
void this.refresh({ allowNetwork: false });
|
|
}
|
|
|
|
/**
|
|
* Mark a newly registered provider as configured when it has a stored credential or a configured
|
|
* API key. Availability checks run asynchronously, and callers such as initial model selection
|
|
* read the snapshot before they finish. The next availability pass replaces this entry.
|
|
*/
|
|
private markProvisionallyConfigured(
|
|
providerId: string,
|
|
configuredStatus: AuthStatus | undefined,
|
|
type: AuthType,
|
|
): void {
|
|
if (!this.snapshot.storedProviders.has(providerId) && !configuredStatus?.configured) return;
|
|
// Never clobber a real check result.
|
|
if (this.snapshot.auth.has(providerId)) return;
|
|
this.snapshot = {
|
|
...this.snapshot,
|
|
auth: new Map(this.snapshot.auth).set(providerId, { type, source: "configured provider" }),
|
|
};
|
|
this.updateModelSnapshot();
|
|
}
|
|
|
|
registerProvider(providerId: string, config: ProviderConfigInput): void {
|
|
// Validate the incoming registration on its own, like the legacy registry:
|
|
// a broken re-registration must throw without touching the stored config.
|
|
validateExtensionProvider(providerId, this.builtins.get(providerId), this.config.getProvider(providerId), config);
|
|
this.nativeExtensionProviders.delete(providerId);
|
|
// Re-registration merges defined values over the previous registration and
|
|
// preserves undefined ones, matching the legacy ModelRegistry contract.
|
|
const previous = this.extensionProviders.get(providerId);
|
|
const effective: ProviderConfigInput = { ...previous };
|
|
for (const [key, value] of Object.entries(config)) {
|
|
if (value !== undefined) (effective as Record<string, unknown>)[key] = value;
|
|
}
|
|
this.extensionProviders.set(providerId, effective);
|
|
this.recomposeProvider(providerId);
|
|
this.updateModelSnapshot();
|
|
this.markProvisionallyConfigured(
|
|
providerId,
|
|
configuredRequestAuthStatus(this.config.getProvider(providerId), effective),
|
|
effective.oauth && !effective.apiKey ? "oauth" : "api_key",
|
|
);
|
|
void this.refresh({ allowNetwork: false });
|
|
}
|
|
|
|
unregisterProvider(providerId: string): void {
|
|
this.extensionProviders.delete(providerId);
|
|
this.nativeExtensionProviders.delete(providerId);
|
|
this.recomposeProvider(providerId);
|
|
this.updateModelSnapshot();
|
|
void this.refresh({ allowNetwork: false });
|
|
}
|
|
|
|
/**
|
|
* Register a virtual model under `definition.provider`, which may also list physical models or
|
|
* several virtual models. Re-registering the same provider and id replaces the virtual model.
|
|
* Throws when the id belongs to a physical model of that provider.
|
|
*/
|
|
registerVirtualModel(definition: VirtualModelDefinition): void {
|
|
const { provider: providerId, id } = definition;
|
|
if (!providerId.trim() || !id.trim()) throw new Error("Virtual model provider and id must not be empty.");
|
|
const existing = this.models.getModel(providerId, id);
|
|
if (existing && !isVirtualModel(existing)) {
|
|
throw new Error(`Virtual model ${providerId}/${id} conflicts with a physical model.`);
|
|
}
|
|
const models = this.virtualModels.get(providerId) ?? new Map<string, RegisteredVirtualModel>();
|
|
models.set(id, { model: createVirtualModel(definition), route: (request) => definition.route(request) });
|
|
this.virtualModels.set(providerId, models);
|
|
if (!this.recomposeProvider(providerId) && !this.snapshot.auth.has(providerId)) {
|
|
// A provider of only virtual models needs no credentials. Mark it configured now: session
|
|
// restore checks auth before the refresh below lands.
|
|
const auth = new Map(this.snapshot.auth).set(providerId, { type: "api_key", source: "virtual" });
|
|
this.snapshot = { ...this.snapshot, auth };
|
|
}
|
|
this.updateModelSnapshot();
|
|
void this.refresh({ allowNetwork: false });
|
|
}
|
|
|
|
unregisterVirtualModel(providerId: string, id: string): void {
|
|
const models = this.virtualModels.get(providerId);
|
|
if (!models?.delete(id)) return;
|
|
if (models.size === 0) this.virtualModels.delete(providerId);
|
|
this.recomposeProvider(providerId);
|
|
this.updateModelSnapshot();
|
|
void this.refresh({ allowNetwork: false });
|
|
}
|
|
|
|
/**
|
|
* Ask a virtual model's router for the model and thinking level of one request. The router must
|
|
* return a physical catalog model whose provider has credentials; the thinking level is clamped
|
|
* to that model. Throws when routing fails.
|
|
*
|
|
* `previous` reports the latest successful response in `messages`. A retry passes the failed
|
|
* response as `options.failed`; `messages` no longer contains it. `options.state` is the router
|
|
* state stored by the caller, which also stores the returned state.
|
|
*/
|
|
async resolveModel(
|
|
model: Model<Api>,
|
|
messages: readonly Message[],
|
|
options: {
|
|
reason: ModelRouteReason;
|
|
thinkingLevel: ModelThinkingLevel;
|
|
signal?: AbortSignal;
|
|
failed?: AssistantMessage;
|
|
state?: unknown;
|
|
},
|
|
): Promise<ModelRoute> {
|
|
const name = `Virtual model ${model.provider}/${model.id}`;
|
|
const virtual = this.virtualModels.get(model.provider)?.get(model.id);
|
|
if (!virtual) throw new Error(`${name} is not registered.`);
|
|
const { failed, ...request } = options;
|
|
const latest = findLatestResponse(messages);
|
|
const previousModel = latest && this.getPhysicalModel(latest.provider, latest.model);
|
|
// A failed routing attempt names the virtual model; there is no physical request to report.
|
|
const failedModel = failed && this.getPhysicalModel(failed.provider, failed.model);
|
|
const route = await virtual.route({
|
|
...request,
|
|
model,
|
|
previous: previousModel && { model: previousModel, thinkingLevel: latest?.thinkingLevel },
|
|
failed: failedModel && failed && { model: failedModel, thinkingLevel: failed.thinkingLevel, message: failed },
|
|
messages,
|
|
});
|
|
const target = this.getPhysicalModel(route.model.provider, route.model.id);
|
|
const routed = `${name} routed to ${route.model.provider}/${route.model.id}`;
|
|
if (!target) throw new Error(`${routed}, which is not a physical model.`);
|
|
if (!this.hasConfiguredAuth(target.provider)) throw new Error(`${routed}, which has no credentials.`);
|
|
return { model: target, thinkingLevel: clampThinkingLevel(target, route.thinkingLevel), state: route.state };
|
|
}
|
|
|
|
/** A catalog chat model that is not virtual. */
|
|
getPhysicalModel(providerId: string, modelId: string): Model<Api> | undefined {
|
|
const model = this.models.getModel(providerId, modelId);
|
|
return model && !isVirtualModel(model) ? model : undefined;
|
|
}
|
|
}
|