Files
oh-my-pi/packages/coding-agent/src/config/model-registry.ts
T
2026-08-01 12:29:07 +03:00

2806 lines
107 KiB
TypeScript

import { execSync } from "node:child_process";
import * as path from "node:path";
import { registerCustomApi, unregisterCustomApis } from "@oh-my-pi/pi-ai/api-registry";
import type {
Api,
Context,
Model,
ModelSpec,
RemoteCompactionConfig,
SimpleStreamOptions,
ThinkingConfig,
} from "@oh-my-pi/pi-ai/types";
import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { isVertexExpressOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts";
import { readModelCache } from "@oh-my-pi/pi-catalog/model-cache";
import {
createModelManager,
type ModelManagerOptions,
type ModelRefreshStrategy,
} from "@oh-my-pi/pi-catalog/model-manager";
import { getBundledModels, getBundledProviders } from "@oh-my-pi/pi-catalog/models";
import {
googleAntigravityModelManagerOptions,
googleGeminiCliModelManagerOptions,
type OpenAICodexAccount,
openaiCodexModelManagerOptions,
PROVIDER_DESCRIPTORS,
resolveModelCacheProviderId,
} from "@oh-my-pi/pi-catalog/provider-models";
import {
collapseBuiltModelVariants,
getVariantAliasSources,
resolveVariantAlias,
} from "@oh-my-pi/pi-catalog/variant-collapse";
const SPECIAL_MODEL_MANAGER_PROVIDER_IDS: readonly string[] = [
"google-antigravity",
"google-gemini-cli",
"openai-codex",
];
const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [
...PROVIDER_DESCRIPTORS.map(descriptor => descriptor.providerId),
...SPECIAL_MODEL_MANAGER_PROVIDER_IDS,
];
// Sentinels for local-only OAuth tokens — declared inline to avoid loading
// provider modules at startup. Must match packages/ai/src/registry/llama-cpp.ts,
// packages/ai/src/registry/lm-studio.ts, and packages/ai/src/registry/vllm.ts.
const LOCAL_PROVIDER_PLACEHOLDERS = new Set<string>(["llama-cpp-local", "lm-studio-local", "vllm-local"]);
/**
* Hard bound for extension-provided fetchDynamicModels to prevent indefinite hangs
* during runtime provider discovery. Uses a cancellable manual timer (not AbortSignal.timeout)
* so a successful fast path does not leave an armed timeout signal for concurrent GC.
*/
const RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS = 15_000;
// Built-in discovery preflight mirror of the catalog model-manager's private
// cache timings (model-manager.ts: DEFAULT_CACHE_TTL_MS / NON_AUTHORITATIVE_RETRY_MS).
// Built-in descriptors never override cacheTtlMs, so agreeing with these values
// makes the OAuth-refresh preflight fire exactly when the manager will fetch.
const BUILT_IN_DISCOVERY_CACHE_TTL_MS = 2 * 60 * 60 * 1000;
const BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS = 5 * 60 * 1000;
import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai";
import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
import { setCodexAttestationProvider } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
import { getProviderDefinition } from "@oh-my-pi/pi-ai/registry";
import {
getBundledModelReferenceIndex,
inheritReferenceThinking,
resolveModelReference,
} from "@oh-my-pi/pi-catalog/identity";
import { isBunTestRuntime, isRecord, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils";
import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
import { generateCodexAttestation } from "../live/attestation";
import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
import { type ApiKeyResolverModel, type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver";
import type { ConfigError, ConfigFile } from "./config-file";
import {
applyLlamaCppQwenThinking,
DISCOVERY_DEFAULT_MAX_TOKENS,
type DiscoveryContext,
type DiscoveryProviderConfig,
discoverLlamaCppModelRuntimeMetadata,
discoverModelsByProviderType,
getImplicitOllamaBaseUrl,
getOllamaContextLengthOverride,
normalizeLiteLLMDiscoveryBaseUrl,
} from "./model-discovery";
import { ModelsConfigFile, type ProviderValidationModel, validateProviderConfiguration } from "./models-config";
import type { ModelOverride, ModelsConfig, ProviderAuthMode } from "./models-config-schema";
import { settings } from "./settings";
// DeviceCheck attestation (`x-oai-attestation`) for ChatGPT-OAuth Codex
// requests; the pi-ai provider resolves it just-in-time per request.
setCodexAttestationProvider(generateCodexAttestation);
export const kNoAuth = "N/A";
export function isAuthenticated(apiKey: string | undefined | null): apiKey is string {
return Boolean(apiKey) && apiKey !== kNoAuth;
}
function isDiscoveryBearerApiKey(apiKey: string | undefined | null): apiKey is string {
return isAuthenticated(apiKey) && !LOCAL_PROVIDER_PLACEHOLDERS.has(apiKey);
}
/**
* Wraps an extension-provided fetchDynamicModels call with a hard timeout.
* Uses a cancellable manual timer (not AbortSignal.timeout) so that a fast
* successful path does not leave an armed timeout signal for concurrent GC.
* The inner fetcher does not receive a signal (extension contract has none).
*/
async function withRuntimeDynamicModelsTimeout<T>(timeoutMs: number, run: () => Promise<T>): Promise<T> {
const { promise: timeoutPromise, reject: timeoutReject } = Promise.withResolvers<never>();
const timer = setTimeout(() => {
timeoutReject(new Error(`fetchDynamicModels timed out after ${timeoutMs}ms`));
}, timeoutMs);
try {
return await Promise.race([run(), timeoutPromise]);
} finally {
clearTimeout(timer);
}
}
/** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */
interface ProviderOverride {
baseUrl?: string;
headers?: Record<string, string>;
apiKey?: string;
authHeader?: boolean;
compat?: ModelSpec<Api>["compat"];
remoteCompaction?: RemoteCompactionConfig<Api>;
transport?: Model<Api>["transport"];
}
/**
* Merge a freshly discovered model with the matching bundled/configured entry
* (or a runtime provider override when no bundled entry exists).
*
* `baseUrl` resolution priority:
* 1. User-set `providerOverride.baseUrl` (explicit override in models.json)
* 2. Discovered baseUrl (xiaomi `tp-` token-plan keys resolve to
* `token-plan-sgp.xiaomimimo.com` at discovery time)
* 3. Existing bundled baseUrl (the host baked into `models.json`)
*
* `transport` resolution priority:
* 1. `providerOverride.transport` (e.g. `pi-native` for auth-gateway users)
* 2. `existing.transport` (carried over from boot-time override application)
* 3. `model.transport` (rarely set — discovery defaults omit it)
*
* Without (1), the user's override would lose to discovery; without (2)
* preferred over (3), the bundled `api.xiaomimimo.com` would shadow the
* tp- token-plan host and produce 401s on the first stream call.
* Without explicit transport propagation, an openrouter (or any) entry
* marked `transport: pi-native` in models.yml silently reverts to the
* default openai-completions transport after the background catalog
* refresh — so the first `/model` switch after boot hits the raw OpenAI
* chat-completions URL instead of the gateway's `/v1/pi/stream` (#2555).
* See `xiaomi-tp-discovery-merge.test.ts` and the `refresh()` baseUrl-override
* regression in `model-registry.test.ts`.
*/
export function mergeDiscoveredModel<TApi extends Api>(
model: Model<TApi>,
existing: Model<Api> | undefined,
providerOverride?: Pick<ProviderOverride, "baseUrl" | "compat" | "headers" | "remoteCompaction" | "transport">,
): Model<TApi> {
if (existing) {
const supportsTools = model.supportsTools ?? existing.supportsTools;
return buildModel({
...model,
baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl,
headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
transport: providerOverride?.transport ?? existing.transport ?? model.transport,
remoteCompaction: mergeProviderRemoteCompactionConfig(
mergeRemoteCompactionConfig(existing.remoteCompaction, model.remoteCompaction),
providerOverride?.remoteCompaction,
),
...(supportsTools !== undefined ? { supportsTools } : {}),
compat: mergeCompat(model.compatConfig, providerOverride?.compat),
} as ModelSpec<TApi>);
}
if (providerOverride) {
return buildModel({
...model,
baseUrl: providerOverride.baseUrl ?? model.baseUrl,
headers: providerOverride.headers ? { ...model.headers, ...providerOverride.headers } : model.headers,
...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}),
remoteCompaction: mergeProviderRemoteCompactionConfig(
model.remoteCompaction,
providerOverride.remoteCompaction,
),
compat: mergeCompat(model.compatConfig, providerOverride.compat),
} as ModelSpec<TApi>);
}
return model;
}
const AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS = new Set<string>(
PROVIDER_DESCRIPTORS.filter(descriptor => descriptor.dynamicModelsAuthoritative).map(
descriptor => descriptor.providerId,
),
);
function isAuthoritativeProjectCatalogModel(model: Model<Api>): boolean {
return (
model.provider === "google-vertex" &&
model.api === "openai-completions" &&
isVertexExpressOpenAIUrl(model.baseUrl)
);
}
function providersWithAuthoritativeProjectCatalog(models: readonly Model<Api>[]): Set<string> {
const providers = new Set<string>();
for (const model of models) {
if (isAuthoritativeProjectCatalogModel(model)) {
providers.add(model.provider);
}
}
return providers;
}
function dropProviderModels(models: readonly Model<Api>[], providers: ReadonlySet<string>): Model<Api>[] {
return models.filter(model => !providers.has(model.provider));
}
/**
* Merge `incoming` entries into a copy of `base`, keyed by `provider`+`id`.
* Matches are replaced with `combine(existing, entry)`; new entries are
* appended as `combine(undefined, entry)`.
*/
function mergeByModelKey<T extends { provider: string; id: string }>(
base: readonly Model<Api>[],
incoming: readonly T[],
combine: (existing: Model<Api> | undefined, entry: T) => Model<Api>,
): Model<Api>[] {
const merged = [...base];
const indexByKey = new Map<string, number>();
for (let i = 0; i < merged.length; i += 1) {
indexByKey.set(`${merged[i].provider}\u0000${merged[i].id}`, i);
}
for (const entry of incoming) {
const key = `${entry.provider}\u0000${entry.id}`;
const existingIndex = indexByKey.get(key);
if (existingIndex !== undefined) {
merged[existingIndex] = combine(merged[existingIndex], entry);
} else {
merged.push(combine(undefined, entry));
indexByKey.set(key, merged.length - 1);
}
}
return merged;
}
interface BuiltInDiscoveryResult {
models: Model<Api>[];
authoritativeProviders: Set<string>;
}
export type ProviderDiscoveryStatus = "idle" | "ok" | "empty" | "cached" | "unavailable" | "unauthenticated";
export interface ProviderDiscoveryState {
provider: string;
status: ProviderDiscoveryStatus;
optional: boolean;
stale: boolean;
fetchedAt?: number;
models: string[];
error?: string;
}
/** Result of loading custom models config. */
interface CustomModelsResult {
models?: CustomModelOverlay[];
overrides?: Map<string, ProviderOverride>;
modelOverrides?: Map<string, Map<string, ModelOverride>>;
keylessProviders?: Set<string>;
discoverableProviders?: DiscoveryProviderConfig[];
configuredProviders?: Set<string>;
error?: ConfigError;
found: boolean;
}
const commandValueCache = new Map<string, string>();
// Failed `!command` resolutions (non-zero exit, empty stdout) are negative-cached
// with a TTL instead of forever: a transient failure (locked password manager,
// network hiccup) must not disable the key until process restart, but re-running
// the command on every resolution would restore the execSync storm this cache
// exists to prevent. One probe per TTL window bounds both.
const COMMAND_FAILURE_RETRY_MS = 30_000;
const commandFailureRetryAt = new Map<string, number>();
function isCommandConfigValue(valueConfig: string | undefined): valueConfig is string {
return valueConfig?.startsWith("!") === true;
}
function resolveCommandConfig(command: string): string | undefined {
const cached = commandValueCache.get(command);
if (cached !== undefined) return cached;
const retryAt = commandFailureRetryAt.get(command);
if (retryAt !== undefined && Date.now() < retryAt) return undefined;
try {
const stdout = execSync(command, { encoding: "utf8", timeout: 10_000, windowsHide: true });
const trimmed = stdout.trim();
if (trimmed.length === 0) {
commandFailureRetryAt.set(command, Date.now() + COMMAND_FAILURE_RETRY_MS);
return undefined;
}
commandFailureRetryAt.delete(command);
commandValueCache.set(command, trimmed);
return trimmed;
} catch {
commandFailureRetryAt.set(command, Date.now() + COMMAND_FAILURE_RETRY_MS);
return undefined;
}
}
interface CommandApiKeyResolution {
configured: boolean;
value?: string;
}
/**
* Resolve a models.yml/models.yaml secret/config value to an actual value.
* `!cmd` runs a shell command and returns trimmed stdout, otherwise env vars are
* checked first and the input falls back to a literal value.
*/
function resolveConfigValue(valueConfig: string): string | undefined {
if (valueConfig.startsWith("!")) return resolveCommandConfig(valueConfig.slice(1).trim());
const envValue = Bun.env[valueConfig];
if (envValue) return envValue;
return valueConfig;
}
type HeaderSource = Record<string, string> | undefined;
interface HeaderResolutionOptions {
authHeader?: boolean;
apiKeyConfig?: string;
}
function materializeConfigHeaderSources(
sources: readonly HeaderSource[],
options?: HeaderResolutionOptions,
): Record<string, string> | undefined {
const resolved: Record<string, string> = {};
for (const source of sources) {
if (!source) continue;
for (const [key, value] of Object.entries(source)) {
const next = resolveConfigValue(value);
if (next) resolved[key] = next;
}
}
if (options?.authHeader && options.apiKeyConfig) {
const resolvedKey = resolveConfigValue(options.apiKeyConfig);
if (resolvedKey) resolved.Authorization = `Bearer ${resolvedKey}`;
}
return Object.keys(resolved).length > 0 ? resolved : undefined;
}
function createLiveConfigHeaders(
sources: readonly HeaderSource[],
options?: HeaderResolutionOptions,
): Record<string, string> | undefined {
const liveSources = sources.filter((source): source is Record<string, string> => source !== undefined);
if (liveSources.length === 0 && (!options?.authHeader || !options.apiKeyConfig)) return undefined;
const localHeaders: Record<string, string> = {};
const allSources = [...liveSources, localHeaders];
const current = () => materializeConfigHeaderSources(allSources, options) ?? {};
return new Proxy(localHeaders, {
get(target, property, receiver) {
if (typeof property !== "string") return Reflect.get(target, property, receiver);
return current()[property];
},
set(target, property, value) {
if (typeof property !== "string" || typeof value !== "string") return false;
target[property] = value;
return true;
},
deleteProperty(target, property) {
if (typeof property !== "string") return false;
delete target[property];
return true;
},
has(_target, property) {
if (typeof property !== "string") return false;
return Object.hasOwn(current(), property);
},
ownKeys() {
return Reflect.ownKeys(current());
},
getOwnPropertyDescriptor(_target, property) {
if (typeof property !== "string") return undefined;
const headers = current();
if (!Object.hasOwn(headers, property)) return undefined;
return {
configurable: true,
enumerable: true,
value: headers[property],
writable: true,
};
},
});
}
function resolveConfigHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined {
return materializeConfigHeaderSources([headers]);
}
function extractGoogleOAuthToken(value: string | undefined): string | undefined {
if (!isAuthenticated(value)) return undefined;
try {
const parsed = JSON.parse(value) as { token?: unknown };
if (Object.hasOwn(parsed, "token")) {
if (typeof parsed.token !== "string") {
return undefined;
}
const token = parsed.token.trim();
return token.length > 0 ? token : undefined;
}
} catch {
// OAuth values for Google providers are expected to be JSON, but custom setups may already provide raw token.
}
return value;
}
function getOAuthCredentialsForProvider(authStorage: AuthStorage, provider: string): OAuthCredential[] {
const providerEntry = authStorage.getAll()[provider];
if (!providerEntry) {
return [];
}
const entries = Array.isArray(providerEntry) ? providerEntry : [providerEntry];
return entries.filter((entry): entry is OAuthCredential => entry.type === "oauth");
}
/**
* Resolve every configured Codex OAuth account for catalog discovery, refreshing
* each credential exactly once. Codex `/models` is account-scoped, so discovery
* must fetch per account and union the results; resolving a single access token
* (as before) hid models available only through a sibling account (#6265).
*
* Returns `null` when any stored account fails to resolve (e.g. a transient
* refresh failure): the Codex manager is authoritative, so unioning only the
* accounts that resolved would cache a partial catalog and hide the failed
* account's models for the cache TTL. Aborting keeps the previous/bundled
* catalog instead.
*/
async function resolveCodexDiscoveryAccounts(
authStorage: AuthStorage,
resolvedAccessToken: string,
): Promise<OpenAICodexAccount[] | null> {
const accesses = await authStorage.getOAuthAccesses("openai-codex");
const accounts: OpenAICodexAccount[] = [];
for (const access of accesses) {
if (!access.ok) return null;
accounts.push({ accessToken: access.accessToken, accountId: access.accountId });
}
if (!accounts.some(account => account.accessToken === resolvedAccessToken)) {
const matchingCredential = getOAuthCredentialsForProvider(authStorage, "openai-codex").find(
credential => credential.access === resolvedAccessToken,
);
accounts.push({ accessToken: resolvedAccessToken, accountId: matchingCredential?.accountId });
}
return accounts;
}
function mergeCompat<TBase extends object, TOverride extends object>(
baseCompat: TBase | null | undefined,
overrideCompat: TOverride | null | undefined,
): (TBase & TOverride) | TBase | TOverride | undefined {
if (!baseCompat) return overrideCompat ?? undefined;
if (!overrideCompat) return baseCompat;
const merged: Record<string, unknown> = { ...(baseCompat as Record<string, unknown>) };
for (const [key, overrideValue] of Object.entries(overrideCompat)) {
const baseValue = (baseCompat as Record<string, unknown>)[key];
merged[key] =
isRecord(baseValue) && isRecord(overrideValue) ? mergeCompat(baseValue, overrideValue) : overrideValue;
}
return merged as TBase & TOverride;
}
function mergeRemoteCompactionConfig(
baseConfig: RemoteCompactionConfig<Api> | undefined,
overrideConfig: RemoteCompactionConfig<Api> | undefined,
): RemoteCompactionConfig<Api> | undefined {
if (!baseConfig) return overrideConfig;
if (!overrideConfig) return baseConfig;
return { ...baseConfig, ...overrideConfig };
}
function mergeProviderRemoteCompactionConfig(
modelConfig: RemoteCompactionConfig<Api> | undefined,
providerConfig: RemoteCompactionConfig<Api> | undefined,
): RemoteCompactionConfig<Api> | undefined {
return mergeRemoteCompactionConfig(providerConfig, modelConfig);
}
/**
* Project a built model back to spec shape for the model-manager/cache
* boundary: sparse compat comes from `compatConfig`, never from the resolved
* record.
*/
function toModelSpec<TApi extends Api>(model: Model<TApi>): ModelSpec<TApi> {
return { ...model, compat: model.compatConfig } as ModelSpec<TApi>;
}
/**
* The patchable subset of `Model` fields shared by `modelOverrides` entries,
* custom model definitions, and parsed custom-model overlays. `undefined`
* always means "leave the base value alone".
*/
interface ModelPatch {
name?: string;
reasoning?: boolean;
thinking?: ThinkingConfig;
input?: ("text" | "image")[];
supportsTools?: boolean;
cost?: Partial<Model<Api>["cost"]>;
contextWindow?: number;
maxTokens?: number;
omitMaxOutputTokens?: boolean;
headers?: Record<string, string>;
compat?: ModelSpec<Api>["compat"];
contextPromotionTarget?: string;
compactionModel?: string;
remoteCompaction?: RemoteCompactionConfig<Api>;
premiumMultiplier?: number;
}
/**
* How a patch treats the base model's transport metadata (headers/compat):
* - `merge`: fold the patch into the base's (modelOverrides semantics).
* - `replace`: the patch owns transport wholesale — same-id custom definitions
* already folded provider-level headers/compat in during parsing, so bundled
* transport metadata must not be re-merged (see `#mergeCustomModels`).
*/
type ModelTransportPolicy = "merge" | "replace";
/**
* Credential-aware model projection supplied by an extension provider. Receives
* the fully composed catalog and returns the list the host should serve.
*/
type ModifyModelsHook = (models: Model<Api>[], credentials: OAuthCredentials) => Model<Api>[];
function applyModelPatch(base: Model<Api>, patch: ModelPatch, transport: ModelTransportPolicy): Model<Api> {
const result = { ...base };
if (patch.name !== undefined) result.name = patch.name;
if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
if (patch.thinking !== undefined) result.thinking = patch.thinking;
if (patch.input !== undefined) result.input = patch.input;
if (patch.supportsTools !== undefined) result.supportsTools = patch.supportsTools;
if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
if (patch.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = patch.omitMaxOutputTokens;
if (patch.contextPromotionTarget !== undefined) result.contextPromotionTarget = patch.contextPromotionTarget;
if (patch.compactionModel !== undefined) result.compactionModel = patch.compactionModel;
if (patch.remoteCompaction !== undefined) {
result.remoteCompaction = mergeRemoteCompactionConfig(base.remoteCompaction, patch.remoteCompaction);
}
if (patch.premiumMultiplier !== undefined) result.premiumMultiplier = patch.premiumMultiplier;
if (patch.cost) {
result.cost = {
input: patch.cost.input ?? base.cost.input,
output: patch.cost.output ?? base.cost.output,
cacheRead: patch.cost.cacheRead ?? base.cost.cacheRead,
cacheWrite: patch.cost.cacheWrite ?? base.cost.cacheWrite,
};
}
let compat: ModelSpec<Api>["compat"];
if (transport === "merge") {
if (patch.headers) {
result.headers = { ...base.headers, ...patch.headers };
}
compat = mergeCompat(base.compatConfig, patch.compat);
} else {
result.headers = patch.headers;
compat = patch.compat;
}
return buildModel({ ...result, compat } as ModelSpec<Api>);
}
function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<Api> {
return applyModelPatch(model, override as ModelPatch, "merge");
}
interface CustomModelDefinitionLike extends ModelPatch {
id: string;
api?: Api;
baseUrl?: string;
cost?: Model<Api>["cost"];
}
interface CustomModelBuildOptions {
useDefaults: boolean;
}
interface CustomModelOverlay extends ModelPatch {
id: string;
provider: string;
api: Api;
baseUrl: string;
cost?: Model<Api>["cost"];
isOAuth?: boolean;
}
function mergeCustomModelHeaders(
providerHeaders: Record<string, string> | undefined,
modelHeaders: Record<string, string> | undefined,
authHeader: boolean | undefined,
apiKeyConfig: string | undefined,
): Record<string, string> | undefined {
return createLiveConfigHeaders([providerHeaders, modelHeaders], { authHeader, apiKeyConfig });
}
function mergeAuthHeaderSources(
sources: readonly HeaderSource[],
authHeader: boolean | undefined,
apiKeyConfig: string | undefined,
): Record<string, string> | undefined {
return createLiveConfigHeaders(sources, { authHeader, apiKeyConfig });
}
/**
* Decide whether a custom-yaml model should force OAuth-style request shaping.
* - Explicit `auth: oauth` → force on.
* endpoints are typically Claude-Code-style proxies (e.g. CLIProxyAPI) that expect
* the cloaked request shape regardless of how the proxy itself is authenticated.
* - Otherwise → unset.
*/
function resolveCustomModelIsOAuth(api: Api, providerAuth: ProviderAuthMode | undefined): boolean | undefined {
if (providerAuth === "oauth") return true;
if (providerAuth !== undefined) return undefined;
if (api === "anthropic-messages") return true;
return undefined;
}
function buildCustomModelOverlay(
providerName: string,
providerBaseUrl: string,
providerApi: Api | undefined,
providerHeaders: Record<string, string> | undefined,
providerApiKey: string | undefined,
authHeader: boolean | undefined,
providerCompat: ModelSpec<Api>["compat"] | undefined,
providerAuth: ProviderAuthMode | undefined,
providerRemoteCompaction: RemoteCompactionConfig<Api> | undefined,
modelDef: CustomModelDefinitionLike,
): CustomModelOverlay | undefined {
const api = modelDef.api ?? providerApi;
if (!api) return undefined;
return {
id: modelDef.id,
provider: providerName,
api,
baseUrl: modelDef.baseUrl ?? providerBaseUrl,
name: modelDef.name,
reasoning: modelDef.reasoning,
thinking: modelDef.thinking,
input: modelDef.input,
supportsTools: modelDef.supportsTools,
cost: modelDef.cost,
contextWindow: modelDef.contextWindow,
maxTokens: modelDef.maxTokens,
omitMaxOutputTokens: modelDef.omitMaxOutputTokens,
headers: mergeCustomModelHeaders(providerHeaders, modelDef.headers, authHeader, providerApiKey),
compat: mergeCompat(providerCompat, modelDef.compat),
contextPromotionTarget: modelDef.contextPromotionTarget,
compactionModel: modelDef.compactionModel,
remoteCompaction: mergeRemoteCompactionConfig(providerRemoteCompaction, modelDef.remoteCompaction),
premiumMultiplier: modelDef.premiumMultiplier,
isOAuth: resolveCustomModelIsOAuth(api, providerAuth),
};
}
function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomModelOverlay {
if (model.id !== "gpt-5.4" || model.provider === "github-copilot" || model.contextWindow !== undefined) {
return model;
}
return { ...model, contextWindow: 1_000_000 };
}
function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuildOptions): Model<Api> {
const resolvedModel = options.useDefaults ? applyStandaloneCustomModelPolicies(model) : model;
const reference = options.useDefaults
? resolveModelReference(resolvedModel.id, getBundledModelReferenceIndex())
: undefined;
const cost =
resolvedModel.cost ??
reference?.cost ??
(options.useDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined);
const input = resolvedModel.input ?? reference?.input ?? (options.useDefaults ? ["text"] : undefined);
const supportsTools = resolvedModel.supportsTools ?? reference?.supportsTools;
return buildModel({
id: resolvedModel.id,
name: resolvedModel.name ?? (options.useDefaults ? resolvedModel.id : undefined),
api: resolvedModel.api,
provider: resolvedModel.provider,
baseUrl: resolvedModel.baseUrl,
reasoning: resolvedModel.reasoning ?? reference?.reasoning ?? (options.useDefaults ? false : undefined),
thinking: inheritReferenceThinking(resolvedModel.thinking, reference, resolvedModel.provider),
input: input as ("text" | "image")[],
...(supportsTools !== undefined ? { supportsTools } : {}),
cost,
contextWindow: resolvedModel.contextWindow ?? reference?.contextWindow ?? (options.useDefaults ? 128000 : null),
maxTokens: resolvedModel.maxTokens ?? reference?.maxTokens ?? (options.useDefaults ? 16384 : null),
headers: resolvedModel.headers,
omitMaxOutputTokens: resolvedModel.omitMaxOutputTokens ?? reference?.omitMaxOutputTokens,
compat: mergeCompat(reference?.compatConfig, resolvedModel.compat),
contextPromotionTarget: resolvedModel.contextPromotionTarget,
compactionModel: resolvedModel.compactionModel,
remoteCompaction: resolvedModel.remoteCompaction,
premiumMultiplier: resolvedModel.premiumMultiplier,
isOAuth: resolvedModel.isOAuth,
} as ModelSpec<Api>);
}
function normalizeSuppressedSelector(
selector: string,
hasLiveModel?: (provider: string, id: string) => boolean,
): string {
const trimmed = selector.trim();
if (!trimmed) return trimmed;
const parsed = parseModelString(trimmed, {
allowMaxSuffix: true,
allowAutoAlias: true,
isLiteralModelId: (provider, id) => hasLiveModel?.(provider, id) === true,
});
if (!parsed) return trimmed;
// Retired effort-tier variant ids normalize to their collapsed logical id
// so persisted suppressions keyed by raw member ids still bind.
const aliasId = resolveVariantAlias(parsed.provider, parsed.id);
return `${parsed.provider}/${aliasId ?? parsed.id}`;
}
/**
* Look up a model's override, falling back to entries keyed by retired
* effort-tier variant ids (models.yml authored before collapsing). A raw key
* only re-binds when no live model holds that id.
*/
function resolveModelOverrideWithAliases(
overrides: Map<string, ModelOverride>,
model: Model<Api>,
hasLiveModel: (provider: string, id: string) => boolean,
): ModelOverride | undefined {
const direct = overrides.get(model.id);
if (direct) return direct;
for (const rawId of getVariantAliasSources(model.provider, model.id)) {
if (hasLiveModel(model.provider, rawId)) continue;
const remapped = overrides.get(rawId);
if (remapped) {
logger.debug("model override re-keyed through variant alias", {
provider: model.provider,
from: rawId,
to: model.id,
});
return remapped;
}
}
return undefined;
}
function getDisabledProviderIdsFromSettings(): Set<string> {
try {
return new Set(settings.get("disabledProviders"));
} catch {
return new Set();
}
}
/** Authentication material returned to legacy extensions for one model request. */
export type ResolvedRequestAuth =
| {
ok: true;
apiKey?: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}
| { ok: false; error: string };
/**
* Model registry - loads and manages models, resolves API keys via AuthStorage.
*/
export class ModelRegistry {
#models: Model<Api>[] = [];
#unprojectedModels: Model<Api>[] = [];
#hasFullSnapshot = false;
#cachedStandardModels: Model<Api>[] = [];
#cachedDiscoverableModels: Model<Api>[] = [];
#cachedAuthoritativeProviders: Set<string> = new Set();
#internedStaticModels: Map<string, Model<Api>> = new Map();
#providerLookupSnapshots: Map<string, Model<Api>[]> = new Map();
#customProviderApiKeys: Map<string, string> = new Map();
#keylessProviders: Set<string> = new Set();
#discoverableProviders: DiscoveryProviderConfig[] = [];
#customModelOverlays: CustomModelOverlay[] = [];
#providerOverrides: Map<string, ProviderOverride> = new Map();
#modelOverrides: Map<string, Map<string, ModelOverride>> = new Map();
#configError: ConfigError | undefined = undefined;
#modelsConfigFile: ConfigFile<ModelsConfig>;
#lastStaticLoadMtime: number | null = null;
#registeredProviderSources: Set<string> = new Set();
#providerDiscoveryStates: Map<string, ProviderDiscoveryState> = new Map();
#cacheDbPath?: string;
#suppressedSelectors: Map<string, number> = new Map();
#backgroundRefresh?: Promise<void>;
#lastDiscoveryWarnings: Map<string, string> = new Map();
// Runtime extension model overlays — persist across refresh() cycles so that
// models registered by extensions survive the model selector's offline reload.
#runtimeModelOverlays: CustomModelOverlay[] = [];
#runtimeProviderApiKeys: Map<string, string> = new Map();
#runtimeProviderOverrides: Map<string, ProviderOverride> = new Map();
// Credential-aware model projections registered via
// `registerProvider({ oauth: { modifyModels } })`. Persisted for the same
// reason as #runtimeModelOverlays: the overlays hold the *pre-projection*
// definitions, so without re-applying the projection every static reload
// would silently revert the provider to its unprojected catalog.
#runtimeModelModifiers: Map<string, ModifyModelsHook> = new Map();
#lastModelModifierWarnings: Map<string, string> = new Map();
#runtimeProvidersBySource: Map<string, Set<string>> = new Map();
#runtimeProviderSourceByName: Map<string, string> = new Map();
// Runtime model managers registered by extensions via fetchDynamicModels.
// Keyed by provider name; use the same SQLite cache path as builtins.
#runtimeModelManagers: Map<string, { options: ModelManagerOptions<Api>; sourceId: string }> = new Map();
#ignoreLocalModelConfig: boolean;
#fetch: FetchImpl;
#resolveCommandBackedApiKey(provider: string): CommandApiKeyResolution {
const keyConfig = this.#customProviderApiKeys.get(provider);
if (!isCommandConfigValue(keyConfig)) return { configured: false };
const value = resolveConfigValue(keyConfig);
if (value) {
this.authStorage.setConfigApiKey(provider, value);
return { configured: true, value };
}
this.authStorage.removeConfigApiKey(provider);
return { configured: true };
}
#installProviderApiKey(provider: string, keyConfig: string): void {
this.#customProviderApiKeys.set(provider, keyConfig);
const resolved = resolveConfigValue(keyConfig);
if (resolved) {
this.authStorage.setConfigApiKey(provider, resolved);
} else if (isCommandConfigValue(keyConfig)) {
this.authStorage.removeConfigApiKey(provider);
}
}
/**
* @param authStorage - Auth storage for API key resolution
*
* Sync constructor — eagerly loads config (including migrations), cache
* metadata, and custom models. Bundled providers are enriched selectively
* when synchronous callers query them. Production boot paths SHOULD prefer
* {@link ModelRegistry.create} so the YAML/JSONC migration step lands off the
* event loop's hot path before the first `tryLoad()` runs.
*/
constructor(
readonly authStorage: AuthStorage,
modelsPath?: string,
options?: {
/**
* Gateway mode: ignore local `models.yml` entirely (provider overrides,
* config API keys, custom models, custom discovery). A broker-backed
* gateway serves only bundled + broker-discovered catalog metadata and
* must never apply client-side credential or routing overrides.
*/
ignoreLocalModelConfig?: boolean;
fetch?: FetchImpl;
},
) {
this.#ignoreLocalModelConfig = options?.ignoreLocalModelConfig ?? false;
this.#fetch =
options?.fetch ??
(isBunTestRuntime()
? () => Promise.reject(new Error("network disabled in model-registry runtime test"))
: wrapFetchForExtraCa(fetch));
this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath);
this.#cacheDbPath = modelsPath ? path.join(path.dirname(modelsPath), "models.db") : undefined;
// Set up fallback resolver for custom provider API keys
this.authStorage.setFallbackResolver(provider => {
const keyConfig = this.#customProviderApiKeys.get(provider);
if (!keyConfig) return undefined;
return resolveConfigValue(keyConfig);
});
// Load config and cache-backed layers synchronously in the constructor.
this.#loadModels();
}
/**
* Reload models from disk (built-in + custom config).
*/
async refresh(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
this.#reloadStaticModels();
this.#suppressedSelectors.clear();
await this.#refreshRuntimeDiscoveries(strategy);
}
refreshInBackground(strategy: ModelRefreshStrategy = "online-if-uncached"): void {
if (this.#backgroundRefresh) {
return;
}
const refreshPromise = this.refresh(strategy)
.catch(error => {
logger.warn("background model refresh failed", {
error: error instanceof Error ? error.message : String(error),
});
})
.finally(() => {
if (this.#backgroundRefresh === refreshPromise) {
this.#backgroundRefresh = undefined;
}
});
this.#backgroundRefresh = refreshPromise;
}
/**
* Wait for any in-flight background model discovery to settle.
*
* Background discovery started by {@link refreshInBackground} is
* fire-and-forget; RPC consumers (e.g. `get_available_models`,
* `set_model`) and deferred `--model` resolution that read the registry
* immediately after session creation can otherwise observe a partial
* catalog before discovery-backed providers have populated `#models`.
* Awaiting the tracked promise ensures the response reflects every
* configured provider once the initial background refresh resolves.
*
* No-op when no refresh is in flight (`#backgroundRefresh` cleared in the
* `finally` of `refreshInBackground` on completion). Resolves immediately
* in that case so already-warm sessions are unaffected. Discovery errors
* remain swallowed by `refreshInBackground`'s existing `.catch`.
*/
async awaitBackgroundRefresh(): Promise<void> {
if (this.#backgroundRefresh) {
await this.#backgroundRefresh;
}
}
async refreshProvider(providerId: string, strategy: ModelRefreshStrategy = "online"): Promise<void> {
this.#reloadStaticModels();
for (const selector of this.#suppressedSelectors.keys()) {
if (selector.startsWith(`${providerId}/`)) {
this.#suppressedSelectors.delete(selector);
}
}
await this.#refreshRuntimeDiscoveries(strategy, new Set([providerId]));
// #reloadStaticModels above may have rebuilt #models from static sources,
// dropping models previously discovered by OTHER runtime providers (their
// fetchDynamicModels results live only in #models + the SQLite cache, not
// in #loadModels' static inputs). Restore them from cache with the default
// online-if-uncached strategy: no network while their cached row is
// fresh, so the scoped refresh above stays the only forced fetch.
const otherRuntimeProviderIds = new Set(
[...this.#runtimeModelManagers.keys()].filter(runtimeId => runtimeId !== providerId),
);
if (otherRuntimeProviderIds.size > 0) {
await this.#refreshRuntimeDiscoveries("online-if-uncached", otherRuntimeProviderIds);
}
}
/**
* Refresh dynamic metadata that can appear only after a local model loads.
*/
async refreshSelectedModelMetadata(model: Model<Api>): Promise<Model<Api>> {
const isLlamaCppDiscovery = this.#discoverableProviders.some(
providerConfig => providerConfig.provider === model.provider && providerConfig.discovery.type === "llama.cpp",
);
if (!isLlamaCppDiscovery) {
return model;
}
this.#ensureFullSnapshot();
const runtimeMetadata = await discoverLlamaCppModelRuntimeMetadata(model, this.#nonResolvingDiscoveryContext());
if (runtimeMetadata === undefined) {
return this.find(model.provider, model.id) ?? model;
}
const { contextWindow, maxTokens, input } = runtimeMetadata;
const current = this.find(model.provider, model.id) ?? model;
const override = this.#resolveLiveModelOverride(current);
const customModel = this.#resolveLiveCustomModelOverlay(current);
const patch: ModelPatch = {};
if (
contextWindow !== undefined &&
override?.contextWindow === undefined &&
customModel?.contextWindow === undefined &&
current.contextWindow !== contextWindow
) {
patch.contextWindow = contextWindow;
}
const effectiveContextWindow =
override?.contextWindow ??
customModel?.contextWindow ??
patch.contextWindow ??
current.contextWindow ??
contextWindow;
if (maxTokens !== undefined && effectiveContextWindow !== undefined) {
const effectiveMaxTokens = Math.min(maxTokens, effectiveContextWindow);
if (
override?.maxTokens === undefined &&
customModel?.maxTokens === undefined &&
current.maxTokens !== effectiveMaxTokens
) {
patch.maxTokens = effectiveMaxTokens;
}
}
if (
input !== undefined &&
override?.input === undefined &&
customModel?.input === undefined &&
(current.input.length !== input.length || current.input.some((value, index) => value !== input[index]))
) {
patch.input = input;
}
if (patch.contextWindow === undefined && patch.maxTokens === undefined && patch.input === undefined) {
return current;
}
const unprojected = resolveProviderModelReference(current.provider, current.id, this.#unprojectedModels);
if (unprojected) {
const patchedBase = applyModelPatch(unprojected, patch, "merge");
this.#unprojectedModels = this.#unprojectedModels.map(candidate =>
candidate.provider === unprojected.provider && candidate.id === unprojected.id ? patchedBase : candidate,
);
this.#models = this.#applyRuntimeModelModifiers(this.#unprojectedModels);
return resolveProviderModelReference(current.provider, current.id, this.#models) ?? patchedBase;
}
const patched = applyModelPatch(current, patch, "merge");
this.#models = this.#models.map(candidate =>
candidate.provider === current.provider && candidate.id === current.id ? patched : candidate,
);
return patched;
}
/**
* Discover models for providers registered at runtime via `fetchDynamicModels`
* (extension providers). Merges the discovered catalog into the existing model
* set without reloading static models, so dynamically-discovered models from
* other providers are preserved. No-op when no runtime providers are registered.
*
* Drives the same SQLite model cache as built-in providers, so the default
* `online-if-uncached` strategy fetches at most once per cache TTL (24 h).
*/
async refreshRuntimeProviders(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
if (this.#runtimeModelManagers.size === 0) {
return;
}
await this.#refreshRuntimeDiscoveries(strategy, new Set(this.#runtimeModelManagers.keys()));
}
#reloadStaticModels(): void {
const currentMtime = this.#modelsConfigFile.getMtimeMs();
if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) {
// Models config unchanged since last load; reloading would be redundant.
return;
}
this.#modelsConfigFile.invalidate();
this.#customProviderApiKeys.clear();
this.#keylessProviders.clear();
this.#discoverableProviders = [];
// Drop config-sourced apiKeys from AuthStorage before reload; entries
// removed from models.yml must actually disappear from the resolver, not
// linger from the previous parse. The post-load setters below repopulate.
this.authStorage.clearConfigApiKeys();
// Restore runtime API keys before #loadModels — survives because
// #loadModels only calls .set() on #customProviderApiKeys, never reassigns it.
for (const [k, v] of this.#runtimeProviderApiKeys) {
this.#installProviderApiKey(k, v);
}
this.#providerOverrides.clear();
this.#modelOverrides.clear();
this.#configError = undefined;
this.#providerDiscoveryStates.clear();
this.#loadModels();
}
/**
* Get any error from loading custom models config (undefined if no error).
*/
getError(): ConfigError | undefined {
return this.#configError;
}
#loadModels() {
this.#resetStaticComposition();
// Load custom config first (to know which providers to override).
const {
models: customModels = [],
overrides = new Map(),
modelOverrides = new Map(),
keylessProviders = new Set(),
discoverableProviders = [],
configuredProviders = new Set(),
error: configError,
} = this.#loadCustomModels();
this.#configError = configError;
this.#keylessProviders = keylessProviders;
this.#discoverableProviders = discoverableProviders;
this.#customModelOverlays = customModels;
this.#providerOverrides = overrides;
this.#modelOverrides = modelOverrides;
this.#addImplicitDiscoverableProviders(configuredProviders);
const cachedStandardResult = this.#loadCachedStandardProviderModels();
this.#cachedStandardModels = this.#applyHardcodedModelPolicies(cachedStandardResult.models);
this.#cachedDiscoverableModels = this.#applyHardcodedModelPolicies(this.#loadCachedDiscoverableModels());
// Only drop bundled fallback models when the cached project-catalog row is
// itself fresh AND authoritative. A stale or non-authoritative snapshot
// (e.g. after ADC discovery failure rewrote the row with authoritative=0)
// must not strip bundled Vertex Gemini entries — that would leave only the
// stale project-scoped rows in API-key-only environments.
this.#cachedAuthoritativeProviders = new Set<string>();
for (const provider of providersWithAuthoritativeProjectCatalog(this.#cachedStandardModels)) {
if (cachedStandardResult.authoritativeFreshProviders.has(provider)) {
this.#cachedAuthoritativeProviders.add(provider);
}
}
for (const provider of cachedStandardResult.authoritativeFreshProviders) {
if (AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS.has(provider)) {
this.#cachedAuthoritativeProviders.add(provider);
}
}
this.#lastStaticLoadMtime = this.#modelsConfigFile.getMtimeMs();
}
#resetStaticComposition(): void {
this.#models = [];
this.#unprojectedModels = [];
this.#hasFullSnapshot = false;
this.#internedStaticModels.clear();
this.#providerLookupSnapshots.clear();
}
#knownStaticProviders(): string[] {
const providers = new Set<string>(getBundledProviders());
for (const model of this.#cachedStandardModels) providers.add(model.provider);
for (const model of this.#cachedDiscoverableModels) providers.add(model.provider);
for (const model of this.#customModelOverlays) providers.add(model.provider);
for (const model of this.#runtimeModelOverlays) providers.add(model.provider);
return [...providers];
}
#internStaticModels(models: Model<Api>[]): Model<Api>[] {
return models.map(model => {
const key = `${model.provider}\u0000${model.id}`;
const interned = this.#internedStaticModels.get(key);
if (interned) return interned;
this.#internedStaticModels.set(key, model);
return model;
});
}
/**
* Re-apply the credential-aware projections registered by extension providers.
*
* Runtime overlays hold the pre-projection definitions, so the registry keeps
* those definitions separate from `#models` and reruns the ordered hooks after
* every catalog rebuild. Otherwise an offline refresh silently restores the
* provider's placeholder catalog.
*
* A throwing hook falls back to the catalog produced by earlier hooks instead
* of failing the whole composition; one bad extension must not empty the
* registry. The failure is logged (deduped per provider) so it is not silent.
* Each hook receives a deep clone because the public contract permits
* mutation of both the array and its model records before returning.
*/
#applyRuntimeModelModifiers(models: Model<Api>[]): Model<Api>[] {
if (this.#runtimeModelModifiers.size === 0) return models;
let projected = models;
for (const [providerName, modifyModels] of this.#runtimeModelModifiers) {
const credential = this.authStorage.getOAuthCredential(providerName);
if (!credential) continue;
try {
projected = modifyModels(structuredClone(projected), credential);
} catch (error) {
this.#warnModelModifierFailure(providerName, error instanceof Error ? error.message : String(error));
}
}
return projected;
}
/**
* Dedup key is separate from `#lastDiscoveryWarnings` so a repeated modifier
* failure cannot mask a subsequent discovery failure for the same provider.
*/
#warnModelModifierFailure(provider: string, error: string): void {
if (this.#lastModelModifierWarnings.get(provider) === error) return;
this.#lastModelModifierWarnings.set(provider, error);
logger.warn("extension model projection failed; serving unprojected catalog", { provider, error });
}
#composeUnprojectedStaticModels(providerFilter?: ReadonlySet<string>): Model<Api>[] {
const select = <T extends { provider: string }>(models: readonly T[]): T[] =>
providerFilter ? models.filter(model => providerFilter.has(model.provider)) : [...models];
let builtInModels = this.#applyHardcodedModelPolicies(
this.#loadBuiltInModels(this.#providerOverrides, providerFilter),
);
if (this.#cachedAuthoritativeProviders.size > 0) {
builtInModels = dropProviderModels(builtInModels, this.#cachedAuthoritativeProviders);
}
const resolvedDefaults = this.#mergeResolvedModels(
this.#mergeResolvedModels(builtInModels, select(this.#cachedStandardModels)),
select(this.#cachedDiscoverableModels),
);
const withConfigModels = this.#mergeCustomModels(resolvedDefaults, select(this.#customModelOverlays));
const combined = this.#mergeCustomModels(withConfigModels, select(this.#runtimeModelOverlays));
const withModelOverrides = this.#applyModelOverrides(collapseBuiltModelVariants(combined), this.#modelOverrides);
return this.#applyLlamaCppQwenThinkingToModels(this.#applyRuntimeProviderOverrides(withModelOverrides));
}
#composeStaticModels(providerFilter?: ReadonlySet<string>): Model<Api>[] {
// A modifier is a whole-catalog transform. Build and project the full catalog
// before narrowing a lazy lookup, matching getAll() followed by filtering.
const projectFullCatalog = providerFilter !== undefined && this.#runtimeModelModifiers.size > 0;
const unprojected = this.#composeUnprojectedStaticModels(projectFullCatalog ? undefined : providerFilter);
const projected = this.#applyRuntimeModelModifiers(unprojected);
const selected = projectFullCatalog ? projected.filter(model => providerFilter.has(model.provider)) : projected;
return this.#internStaticModels(selected);
}
#ensureFullSnapshot(): Model<Api>[] {
if (!this.#hasFullSnapshot) {
this.#unprojectedModels = this.#composeUnprojectedStaticModels();
this.#models = this.#internStaticModels(this.#applyRuntimeModelModifiers(this.#unprojectedModels));
this.#hasFullSnapshot = true;
this.#providerLookupSnapshots.clear();
}
return this.#models;
}
/** Load built-in models, applying provider-level overrides only.
* Per-model overrides are applied later by #applyModelOverrides. */
#loadBuiltInModels(overrides: Map<string, ProviderOverride>, providerFilter?: ReadonlySet<string>): Model<Api>[] {
return getBundledProviders().flatMap(provider => {
if (providerFilter && !providerFilter.has(provider)) return [];
const models = getBundledModels(provider as Parameters<typeof getBundledModels>[0]) as Model<Api>[];
const providerOverride = overrides.get(provider);
return models.map(m => {
if (!providerOverride) return m;
const withTransportOverride = this.#applyProviderTransportOverride(m, providerOverride);
return buildModel({
...withTransportOverride,
compat: mergeCompat(m.compatConfig, providerOverride.compat),
} as ModelSpec<Api>);
});
});
}
#mergeResolvedModels(baseModels: Model<Api>[], replacementModels: Model<Api>[]): Model<Api>[] {
return mergeByModelKey(baseModels, replacementModels, (existing, replacementModel) => {
if (!existing) return replacementModel;
const supportsTools = replacementModel.supportsTools ?? existing.supportsTools;
return {
...replacementModel,
contextWindow: replacementModel.contextWindow ?? existing.contextWindow,
maxTokens: replacementModel.maxTokens ?? existing.maxTokens,
omitMaxOutputTokens: replacementModel.omitMaxOutputTokens ?? existing.omitMaxOutputTokens,
...(supportsTools !== undefined ? { supportsTools } : {}),
};
});
}
/** Merge custom models with built-in, replacing by provider+id match */
#mergeCustomModels(builtInModels: Model<Api>[], customModels: CustomModelOverlay[]): Model<Api>[] {
return mergeByModelKey(builtInModels, customModels, (existingModel, customModel) => {
if (!existingModel) return finalizeCustomModel(customModel, { useDefaults: true });
// Same-id custom definitions replace bundled transport behavior, so the
// patch is applied with the `replace` transport policy.
return applyModelPatch(
{
...existingModel,
id: customModel.id,
provider: customModel.provider,
api: customModel.api,
baseUrl: customModel.baseUrl,
},
customModel,
"replace",
);
});
}
#descriptorBaseUrl(providerId: string): string | undefined {
return (
this.#runtimeProviderOverrides.get(providerId)?.baseUrl ??
this.#providerOverrides.get(providerId)?.baseUrl ??
(this.#hasFullSnapshot ? this.getProviderBaseUrl(providerId) : undefined)
);
}
#resolveStartupModelCacheProviderId(providerId: string): string {
const baseUrl =
this.#runtimeProviderOverrides.get(providerId)?.baseUrl ??
this.#providerOverrides.get(providerId)?.baseUrl ??
(this.#hasFullSnapshot ? this.getProviderBaseUrl(providerId) : undefined);
return resolveModelCacheProviderId(providerId, { baseUrl });
}
#loadCachedStandardProviderModels(): { models: Model<Api>[]; authoritativeFreshProviders: Set<string> } {
const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(provider => provider.provider));
const cachedModels: Model<Api>[] = [];
const authoritativeFreshProviders = new Set<string>();
for (const providerId of STARTUP_MODEL_CACHE_PROVIDER_IDS) {
if (configuredDiscoveryProviders.has(providerId)) {
continue;
}
const cacheProviderId = this.#resolveStartupModelCacheProviderId(providerId);
const cache = readModelCache<Api>(cacheProviderId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
if (!cache) {
continue;
}
if (cache.fresh && cache.authoritative) {
authoritativeFreshProviders.add(providerId);
}
// The v10 model cache never persists request headers (#5780): restore
// them from the bundled static catalog, and drop cached rows whose
// headers cannot be rebuilt so the bundled fallback (which still
// carries its headers) wins the startup merge instead of a cached
// model with required transport headers missing.
const omittedHeaderIds = new Set(cache.headerOmittedModelIds);
const unrestorableHeaderIds = new Set(cache.unrestorableHeaderModelIds);
const bundledById =
omittedHeaderIds.size > 0
? new Map(
(getBundledModels(providerId as Parameters<typeof getBundledModels>[0]) as Model<Api>[]).map(
bundledModel => [bundledModel.id, bundledModel],
),
)
: undefined;
const models: ModelSpec<Api>[] = [];
for (const cachedModel of cache.models) {
const spec = cachedModel.provider === providerId ? cachedModel : { ...cachedModel, provider: providerId };
if (!omittedHeaderIds.has(spec.id)) {
models.push(spec);
continue;
}
// Current unrestorable markers prove that neither same-id nor
// request-model bundled headers matched the live model. Only markers
// from the old id-only writer may recover through `requestModelId`.
const unrestorable = unrestorableHeaderIds.has(spec.id);
const bundledHeaders = (
unrestorable
? cache.legacyHeaderRestoreMarkers && spec.requestModelId
? bundledById?.get(spec.requestModelId)
: undefined
: (bundledById?.get(spec.id) ??
(spec.requestModelId ? bundledById?.get(spec.requestModelId) : undefined))
)?.headers;
if (!bundledHeaders) continue;
models.push({ ...spec, headers: bundledHeaders });
}
const providerOverride = this.#providerOverrides.get(providerId);
const withTransport = providerOverride
? models.map(model => this.#applyProviderTransportOverride(model, providerOverride))
: models;
const withCompat = providerOverride?.compat
? withTransport.map(model =>
buildModel({
...model,
compat: mergeCompat(model.compat, providerOverride.compat),
} as ModelSpec<Api>),
)
: withTransport.map(model => buildModel(model));
cachedModels.push(...this.#applyProviderModelOverrides(providerId, withCompat));
}
return { models: cachedModels, authoritativeFreshProviders };
}
#loadCachedDiscoverableModels(): Model<Api>[] {
const cachedModels: Model<Api>[] = [];
for (const providerConfig of this.#discoverableProviders) {
const cache = readModelCache<Api>(
this.#configuredDiscoveryCacheProviderId(providerConfig),
24 * 60 * 60 * 1000,
Date.now,
this.#cacheDbPath,
);
if (!cache) {
this.#providerDiscoveryStates.set(providerConfig.provider, {
provider: providerConfig.provider,
status: "idle",
optional: providerConfig.optional ?? false,
stale: false,
models: [],
});
continue;
}
const configStale = this.#isDiscoveryCacheOlderThanModelsConfig(cache.updatedAt);
// Cached rows never persist headers (#5780); models that had live
// headers cannot be rebuilt here, so exclude them and mark the
// discovery stale to force a refetch instead of returning models
// missing required transport headers.
const omittedHeaderIds = new Set(cache.headerOmittedModelIds);
const usableCacheModels =
omittedHeaderIds.size > 0 ? cache.models.filter(model => !omittedHeaderIds.has(model.id)) : cache.models;
const models = this.#applyProviderModelOverrides(
providerConfig.provider,
this.#normalizeDiscoverableModels(
providerConfig,
this.#applyProviderCompat(
providerConfig.compat,
usableCacheModels.map(model => buildModel(model)),
),
),
);
cachedModels.push(...models);
this.#providerDiscoveryStates.set(providerConfig.provider, {
provider: providerConfig.provider,
status: "cached",
optional: providerConfig.optional ?? false,
stale:
providerConfig.discovery.type === "llama.cpp" ||
!cache.fresh ||
!cache.authoritative ||
configStale ||
omittedHeaderIds.size > 0,
fetchedAt: cache.updatedAt,
models: models.map(model => model.id),
});
}
return cachedModels;
}
#applyProviderCompat(compat: ModelSpec<Api>["compat"] | undefined, models: Model<Api>[]): Model<Api>[] {
if (!compat) return models;
return models.map(model =>
buildModel({ ...model, compat: mergeCompat(model.compatConfig, compat) } as ModelSpec<Api>),
);
}
#normalizeDiscoverableModels(providerConfig: DiscoveryProviderConfig, models: Model<Api>[]): Model<Api>[] {
const withDecoderMetadata =
providerConfig.discovery.type === "ollama" ||
providerConfig.discovery.type === "llama.cpp" ||
providerConfig.discovery.type === "lm-studio"
? models.map(model =>
buildModel({ ...model, imageInputDecoder: "stb", compat: model.compatConfig } as ModelSpec<Api>),
)
: models;
const withRemoteCompaction = providerConfig.remoteCompaction
? withDecoderMetadata.map(model =>
buildModel({
...model,
remoteCompaction: mergeProviderRemoteCompactionConfig(
model.remoteCompaction,
providerConfig.remoteCompaction,
),
compat: model.compatConfig,
} as ModelSpec<Api>),
)
: withDecoderMetadata;
if (providerConfig.provider !== "ollama" || providerConfig.api !== "openai-responses") {
return withRemoteCompaction;
}
const contextLengthOverride = getOllamaContextLengthOverride();
return withRemoteCompaction.map(model => {
const normalized =
model.api === "openai-completions"
? buildModel({
...model,
api: "openai-responses" as const,
compat: model.compatConfig,
} as ModelSpec<Api>)
: model;
if (contextLengthOverride === undefined) {
return normalized;
}
return {
...normalized,
contextWindow: contextLengthOverride,
maxTokens: Math.min(contextLengthOverride, DISCOVERY_DEFAULT_MAX_TOKENS),
};
});
}
#addImplicitDiscoverableProviders(configuredProviders: Set<string>): void {
const disabledProviders = getDisabledProviderIdsFromSettings();
if (!configuredProviders.has("ollama") && !disabledProviders.has("ollama")) {
this.#discoverableProviders.push({
provider: "ollama",
api: "openai-responses",
baseUrl: getImplicitOllamaBaseUrl(),
discovery: { type: "ollama" },
optional: true,
});
this.#keylessProviders.add("ollama");
}
if (!configuredProviders.has("llama.cpp") && !disabledProviders.has("llama.cpp")) {
this.#discoverableProviders.push({
provider: "llama.cpp",
api: "openai-responses",
baseUrl: Bun.env.LLAMA_CPP_BASE_URL || "http://127.0.0.1:8080",
discovery: { type: "llama.cpp" },
optional: true,
});
// Only mark as keyless if no API key is configured
if (!this.authStorage.hasAuth("llama.cpp")) {
this.#keylessProviders.add("llama.cpp");
}
}
if (!configuredProviders.has("lm-studio") && !disabledProviders.has("lm-studio")) {
this.#discoverableProviders.push({
provider: "lm-studio",
api: "openai-completions",
baseUrl: Bun.env.LM_STUDIO_BASE_URL || "http://127.0.0.1:1234/v1",
discovery: { type: "lm-studio" },
optional: true,
});
this.#keylessProviders.add("lm-studio");
}
}
#loadCustomModels(): CustomModelsResult {
// Gateway mode: serve bundled + broker-discovered catalog metadata only.
// Local models.yml provider overrides (baseUrl/apiKey/headers/transport),
// custom models, custom discovery, and config API keys are all client-side
// routing that MUST NOT reach a broker-backed gateway — applying them would
// send broker bearers to a configured endpoint, install config keys that
// shadow broker credentials (bypassing account pooling/refresh/accounting),
// or route a pi-native gateway back into itself.
if (this.#ignoreLocalModelConfig) {
return {
models: [],
overrides: new Map(),
modelOverrides: new Map(),
keylessProviders: new Set(),
discoverableProviders: [],
configuredProviders: new Set(),
found: false,
};
}
const { value, error, status } = this.#modelsConfigFile.tryLoad();
if (status === "error") {
return {
models: [],
overrides: new Map(),
modelOverrides: new Map(),
keylessProviders: new Set(),
discoverableProviders: [],
configuredProviders: new Set(),
error,
found: true,
};
} else if (status === "not-found") {
return {
models: [],
overrides: new Map(),
modelOverrides: new Map(),
keylessProviders: new Set(),
discoverableProviders: [],
configuredProviders: new Set(),
found: false,
};
}
const overrides = new Map<string, ProviderOverride>();
const allModelOverrides = new Map<string, Map<string, ModelOverride>>();
const keylessProviders = new Set<string>();
const discoverableProviders: DiscoveryProviderConfig[] = [];
const providerEntries = Object.entries(value.providers ?? {});
const configuredProviders = new Set(Object.keys(value.providers ?? {}));
for (const [providerName, providerConfig] of providerEntries) {
const resolvedProviderHeaders = resolveConfigHeaders(providerConfig.headers);
// Always set overrides when baseUrl/headers/apiKey/authHeader/compat/disableStrictTools/transport are present
if (
providerConfig.baseUrl ||
resolvedProviderHeaders ||
providerConfig.apiKey ||
providerConfig.authHeader !== undefined ||
providerConfig.compat ||
providerConfig.disableStrictTools ||
providerConfig.remoteCompaction ||
providerConfig.transport
) {
const disableStrictCompat = providerConfig.disableStrictTools ? { disableStrictTools: true } : undefined;
overrides.set(providerName, {
baseUrl:
providerConfig.discovery?.type === "litellm"
? normalizeLiteLLMDiscoveryBaseUrl(providerConfig.baseUrl)
: providerConfig.baseUrl,
headers: resolvedProviderHeaders,
apiKey: providerConfig.apiKey,
authHeader: providerConfig.authHeader,
compat: mergeCompat(providerConfig.compat, disableStrictCompat),
remoteCompaction: providerConfig.remoteCompaction,
transport: providerConfig.transport,
});
}
const authMode = (providerConfig.auth ?? "apiKey") as ProviderAuthMode;
if (authMode === "none") {
keylessProviders.add(providerName);
}
if (providerConfig.discovery && (providerConfig.api || providerConfig.discovery.type === "proxy")) {
const disableStrictCompat = providerConfig.disableStrictTools ? { disableStrictTools: true } : undefined;
discoverableProviders.push({
provider: providerName,
// Proxy discovery derives per-model api from /v1/models's
// supported_endpoint_types; the provider-level api is only a
// fallback for entries that don't advertise one.
api: (providerConfig.api ?? "openai-completions") as Api,
baseUrl: providerConfig.baseUrl,
headers: resolvedProviderHeaders,
compat: mergeCompat(providerConfig.compat, disableStrictCompat),
remoteCompaction: providerConfig.remoteCompaction,
discovery: providerConfig.discovery,
optional: false,
});
}
// Store API key for fallback resolver AND register as config override
// so it wins over OAuth tokens from the broker — when the user pins a
// bearer in models.yml (e.g. for an auth-gateway baseUrl), that bearer
// must authenticate the outbound request.
if (providerConfig.apiKey) {
this.#installProviderApiKey(providerName, providerConfig.apiKey);
}
// Parse per-model overrides
if (providerConfig.modelOverrides) {
const perModel = new Map<string, ModelOverride>();
for (const [modelId, override] of Object.entries(providerConfig.modelOverrides)) {
perModel.set(
modelId,
override.headers ? { ...override, headers: resolveConfigHeaders(override.headers) } : override,
);
}
allModelOverrides.set(providerName, perModel);
}
}
return {
models: this.#parseModels(value),
overrides,
modelOverrides: allModelOverrides,
keylessProviders,
discoverableProviders,
configuredProviders,
found: true,
};
}
async #refreshRuntimeDiscoveries(
strategy: ModelRefreshStrategy,
providerFilter?: ReadonlySet<string>,
): Promise<void> {
const disabledProviders = getDisabledProviderIdsFromSettings();
const selectedDiscoverableProviders = (
providerFilter
? this.#discoverableProviders.filter(provider => providerFilter.has(provider.provider))
: this.#discoverableProviders
).filter(provider => !disabledProviders.has(provider.provider));
const configuredDiscoveriesPromise =
selectedDiscoverableProviders.length === 0
? Promise.resolve<Model<Api>[]>([])
: Promise.all(
selectedDiscoverableProviders.map(provider => this.#discoverProviderModels(provider, strategy)),
).then(results => results.flat());
const [configuredDiscovered, builtInDiscovery] = await Promise.all([
configuredDiscoveriesPromise,
this.#discoverBuiltInProviderModels(strategy, providerFilter),
]);
const discovered = [...configuredDiscovered, ...builtInDiscovery.models];
if (discovered.length === 0 && builtInDiscovery.authoritativeProviders.size === 0) {
return;
}
this.#ensureFullSnapshot();
const discoveredModels = this.#applyHardcodedModelPolicies(
discovered.map(model =>
mergeDiscoveredModel(
model,
resolveProviderModelReference(model.provider, model.id, this.#unprojectedModels),
this.#providerOverrides.get(model.provider),
),
),
);
const authoritativeProviders = providersWithAuthoritativeProjectCatalog(discoveredModels);
for (const provider of builtInDiscovery.authoritativeProviders) {
authoritativeProviders.add(provider);
}
const baseModels =
authoritativeProviders.size > 0
? dropProviderModels(this.#unprojectedModels, authoritativeProviders)
: this.#unprojectedModels;
const resolved = this.#mergeResolvedModels(baseModels, discoveredModels);
const withConfigModels = this.#mergeCustomModels(resolved, this.#customModelOverlays);
const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays);
const withModelOverrides = this.#applyModelOverrides(collapseBuiltModelVariants(combined), this.#modelOverrides);
this.#unprojectedModels = this.#applyLlamaCppQwenThinkingToModels(
this.#applyRuntimeProviderOverrides(withModelOverrides),
);
this.#models = this.#applyRuntimeModelModifiers(this.#unprojectedModels);
}
#configuredDiscoveryCacheProviderId(providerConfig: DiscoveryProviderConfig): string {
if (providerConfig.discovery.type === "openai-models-list") {
return `${providerConfig.provider}:openai-models-list-context-v2`;
}
if (providerConfig.discovery.type === "litellm") {
// rich-v2 invalidates rows cached before reseller usage-suffix stripping
// (stale display names like `MiniMax-M3 (3x usage)`); keep in lockstep
// with the catalog package's `litellm:rich-vN` namespace.
return `${providerConfig.provider}:litellm-rich-v2`;
}
return providerConfig.provider;
}
#isDiscoveryCacheOlderThanModelsConfig(cacheUpdatedAt: number): boolean {
const configMtime = this.#modelsConfigFile.getMtimeMs();
return configMtime !== null && cacheUpdatedAt < Math.floor(configMtime);
}
async #discoverProviderModels(
providerConfig: DiscoveryProviderConfig,
strategy: ModelRefreshStrategy,
): Promise<Model<Api>[]> {
const cacheProviderId = this.#configuredDiscoveryCacheProviderId(providerConfig);
const cached = readModelCache<Api>(cacheProviderId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
const cacheOlderThanConfig = cached !== null && this.#isDiscoveryCacheOlderThanModelsConfig(cached.updatedAt);
const bypassFreshCache = providerConfig.discovery.type === "llama.cpp" && strategy === "online-if-uncached";
const effectiveStrategy =
strategy === "online-if-uncached" && (cacheOlderThanConfig || bypassFreshCache) ? "online" : strategy;
const requiresAuth = !this.#keylessProviders.has(providerConfig.provider);
if (requiresAuth) {
const apiKey = await this.#peekApiKeyForProvider(providerConfig.provider);
if (!isAuthenticated(apiKey)) {
this.#providerDiscoveryStates.set(providerConfig.provider, {
provider: providerConfig.provider,
status: "unauthenticated",
optional: providerConfig.optional ?? false,
stale: cached !== null,
fetchedAt: cached?.updatedAt,
models: cached?.models.map(model => model.id) ?? [],
});
this.#lastDiscoveryWarnings.delete(providerConfig.provider);
return cached
? this.#normalizeDiscoverableModels(
providerConfig,
cached.models.map(model => buildModel(model)),
)
: [];
}
}
const providerId = providerConfig.provider;
let discoveryError: string | undefined;
const fetchDynamicModels = async (): Promise<readonly ModelSpec<Api>[] | null> => {
try {
const models = this.#applyProviderModelOverrides(
providerId,
await discoverModelsByProviderType(providerConfig, this.#discoveryContext()),
);
this.#lastDiscoveryWarnings.delete(providerId);
return models.map(toModelSpec);
} catch (error) {
discoveryError = error instanceof Error ? error.message : String(error);
return null;
}
};
const manager = createModelManager<Api>({
providerId,
staticModels: [],
cacheDbPath: this.#cacheDbPath,
cacheProviderId,
cacheTtlMs: 24 * 60 * 60 * 1000,
fetchDynamicModels,
});
const result = await manager.refresh(effectiveStrategy);
const status = discoveryError
? result.models.length > 0
? "cached"
: "unavailable"
: effectiveStrategy === "offline"
? cached
? "cached"
: "idle"
: result.models.length > 0
? "ok"
: "empty";
this.#providerDiscoveryStates.set(providerId, {
provider: providerId,
status,
optional: providerConfig.optional ?? false,
stale: result.stale || status === "cached" || ((cacheOlderThanConfig || bypassFreshCache) && status !== "ok"),
fetchedAt: discoveryError ? cached?.updatedAt : Date.now(),
models: result.models.map(model => model.id),
error: discoveryError,
});
if (discoveryError) {
this.#warnProviderDiscoveryFailure(providerConfig, discoveryError);
}
return this.#applyProviderModelOverrides(
providerId,
this.#normalizeDiscoverableModels(
providerConfig,
this.#applyProviderCompat(providerConfig.compat, result.models),
),
);
}
#discoveryContext(): DiscoveryContext {
return {
fetch: this.#fetch,
getBearerApiKeyResolver: async provider => {
const apiKey = await this.getApiKeyForProvider(provider);
if (!isDiscoveryBearerApiKey(apiKey)) {
return undefined;
}
return this.resolver(provider);
},
};
}
#nonResolvingDiscoveryContext(): DiscoveryContext {
return {
fetch: this.#fetch,
getBearerApiKeyResolver: async () => undefined,
};
}
#warnProviderDiscoveryFailure(providerConfig: DiscoveryProviderConfig, error: string): void {
const previous = this.#lastDiscoveryWarnings.get(providerConfig.provider);
if (previous === error) {
return;
}
this.#lastDiscoveryWarnings.set(providerConfig.provider, error);
logger.warn("model discovery failed for provider", {
provider: providerConfig.provider,
url: providerConfig.baseUrl,
error,
});
}
async #discoverBuiltInProviderModels(
strategy: ModelRefreshStrategy,
providerFilter?: ReadonlySet<string>,
): Promise<BuiltInDiscoveryResult> {
// Skip providers already handled by configured discovery (e.g. user-configured ollama with discovery.type)
const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(p => p.provider));
const managerOptions = await this.#collectBuiltInModelManagerOptions(
strategy,
providerFilter,
configuredDiscoveryProviders,
);
if (managerOptions.length === 0) {
return { models: [], authoritativeProviders: new Set() };
}
const discoveries = await Promise.all(
managerOptions.map(options => this.#discoverWithModelManager(options, strategy)),
);
const authoritativeProviders = new Set<string>();
const models: Model<Api>[] = [];
for (const discovery of discoveries) {
models.push(...discovery.models);
for (const provider of discovery.authoritativeProviders) {
authoritativeProviders.add(provider);
}
}
return { models, authoritativeProviders };
}
async #resolveBuiltInDiscoveryApiKey(
providerId: string,
strategy: ModelRefreshStrategy,
cacheProviderId: string,
authoritative: boolean,
): Promise<string | undefined> {
const peekedKey = await this.#peekApiKeyForProvider(providerId);
if (isAuthenticated(peekedKey) || strategy === "offline") {
return peekedKey;
}
const oauthCredentials = getOAuthCredentialsForProvider(this.authStorage, providerId);
if (oauthCredentials.length === 0) {
return peekedKey;
}
// Authoritative providers prune bundled models only when their manager is
// actually constructed, which needs an authenticated key. A fresh cache does
// not let us skip the refresh here: with an expired OAuth token peekedKey is
// undefined, the manager is never added, and stale bundled models survive the
// full cache TTL. So only take the no-refresh shortcut for non-authoritative
// providers, whose bundled models stay visible regardless.
if (strategy === "online-if-uncached" && !authoritative) {
// Mirror shouldFetchRemoteSources: built-in managers use the catalog's
// default TTL, so only refresh when the manager will actually fetch.
const cache = readModelCache<Api>(
cacheProviderId,
BUILT_IN_DISCOVERY_CACHE_TTL_MS,
Date.now,
this.#cacheDbPath,
);
const cacheAgeMs = cache ? Date.now() - cache.updatedAt : Number.POSITIVE_INFINITY;
if (cache?.fresh && (cache.authoritative || cacheAgeMs < BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS)) {
return peekedKey;
}
}
try {
return await this.getApiKeyForProvider(providerId);
} catch (error) {
logger.debug("OAuth refresh failed during model discovery preflight", {
provider: providerId,
error: error instanceof Error ? error.message : String(error),
});
return peekedKey;
}
}
async #collectBuiltInModelManagerOptions(
strategy: ModelRefreshStrategy,
providerFilter: ReadonlySet<string> | undefined,
configuredDiscoveryProviders: ReadonlySet<string>,
): Promise<ModelManagerOptions<Api>[]> {
const specialProviderDescriptors: Array<{
providerId: string;
authoritative: boolean;
resolveKey: (value: string | undefined) => string | undefined;
createOptions: (key: string) => ModelManagerOptions<Api>;
}> = [
{
providerId: "google-antigravity",
authoritative: false,
resolveKey: extractGoogleOAuthToken,
createOptions: oauthToken =>
googleAntigravityModelManagerOptions({
oauthToken,
endpoint: this.#descriptorBaseUrl("google-antigravity"),
fetch: this.#fetch,
}),
},
{
providerId: "google-gemini-cli",
authoritative: false,
resolveKey: extractGoogleOAuthToken,
createOptions: oauthToken =>
googleGeminiCliModelManagerOptions({
oauthToken,
endpoint: this.#descriptorBaseUrl("google-gemini-cli"),
fetch: this.#fetch,
}),
},
{
providerId: "openai-codex",
authoritative: true,
resolveKey: value => value,
createOptions: accessToken =>
openaiCodexModelManagerOptions({
resolveAccounts: () => resolveCodexDiscoveryAccounts(this.authStorage, accessToken),
fetch: this.#fetch,
}),
},
];
const disabledProviders = getDisabledProviderIdsFromSettings();
const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(descriptor => {
if (disabledProviders.has(descriptor.providerId)) return false;
if (configuredDiscoveryProviders.has(descriptor.providerId)) return false;
return providerFilter ? providerFilter.has(descriptor.providerId) : true;
});
const enabledSpecialProviderDescriptors = specialProviderDescriptors.filter(descriptor => {
if (disabledProviders.has(descriptor.providerId)) return false;
if (configuredDiscoveryProviders.has(descriptor.providerId)) return false;
return providerFilter ? providerFilter.has(descriptor.providerId) : true;
});
const standardProviderKeys = await Promise.all(
standardProviderDescriptors.map(descriptor => {
const cacheProviderId = this.#resolveStartupModelCacheProviderId(descriptor.providerId);
return this.#resolveBuiltInDiscoveryApiKey(
descriptor.providerId,
strategy,
cacheProviderId,
descriptor.dynamicModelsAuthoritative ?? false,
);
}),
);
const specialKeys = await Promise.all(
enabledSpecialProviderDescriptors.map(descriptor =>
this.#resolveBuiltInDiscoveryApiKey(
descriptor.providerId,
strategy,
descriptor.providerId,
descriptor.authoritative,
),
),
);
const options: ModelManagerOptions<Api>[] = [];
for (let i = 0; i < standardProviderDescriptors.length; i++) {
const descriptor = standardProviderDescriptors[i];
const apiKey = standardProviderKeys[i];
const hasExplicitVllmConfig =
descriptor.providerId === "vllm" &&
(this.#runtimeProviderOverrides.has(descriptor.providerId) ||
this.#providerOverrides.has(descriptor.providerId) ||
this.#keylessProviders.has(descriptor.providerId));
if (isAuthenticated(apiKey) || descriptor.allowUnauthenticated || hasExplicitVllmConfig) {
const discoveryConfig = {
apiKey: isDiscoveryBearerApiKey(apiKey) ? apiKey : undefined,
baseUrl: this.#descriptorBaseUrl(descriptor.providerId),
fetch: this.#fetch,
};
const preparedConfig =
getProviderDefinition(descriptor.providerId)?.prepareModelDiscovery?.(discoveryConfig) ??
discoveryConfig;
options.push(descriptor.createModelManagerOptions(preparedConfig));
}
}
for (let i = 0; i < enabledSpecialProviderDescriptors.length; i++) {
const descriptor = enabledSpecialProviderDescriptors[i];
const key = descriptor.resolveKey(specialKeys[i]);
if (!isAuthenticated(key)) {
continue;
}
options.push(descriptor.createOptions(key));
}
// Append runtime model managers registered by extensions via fetchDynamicModels.
for (const { options: managerOpts } of this.#runtimeModelManagers.values()) {
if (
!configuredDiscoveryProviders.has(managerOpts.providerId) &&
(!providerFilter || providerFilter.has(managerOpts.providerId))
) {
options.push(managerOpts);
}
}
return options;
}
async #discoverWithModelManager(
options: ModelManagerOptions<Api>,
strategy: ModelRefreshStrategy,
): Promise<BuiltInDiscoveryResult> {
try {
const manager = createModelManager({ ...options, cacheDbPath: this.#cacheDbPath });
const result = await manager.refresh(strategy);
const models = result.models.map(model =>
model.provider === options.providerId ? model : { ...model, provider: options.providerId },
);
const authoritativeProviders = new Set<string>();
if (options.dynamicModelsAuthoritative && !result.stale) {
authoritativeProviders.add(options.providerId);
}
return { models, authoritativeProviders };
} catch (error) {
logger.warn("model discovery failed for provider", {
provider: options.providerId,
error: error instanceof Error ? error.message : String(error),
});
return { models: [], authoritativeProviders: new Set() };
}
}
#applyProviderModelOverrides(provider: string, models: Model<Api>[]): Model<Api>[] {
const overrides = this.#modelOverrides.get(provider);
if (!overrides || overrides.size === 0) return models;
let liveIds: Set<string> | null = null;
const hasLiveModel = (_provider: string, id: string) => {
liveIds ??= new Set(models.map(m => m.id));
return liveIds.has(id);
};
return models.map(model => {
const override = resolveModelOverrideWithAliases(overrides, model, hasLiveModel);
if (!override) return model;
return applyModelOverride(model, override);
});
}
// #applyLlamaCppQwenThinkingToModels re-runs applyLlamaCppQwenThinking as the
// outermost transform for llama.cpp-provider models, after discovery merges,
// cache fallbacks, and provider/transport overrides have run. It is
// idempotent, so it restores the routed Qwen model's chat-completions api,
// `/v1` runtime base URL, and disable dialect even when a configured `baseUrl`
// override (which wins in mergeDiscoveredModel) or a fallback to a pre-fix
// cached row would otherwise leave the old spec in place.
#applyLlamaCppQwenThinkingToModels(models: Model<Api>[]): Model<Api>[] {
const llamaCppProviders = new Set<string>();
for (const provider of this.#discoverableProviders) {
if (provider.discovery.type === "llama.cpp") llamaCppProviders.add(provider.provider);
}
if (llamaCppProviders.size === 0) return models;
return models.map(model => (llamaCppProviders.has(model.provider) ? applyLlamaCppQwenThinking(model) : model));
}
#mergeProviderOverride(baseOverride: ProviderOverride | undefined, override: ProviderOverride): ProviderOverride {
return {
baseUrl: override.baseUrl ?? baseOverride?.baseUrl,
apiKey: override.apiKey ?? baseOverride?.apiKey,
authHeader: override.authHeader ?? baseOverride?.authHeader,
headers: override.headers
? createLiveConfigHeaders([baseOverride?.headers, override.headers])
: baseOverride?.headers,
compat: override.compat ? mergeCompat(baseOverride?.compat, override.compat) : baseOverride?.compat,
remoteCompaction: mergeRemoteCompactionConfig(baseOverride?.remoteCompaction, override.remoteCompaction),
transport: override.transport ?? baseOverride?.transport,
};
}
#applyProviderTransportOverride<
T extends { baseUrl?: string; headers?: Record<string, string>; remoteCompaction?: RemoteCompactionConfig<Api> },
>(
entry: T,
override: Pick<
ProviderOverride,
"baseUrl" | "headers" | "authHeader" | "apiKey" | "remoteCompaction" | "transport"
>,
): T {
const headers = mergeAuthHeaderSources(
override.headers ? [entry.headers, override.headers] : [entry.headers],
override.authHeader,
override.apiKey,
);
return {
...entry,
baseUrl: override.baseUrl ?? entry.baseUrl,
headers,
// Preserve the model's existing transport when the override omits one;
// providers without a `transport` field keep the default per-API dispatch.
...(override.transport !== undefined ? { transport: override.transport } : {}),
remoteCompaction: mergeProviderRemoteCompactionConfig(entry.remoteCompaction, override.remoteCompaction),
};
}
#applyProviderTransportOverrideToModel(
model: Model<Api>,
override: Pick<
ProviderOverride,
"baseUrl" | "headers" | "authHeader" | "apiKey" | "remoteCompaction" | "transport"
>,
): Model<Api> {
return buildModel(this.#applyProviderTransportOverride(toModelSpec(model), override));
}
#applyRuntimeProviderOverrides(models: Model<Api>[]): Model<Api>[] {
if (this.#runtimeProviderOverrides.size === 0) return models;
return models.map(model => {
const override = this.#runtimeProviderOverrides.get(model.provider);
if (!override) return model;
return this.#applyProviderTransportOverrideToModel(model, override);
});
}
#resolveLiveModelOverride(model: Model<Api>): ModelOverride | undefined {
const providerOverrides = this.#modelOverrides.get(model.provider);
if (!providerOverrides) return undefined;
return resolveModelOverrideWithAliases(
providerOverrides,
model,
(provider, id) => this.find(provider, id) !== undefined,
);
}
#resolveLiveCustomModelOverlay(model: Model<Api>): CustomModelOverlay | undefined {
return (
this.#customModelOverlays.find(overlay => overlay.provider === model.provider && overlay.id === model.id) ??
this.#runtimeModelOverlays.find(overlay => overlay.provider === model.provider && overlay.id === model.id)
);
}
#applyModelOverrides(models: Model<Api>[], overrides: Map<string, Map<string, ModelOverride>>): Model<Api>[] {
if (overrides.size === 0) return models;
let liveKeys: Set<string> | null = null;
const hasLiveModel = (provider: string, id: string) => {
liveKeys ??= new Set(models.map(m => `${m.provider}\u0000${m.id}`));
return liveKeys.has(`${provider}\u0000${id}`);
};
return models.map(model => {
const providerOverrides = overrides.get(model.provider);
if (!providerOverrides) return model;
const override = resolveModelOverrideWithAliases(providerOverrides, model, hasLiveModel);
if (!override) return model;
return applyModelOverride(model, override);
});
}
#applyHardcodedModelPolicies(models: Model<Api>[]): Model<Api>[] {
return models.map(model => {
if (model.provider === "ollama-cloud" && model.omitMaxOutputTokens !== true) {
model = applyModelOverride(model, { omitMaxOutputTokens: true });
}
if (model.id !== "gpt-5.4" || model.provider === "github-copilot") {
return model;
}
const overrides = this.#modelOverrides.get(model.provider)?.get(model.id);
if (!overrides) {
return applyModelOverride(model, { contextWindow: 1_000_000 });
}
return applyModelOverride(model, {
contextWindow: overrides.contextWindow ?? 1_000_000,
...overrides,
});
});
}
#parseModels(config: ModelsConfig): CustomModelOverlay[] {
const models: CustomModelOverlay[] = [];
for (const [providerName, providerConfig] of Object.entries(config.providers ?? {})) {
const modelDefs = providerConfig.models ?? [];
if (modelDefs.length === 0) continue; // Override-only, no custom models
const resolvedProviderHeaders = resolveConfigHeaders(providerConfig.headers);
if (providerConfig.apiKey) {
this.#installProviderApiKey(providerName, providerConfig.apiKey);
}
for (const modelDef of modelDefs) {
const providerCompat = providerConfig.disableStrictTools
? mergeCompat(providerConfig.compat, { disableStrictTools: true })
: providerConfig.compat;
const model = buildCustomModelOverlay(
providerName,
providerConfig.baseUrl!,
providerConfig.api as Api | undefined,
resolvedProviderHeaders,
providerConfig.apiKey,
providerConfig.authHeader,
providerCompat,
(providerConfig.auth as ProviderAuthMode | undefined) ?? undefined,
providerConfig.remoteCompaction,
modelDef as CustomModelDefinitionLike,
);
if (!model) continue;
models.push(model);
}
}
return models;
}
#modelsForProviderLookup(provider: string): Model<Api>[] {
if (this.#hasFullSnapshot) return this.#models;
const normalizedProvider = provider.trim().toLowerCase();
if (!normalizedProvider) return [];
const cached = this.#providerLookupSnapshots.get(normalizedProvider);
if (cached) return cached;
const matchingProviders = new Set(
this.#knownStaticProviders().filter(candidate => candidate.toLowerCase() === normalizedProvider),
);
const models = this.#composeStaticModels(matchingProviders);
this.#providerLookupSnapshots.set(normalizedProvider, models);
return models;
}
/**
* Get all models (built-in + custom).
* If custom config had errors, returns only built-in models.
*/
getAll(): Model<Api>[] {
return this.#ensureFullSnapshot();
}
/**
* Availability predicate with per-provider memoization. Auth lookups
* (`authStorage.hasAuth`) and the disabled-provider set are resolved once
* per provider instead of once per model, which matters when filtering the
* full bundled catalog (thousands of models, ~50 providers).
*/
#createProviderAvailabilityCheck(): (provider: string) => boolean {
const disabledProviders = getDisabledProviderIdsFromSettings();
const byProvider = new Map<string, boolean>();
return provider => {
let available = byProvider.get(provider);
if (available === undefined) {
available =
!disabledProviders.has(provider) &&
(this.#keylessProviders.has(provider) || this.authStorage.hasAuth(provider));
byProvider.set(provider, available);
}
return available;
};
}
/**
* Get only models that have auth configured.
* This is a fast check that doesn't refresh OAuth tokens.
*/
getAvailable(): Model<Api>[] {
const isProviderAvailable = this.#createProviderAvailabilityCheck();
if (this.#hasFullSnapshot) {
return this.#models.filter(model => isProviderAvailable(model.provider));
}
const availableProviders = new Set(this.#knownStaticProviders().filter(isProviderAvailable));
return this.#composeStaticModels(availableProviders);
}
/**
* Check whether auth is configured for a model's provider.
*
* Mirrors the upstream `@mariozechner/pi-coding-agent` API surface so that
* external plugins/extensions and downstream wrappers (e.g. subagent launch
* paths that pre-flight auth before model resolution) can probe a model
* without resolving an API key. Returns true for keyless providers as well
* as providers with stored credentials. See issue #993.
*
* Side-effect-free and synchronous: a command-backed key (`!cmd`) counts as
* configured by its presence alone — the program is NOT executed — and OAuth
* tokens are NOT refreshed (`authStorage.hasAuth`). This is what keeps the
* model-switch pre-flight off the event loop's hot path; the real key
* (command execution + OAuth refresh) is resolved lazily per request via
* {@link ModelRegistry.resolver}.
*/
hasConfiguredAuth(model: Model<Api>): boolean {
const keyConfig = this.#customProviderApiKeys.get(model.provider);
return (
isCommandConfigValue(keyConfig) ||
this.#keylessProviders.has(model.provider) ||
this.authStorage.hasAuth(model.provider)
);
}
/**
* Whether the provider's configured API key is resolved from a command.
*
* Callers use this to distinguish the registry's command-first resolver
* path from lower-priority credentials in {@link authStorage}.
*/
hasCommandBackedApiKey(provider: string): boolean {
const keyConfig = this.#customProviderApiKeys.get(provider);
return isCommandConfigValue(keyConfig);
}
getDiscoverableProviders(): string[] {
const disabledProviders = getDisabledProviderIdsFromSettings();
return this.#discoverableProviders
.filter(provider => !disabledProviders.has(provider.provider))
.map(provider => provider.provider);
}
/**
* Whether `providerId` is known to the registry: it has at least one live
* model, or it is configured for dynamic discovery (models.yml `discovery:`
* or a runtime extension provider) and is not disabled. Discovery-only
* providers can hold zero models at startup — cached rows never persist
* live auth headers (#5780), so a provider whose discovered models all
* carry config headers (`authHeader: true`) only materializes models after
* the online refresh completes.
*/
hasProvider(providerId: string): boolean {
const providerModels = this.#hasFullSnapshot ? this.#models : this.#composeStaticModels(new Set([providerId]));
if (providerModels.some(model => model.provider === providerId)) return true;
if (getDisabledProviderIdsFromSettings().has(providerId)) return false;
return (
this.#discoverableProviders.some(provider => provider.provider === providerId) ||
this.#runtimeModelManagers.has(providerId)
);
}
getProviderDiscoveryState(provider: string): ProviderDiscoveryState | undefined {
return this.#providerDiscoveryStates.get(provider);
}
/**
* Find a model by provider and ID.
*/
find(provider: string, modelId: string): Model<Api> | undefined {
return resolveProviderModelReference(provider, modelId, this.#modelsForProviderLookup(provider));
}
/**
* Get the base URL associated with a provider, if any model defines one.
*/
getProviderBaseUrl(provider: string): string | undefined {
return this.#modelsForProviderLookup(provider).find(m => m.provider === provider && m.baseUrl)?.baseUrl;
}
/**
* Get provider-level headers without including per-model overrides.
*/
getProviderHeaders(provider: string): Record<string, string> | undefined {
return createLiveConfigHeaders([
this.#providerOverrides.get(provider)?.headers,
this.#runtimeProviderOverrides.get(provider)?.headers,
]);
}
/**
* Get API key for a model.
*/
async getApiKey(
model: Model<Api>,
sessionId?: string,
options?: { signal?: AbortSignal },
): Promise<string | undefined> {
const commandKey = this.#resolveCommandBackedApiKey(model.provider);
if (commandKey.configured) return commandKey.value;
if (this.#keylessProviders.has(model.provider) && !this.authStorage.hasAuth(model.provider)) {
return kNoAuth;
}
return this.authStorage.getApiKey(model.provider, sessionId, {
baseUrl: model.baseUrl,
modelId: model.id,
signal: options?.signal,
});
}
/** Resolve request authentication through the historical Pi extension facade. */
async getApiKeyAndHeaders(model: Model<Api>): Promise<ResolvedRequestAuth> {
try {
const apiKey = await this.getApiKey(model);
if (apiKey === undefined) {
return { ok: false, error: `No API key found for "${model.provider}"` };
}
const headers = this.getProviderHeaders(model.provider);
return { ok: true, apiKey, headers };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
/**
* Get API key for a provider (e.g., "openai").
*
* `options.forceRefresh` powers step (b) of the auth-retry policy — it
* re-mints the session-sticky OAuth token even when the cached copy still
* looks valid. `options.signal` is threaded into any broker-bound refresh.
*/
async getApiKeyForProvider(
provider: string,
sessionId?: string,
options?: { baseUrl?: string; modelId?: string; forceRefresh?: boolean; signal?: AbortSignal },
): Promise<string | undefined> {
const commandKey = this.#resolveCommandBackedApiKey(provider);
if (commandKey.configured) return commandKey.value;
if (this.#keylessProviders.has(provider) && !this.authStorage.hasAuth(provider)) {
return kNoAuth;
}
return this.authStorage.getApiKey(provider, sessionId, {
baseUrl: options?.baseUrl,
modelId: options?.modelId,
forceRefresh: options?.forceRefresh,
signal: options?.signal,
});
}
/**
* Build an {@link ApiKeyResolver} implementing the central a/b/c auth-retry
* policy. Accepts a provider id with options, or a model with an optional
* session id (`resolver(model, sessionId)`) which derives `baseUrl`/`modelId`
* from the model. Callers that need the initial key for a guard can call
* `resolveApiKeyOnce(resolver)`.
*/
resolver(provider: string, options?: ApiKeyResolverOptions): ApiKeyResolver;
resolver(model: ApiKeyResolverModel, sessionId?: string): ApiKeyResolver;
resolver(target: string | ApiKeyResolverModel, optionsOrSessionId?: ApiKeyResolverOptions | string): ApiKeyResolver {
const options = typeof optionsOrSessionId === "string" ? { sessionId: optionsOrSessionId } : optionsOrSessionId;
if (typeof target === "string") {
return createApiKeyResolver(this, target, options);
}
return createApiKeyResolver(this, target.provider, {
...options,
baseUrl: target.baseUrl,
modelId: target.id,
});
}
async #peekApiKeyForProvider(provider: string): Promise<string | undefined> {
const commandKey = this.#resolveCommandBackedApiKey(provider);
if (commandKey.configured) return commandKey.value;
if (this.#keylessProviders.has(provider) && !this.authStorage.hasAuth(provider)) {
return kNoAuth;
}
return this.authStorage.peekApiKey(provider);
}
/**
* Check if a model is using OAuth credentials (subscription).
*/
isUsingOAuth(model: Model<Api>): boolean {
return this.authStorage.hasOAuth(model.provider);
}
#clearRuntimeProviderState(providerName: string): void {
this.#runtimeProviderApiKeys.delete(providerName);
this.#runtimeProviderOverrides.delete(providerName);
this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(overlay => overlay.provider !== providerName);
this.#runtimeModelManagers.delete(providerName);
this.#runtimeModelModifiers.delete(providerName);
this.#lastModelModifierWarnings.delete(providerName);
this.authStorage.removeConfigApiKey(providerName);
}
/**
* Remove custom API/OAuth registrations for a specific extension source.
*/
clearSourceRegistrations(sourceId: string): void {
unregisterCustomApis(sourceId);
unregisterOAuthProviders(sourceId);
const sourceProviders = this.#runtimeProvidersBySource.get(sourceId);
if (!sourceProviders || sourceProviders.size === 0) {
return;
}
this.#ensureFullSnapshot();
this.#runtimeProvidersBySource.delete(sourceId);
for (const providerName of sourceProviders) {
if (this.#runtimeProviderSourceByName.get(providerName) !== sourceId) {
continue;
}
this.#runtimeProviderSourceByName.delete(providerName);
this.#clearRuntimeProviderState(providerName);
}
this.#lastStaticLoadMtime = null;
this.#reloadStaticModels();
}
/**
* Remove registrations for extension sources that are no longer active.
*/
syncExtensionSources(activeSourceIds: string[]): void {
const activeSources = new Set(activeSourceIds);
for (const sourceId of this.#registeredProviderSources) {
if (activeSources.has(sourceId)) {
continue;
}
this.clearSourceRegistrations(sourceId);
this.#registeredProviderSources.delete(sourceId);
}
}
/**
* Register a provider dynamically (from extensions).
*
* If provider has models: replaces all existing models for this provider.
* If provider has only baseUrl/headers: overrides existing models' URLs.
* If provider has streamSimple: registers a custom API streaming function.
* If provider has oauth: registers OAuth provider for /login support.
*/
registerProvider(providerName: string, config: ProviderConfigInput, sourceId?: string): void {
if (config.streamSimple && !config.api) {
throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`);
}
validateProviderConfiguration(
providerName,
{
baseUrl: config.baseUrl,
headers: config.headers,
apiKey: config.apiKey,
api: config.api,
oauthConfigured: Boolean(config.oauth),
models: (config.models ?? []) as ProviderValidationModel[],
},
"runtime-register",
);
if (config.streamSimple && config.api) {
const streamSimple = config.streamSimple;
registerCustomApi(config.api, streamSimple, sourceId, (model, context, options) =>
streamSimple(model, context, options as SimpleStreamOptions),
);
}
if (config.oauth) {
registerOAuthProvider({
...config.oauth,
id: providerName,
sourceId,
});
}
let sourceHandoff = false;
if (sourceId) {
this.#registeredProviderSources.add(sourceId);
const previousSourceId = this.#runtimeProviderSourceByName.get(providerName);
if (previousSourceId && previousSourceId !== sourceId) {
const previousProviders = this.#runtimeProvidersBySource.get(previousSourceId);
previousProviders?.delete(providerName);
if (previousProviders && previousProviders.size === 0) {
this.#runtimeProvidersBySource.delete(previousSourceId);
}
this.#clearRuntimeProviderState(providerName);
sourceHandoff = true;
}
const sourceProviders = this.#runtimeProvidersBySource.get(sourceId) ?? new Set<string>();
sourceProviders.add(providerName);
this.#runtimeProvidersBySource.set(sourceId, sourceProviders);
this.#runtimeProviderSourceByName.set(providerName, sourceId);
}
if (sourceHandoff) {
this.#lastStaticLoadMtime = null;
this.#reloadStaticModels();
}
this.#ensureFullSnapshot();
if (config.apiKey) {
this.#installProviderApiKey(providerName, config.apiKey);
// Persist runtime API keys so they survive #reloadStaticModels() cycles
this.#runtimeProviderApiKeys.set(providerName, config.apiKey);
}
if (config.models && config.models.length > 0) {
// Build model overlays that persist across refresh() cycles
const newOverlays: CustomModelOverlay[] = [];
for (const modelDef of config.models) {
const overlay = buildCustomModelOverlay(
providerName,
config.baseUrl!,
config.api,
config.headers,
config.apiKey,
config.authHeader,
config.compat,
undefined,
config.remoteCompaction,
modelDef as CustomModelDefinitionLike,
);
if (!overlay) {
throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`);
}
newOverlays.push(overlay);
}
// Store as runtime overlays so they survive #reloadStaticModels()
this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(m => m.provider !== providerName);
this.#runtimeModelOverlays.push(...newOverlays);
// Update the unprojected snapshot, then rerun every whole-catalog
// projection exactly once. Incremental projection is not safe because one
// provider's hook may inspect or suppress another provider's models.
const nextModels = this.#unprojectedModels.filter(model => model.provider !== providerName);
for (const overlay of newOverlays) {
nextModels.push(finalizeCustomModel(overlay, { useDefaults: true }));
}
const runtimeTransportOverride = this.#runtimeProviderOverrides.get(providerName);
this.#unprojectedModels = runtimeTransportOverride
? nextModels.map(model => {
if (model.provider !== providerName) return model;
return this.#applyProviderTransportOverrideToModel(model, runtimeTransportOverride);
})
: nextModels;
if (config.oauth?.modifyModels) {
this.#runtimeModelModifiers.set(providerName, config.oauth.modifyModels);
} else {
this.#runtimeModelModifiers.delete(providerName);
}
this.#models = this.#applyRuntimeModelModifiers(this.#unprojectedModels);
this.#providerLookupSnapshots.clear();
return;
}
if (config.fetchDynamicModels) {
const fetcher = config.fetchDynamicModels;
const providerBaseUrl = config.baseUrl ?? "";
const providerApi = config.api;
const providerHeaders = config.headers;
const providerApiKey = config.apiKey;
const providerAuthHeader = config.authHeader;
const providerCompat = config.compat;
const managerOptions: ModelManagerOptions<Api> = {
providerId: providerName as Parameters<typeof createModelManager>[0]["providerId"],
staticModels: [],
cacheDbPath: this.#cacheDbPath,
cacheTtlMs: 24 * 60 * 60 * 1000,
dynamicModelsAuthoritative: true,
fetchDynamicModels: async () => {
const apiKey = await this.#peekApiKeyForProvider(providerName);
const resolvedKey = isAuthenticated(apiKey) ? apiKey : undefined;
const modelDefs = await withRuntimeDynamicModelsTimeout(RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS, () =>
fetcher(resolvedKey),
);
const results: Model<Api>[] = [];
for (const modelDef of modelDefs) {
const overlay = buildCustomModelOverlay(
providerName,
modelDef.baseUrl ?? providerBaseUrl,
modelDef.api ?? providerApi,
providerHeaders,
providerApiKey,
providerAuthHeader,
providerCompat,
undefined,
config.remoteCompaction,
modelDef as CustomModelDefinitionLike,
);
if (overlay) results.push(finalizeCustomModel(overlay, { useDefaults: true }));
}
return results.map(toModelSpec);
},
};
this.#runtimeModelManagers.set(providerName, { options: managerOptions, sourceId: sourceId ?? "" });
// Discovery is driven by refreshRuntimeProviders() after the drain — not
// here, so registration has no network side effect and callers can await.
}
if (
config.baseUrl ||
config.headers ||
config.apiKey ||
config.authHeader !== undefined ||
config.remoteCompaction !== undefined ||
config.transport !== undefined
) {
const transportOverride = {
baseUrl: config.baseUrl,
headers: config.headers,
apiKey: config.apiKey,
authHeader: config.authHeader,
remoteCompaction: config.remoteCompaction,
transport: config.transport,
};
const nextRuntimeOverride = this.#mergeProviderOverride(
this.#runtimeProviderOverrides.get(providerName),
transportOverride,
);
this.#runtimeProviderOverrides.set(providerName, nextRuntimeOverride);
this.#unprojectedModels = this.#applyLlamaCppQwenThinkingToModels(
this.#unprojectedModels.map(model => {
if (model.provider !== providerName) return model;
return this.#applyProviderTransportOverrideToModel(model, transportOverride);
}),
);
this.#models = this.#applyRuntimeModelModifiers(this.#unprojectedModels);
this.#providerLookupSnapshots.clear();
}
}
/**
* Suppress a specific model selector (e.g., "provider/id") until a specific timestamp.
*/
suppressSelector(selector: string, untilMs: number): void {
this.#suppressedSelectors.set(
normalizeSuppressedSelector(selector, (provider, id) => this.find(provider, id) !== undefined),
untilMs,
);
}
/**
* Check if a model selector is currently suppressed due to rate limits.
*/
isSelectorSuppressed(selector: string): boolean {
const normalizedSelector = normalizeSuppressedSelector(
selector,
(provider, id) => this.find(provider, id) !== undefined,
);
const suppressedUntil = this.#suppressedSelectors.get(normalizedSelector);
if (!suppressedUntil) return false;
if (suppressedUntil <= Date.now()) {
this.#suppressedSelectors.delete(normalizedSelector);
return false;
}
return true;
}
/**
* Clear the cooldown suppression for one selector after an explicit user selection.
*/
clearSuppressedSelector(selector: string): void {
this.#suppressedSelectors.delete(
normalizeSuppressedSelector(selector, (provider, id) => this.find(provider, id) !== undefined),
);
}
/**
* Clear all cooldown suppressions recorded via {@link suppressSelector}.
* Used to reset retry-fallback cooldown state without a full {@link refresh}.
*/
clearSuppressedSelectors(): void {
this.#suppressedSelectors.clear();
}
}
/**
* Input type for registerProvider API (from extensions).
*/
export interface ProviderConfigInput {
baseUrl?: string;
apiKey?: string;
api?: Api;
streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
headers?: Record<string, string>;
compat?: ModelSpec<Api>["compat"];
remoteCompaction?: RemoteCompactionConfig<Api>;
authHeader?: boolean;
/** Streaming transport override — see {@link Model.transport}. */
transport?: Model<Api>["transport"];
oauth?: {
name: string;
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials | string>;
refreshToken?(credentials: OAuthCredentials): Promise<OAuthCredentials>;
getApiKey?(credentials: OAuthCredentials): string;
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
};
/**
* Async factory that fetches the live model list from the provider endpoint.
* When present, the result is run through the same SQLite model-cache as
* built-in providers (keyed by provider name, default 24 h TTL).
* The factory receives the resolved API key (undefined when unauthenticated).
*/
fetchDynamicModels?: (
apiKey: string | undefined,
) => Promise<readonly NonNullable<ProviderConfigInput["models"]>[number][]>;
models?: Array<{
id: string;
name: string;
api?: Api;
baseUrl?: string;
reasoning: boolean;
thinking?: ThinkingConfig;
input: ("text" | "image")[];
supportsTools?: boolean;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
headers?: Record<string, string>;
compat?: ModelSpec<Api>["compat"];
contextPromotionTarget?: string;
compactionModel?: string;
remoteCompaction?: RemoteCompactionConfig<Api>;
premiumMultiplier?: number;
}>;
}