import * as path from "node:path"; import type { ApiKeyResolver, FetchImpl, ResolvedApiKey, UsageProvider } from "@oh-my-pi/pi-ai"; import { type AuthApiKeyOptions, type AuthPeekOptions, oauthAccountKey } from "@oh-my-pi/pi-ai/auth-storage"; import { registerCustomApi, unregisterCustomApis } from "@oh-my-pi/pi-ai/api-registry"; import { registerOAuthProvider, unregisterOAuthProvider, 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-attestation"; import { getProviderDefinition } from "@oh-my-pi/pi-ai/registry"; import { getEnvApiKey } from "@oh-my-pi/pi-ai/env-api-key"; import { OAuthRefreshUnavailableError } from "@oh-my-pi/pi-ai/error"; 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 { buildDiscoveredModel, buildModel, nextBuildPriceChange } from "@oh-my-pi/pi-catalog/build"; import { collapseBuiltVariants } from "@oh-my-pi/pi-catalog/compat/collapse"; import { clampCodexContextWindow, clampsContextOverride, resolveMaxContextWindow, } from "@oh-my-pi/pi-catalog/compat/context-window"; import { applyCatalogMetrics, CatalogMetricsIndex, catalogMetricsOf } from "@oh-my-pi/pi-catalog/identity/metrics"; import { getModelCacheWriteStats, readModelCache, retainModelCacheConnection } from "@oh-my-pi/pi-catalog/model-cache"; import { createModelManager, fingerprintStaticModels, 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, isCredentialScopedModelCacheProvider, MODELS_DEV_CATALOG_PROVIDER_IDS, modelsDevCatalogFallback, openaiCodexModelManagerOptions, PROVIDER_DESCRIPTORS, resolveModelCacheProviderId, resolveOllamaModelCacheProviderId, } from "@oh-my-pi/pi-catalog/provider-models"; import { toModelSpec } from "@oh-my-pi/pi-catalog/provider-models/bundled-references"; import { apiServesKind, modelKind, type ModelKind } from "@oh-my-pi/pi-catalog/types"; import { isOfficialCodexApiUrl } from "@oh-my-pi/pi-catalog/wire/codex"; import { getAgentDir, isBunTestRuntime, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils"; import { resolveProviderModelReference } from "../config/model-resolver"; import { generateCodexAttestation } from "../live/attestation"; import { redactUrlForLog } from "../mcp/json-rpc"; import type { AuthStorage } from "../session/auth-storage"; import { type ApiKeyResolverModel, type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver"; import type { ConfigError, ConfigFile } from "./config-file"; import { buildCustomModelOverlay, type CustomModelDefinitionLike, type CustomModelOverlay, finalizeCustomModel, mergeAuthHeaderSources, normalizeSuppressedSelector, resolveModelOverrideWithAliases, } from "./custom-models"; import { createConfigHeaderResolver, invalidateAllCommandConfigs, invalidateCommandConfig, isCommandConfigValue, resolveConfigHeaders, resolveConfigValue, } from "./resolve-config-value"; import { DISCOVERY_DEFAULT_MAX_TOKENS, type DiscoveryContext, type DiscoveryProviderConfig, discoverLlamaCppModelRuntimeMetadata, discoverLmStudioModelRuntimeMetadata, discoverModelsByProviderType, ensureLlamaCppV1BaseUrl, getImplicitOllamaBaseUrl, getOllamaContextLengthOverride, isDiscoveryAuthRejection, LLAMA_CPP_DEFAULT_BASE_URLS, normalizeBareDiscoveryBaseUrl, normalizeLiteLLMDiscoveryBaseUrl, normalizeLlamaCppBaseUrl, } from "./model-discovery"; import { AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS, applyModelOverride, applyModelPatch, dropProviderModels, type ModelPatch, mergeByModelKey, mergeCompat, mergeDiscoveredModel, mergeProviderRemoteCompactionConfig, mergeRemoteCompactionConfig, resolveProviderBaseUrl, type ProviderOverride, providersWithAuthoritativeProjectCatalog, unservedOverrideKind, } from "./model-patch"; import { BUILT_IN_DISCOVERY_CACHE_TTL_MS, BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS, type BuiltInDiscoveryResult, extractGoogleOAuthProjectId, extractGoogleOAuthToken, getOAuthCredentialsForProvider, isAuthenticated, isDiscoveryBearerApiKey, kNoAuth, type ProviderDiscoveryState, RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS, resolveOAuthDiscoveryAccounts, SPECIAL_MODEL_MANAGER_PROVIDER_IDS, STARTUP_MODEL_CACHE_PROVIDER_IDS, withModelDiscoveryTimeout, } from "./model-provider-discovery"; export { mergeDiscoveredModel } from "./model-patch"; export { isAuthenticated, kNoAuth, type ProviderDiscoveryState, type ProviderDiscoveryStatus, } from "./model-provider-discovery"; import { getUnknownCompatKeys, ModelsConfigFile, type ProviderValidationModel, validateProviderConfiguration, } from "./models-config"; import type { ModelOverride, ModelsConfig, ProviderAuthMode } from "./models-config-schema"; import { type Settings, settings } from "./settings"; import { cfgDisabledProviders } from "./model-settings"; import { cfgCompactionModelThresholds, cfgCompactionModelThresholdsEnabled, cfgExtendedContext, } from "../session/context-settings"; import { matchModelCompactionThreshold } from "./compaction-threshold"; // DeviceCheck attestation (`x-oai-attestation`) for ChatGPT-OAuth Codex // requests; the pi-ai provider resolves it just-in-time per request. setCodexAttestationProvider(generateCodexAttestation); /** One built-in discovery pass rewriting more payload rows than this is debug-logged. */ const MODEL_CACHE_REWRITE_LOG_THRESHOLD = 5; /** `scheme://user:pass@` in free text (e.g. a fetch error echoing the request URL). */ const URL_USERINFO_RE = /([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi; const BUILT_IN_MODEL_MANAGER_PROVIDER_IDS: Readonly> = Object.freeze( Object.fromEntries( [...PROVIDER_DESCRIPTORS.map(descriptor => descriptor.providerId), ...SPECIAL_MODEL_MANAGER_PROVIDER_IDS].map( providerId => [providerId, true as const], ), ), ); const MODELS_DEV_CATALOG_PROVIDER_ID_LOOKUP: Readonly> = Object.freeze( Object.fromEntries(MODELS_DEV_CATALOG_PROVIDER_IDS.map(providerId => [providerId, true as const])), ); const ADDITIVE_MODELS_DEV_CATALOG_PROVIDER_ID_LOOKUP: Readonly> = Object.freeze( Object.fromEntries( MODELS_DEV_CATALOG_PROVIDER_IDS.filter( providerId => !PROVIDER_DESCRIPTORS.some( descriptor => descriptor.providerId === providerId && descriptor.dynamicModelsAuthoritative, ), ).map(providerId => [providerId, true as const]), ), ); /** * Bedrock provider-scoped fields to spread onto a model spec, dropping keys * that a provider override left unset so an override never clobbers an * existing value with `undefined`. */ function bedrockProviderFields(override: ProviderOverride): Partial> { const fields: Partial> = {}; if (override.guardrailIdentifier !== undefined) fields.guardrailIdentifier = override.guardrailIdentifier; if (override.guardrailVersion !== undefined) fields.guardrailVersion = override.guardrailVersion; if (override.guardrailTrace !== undefined) fields.guardrailTrace = override.guardrailTrace; if (override.requestMetadata !== undefined) fields.requestMetadata = override.requestMetadata; return fields; } /** Result of loading custom models config. */ interface CustomModelsResult { models?: CustomModelOverlay[]; overrides?: Map; modelOverrides?: Map>; keylessProviders?: Set; discoverableProviders?: DiscoveryProviderConfig[]; configuredProviders?: Set; error?: ConfigError; found: boolean; } interface ConfiguredModelDiscoveryResult { models: Model[]; replaceRuntimeModels: boolean; } /** * 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[], credentials: OAuthCredentials) => Model[]; function getDisabledProviderIdsFromSettings(settingsInstance?: Settings): Set { try { return new Set(cfgDisabledProviders.get(settingsInstance ?? settings)); } catch { return new Set(); } } /** * Whether extended context windows are enabled: advertised maximum windows * plus premium long-context tiers. Matches the schema default (`false`) when * no settings source is available (SDK embedding without settings, early * boot): callers get default windows until they opt in, never silently * elevated ones. */ function isExtendedContextEnabledFromSettings(settingsInstance?: Settings): boolean { try { return cfgExtendedContext.get(settingsInstance ?? settings); } catch { return false; } } /** The `compaction.modelThresholds` map in force, or undefined when it is switched off or unreadable. */ function getModelCompactionPointsFromSettings(settingsInstance?: Settings): unknown { try { const scope = settingsInstance ?? settings; return cfgCompactionModelThresholdsEnabled.get(scope) ? cfgCompactionModelThresholds.get(scope) : undefined; } catch { return undefined; } } /** * Whether `model`'s `compaction.modelThresholds` token entry needs more than * `window`. A base entry stands in for the window, so it needs a larger one * once it exceeds `window`; a fixed trigger (`"f90000"`) needs room above it, * so it does at `window` already. Such an entry opts that model into its * extended window. Percentage entries scale with the window and never opt in. */ function compactionPointReaches(points: unknown, model: Model, window: number): boolean { const threshold = matchModelCompactionThreshold(points, model)?.threshold; if (!threshold || threshold.thresholdTokens <= 0) return false; return threshold.fixed ? threshold.thresholdTokens >= window : threshold.thresholdTokens > window; } function contextWindowKey(model: { provider: string; id: string }): string { return `${model.provider}\u0000${model.id}`; } /** The context window a model runs with by default, and the larger one extended context opts it into. */ export interface ContextWindowTiers { standard: number; extended: number; } /** Rows of a registry layer owned by `providerFilter`, or the whole layer when unfiltered. */ function selectProviderModels(models: T[], providerFilter?: ReadonlySet): T[] { return providerFilter ? models.filter(model => providerFilter.has(model.provider)) : models; } /** * Extra knobs for {@link ModelRegistry.refresh} / {@link ModelRegistry.refreshProvider}. * Online discovery (`strategy: "online"`) is independent of credential minting: * opening `/models` and hovering a provider fetch catalogs without re-running * `!command` helpers. Pass `refreshCommandCredentials` only for explicit user * refresh (`omp models refresh`, TUI F5). */ export interface ModelRegistryRefreshOptions { refreshCommandCredentials?: boolean; } /** Authentication material returned to legacy extensions for one model request. */ export type ResolvedRequestAuth = | { ok: true; apiKey?: string; headers?: Record; env?: Record; } | { ok: false; error: string }; /** * Model registry - loads and manages models, resolves API keys via AuthStorage. */ export class ModelRegistry { #models: Model[] = []; #unprojectedModels: Model[] = []; /** * Full-snapshot input to `#projectBaseModels`: resolved built-in, cached, and * discovered models before custom overlays, overrides, and transport * projections. Refreshes merge onto this, never onto `#unprojectedModels`, * so each projection wraps header resolvers exactly once per refresh. */ #baseModels: Model[] = []; #hasFullSnapshot = false; #cachedStandardModelsByProvider: Map[]> = new Map(); #pendingStandardCacheProviders: Set = new Set(); #cachedDiscoverableModels: Model[] = []; #cachedAuthoritativeProviders: Set = new Set(); #runtimeDiscoveredModels: Model[] = []; #runtimeAuthoritativeProviders: Set = new Set(); // Providers whose cache-backed rows a discovery pass replaced since the last static reload. #replacedCacheProviders: Set = new Set(); #catalogMetrics = new CatalogMetricsIndex(); // The scored rows `#catalogMetrics` was built from, in order, so a mirror rebuilds the same index. // A row whose id and metrics an earlier row already carried adds nothing to the index and is skipped, // so repeated discovery passes neither grow the list nor change its identity. #catalogMetricRows: Model[] = []; #catalogMetricRowKeys = new Set(); #internedStaticModels: Map> = new Map(); #providerLookupSnapshots: Map[]> = new Map(); #fullKindSnapshots = new WeakMap[], Partial[]>>>(); #customProviderApiKeys: Map = new Map(); // Every command-backed (`!cmd`) config value a provider carries — apiKey plus // provider/model-override header values — keyed by provider. The 401 auth // retry invalidates these command caches so the request-boundary resolver // re-materializes refreshed headers, not just the apiKey (#9760). #commandConfigsByProvider: Map> = new Map(); #keylessProviders: Set = new Set(); #discoverableProviders: DiscoveryProviderConfig[] = []; /** * Local providers registered without a models.yml entry (ollama, llama.cpp, * lm-studio, apple). Their models sort after every other provider's, so a * bare id the user also defined in models.yml resolves to the configured one. */ #implicitLocalProviders: Set = new Set(); /** Implicit local provider → env var that set its discovery endpoint. */ #implicitProviderEndpointEnv: Map = new Map(); #customModelOverlays: CustomModelOverlay[] = []; #providerOverrides: Map = new Map(); /** * Last `mergeDiscoveredModel` result per discovered row. Every refresh * re-resolves each built-in provider, and offline (or unchanged online) * passes hand back the same bundled/cached row objects against the same * references, so re-running the full model build for thousands of rows is * pure repetition. A changed reference or provider override misses, and so * does reaching the next dated price card the build would select. Held per * registry (not tagged onto the rows): bundled rows outlive every registry, * so their merges must be collected with the registry. */ #discoveredMergeMemo = new WeakMap< Model, { reference: Model | undefined; override: ProviderOverride | undefined; merged: Model; validUntil: number; } >(); #modelOverrides: Map> = new Map(); // `provider/id:kind` of modelOverrides kinds already reported as unserved, so // every rebuild does not log the same ignored override again. #warnedUnservedOverrideKinds: Set = new Set(); #configError: ConfigError | undefined = undefined; #warnedCompatKeys = new Set(); #configWarnings: string[] = []; #modelsConfigFile: ConfigFile; #lastStaticLoadMtime: number | null = null; #registeredProviderSources: Set = new Set(); #providerDiscoveryStates: Map = new Map(); #cacheDbPath?: string; #suppressedSelectors: Map = new Map(); #backgroundRefresh?: Promise; /** Whether the first background discovery has settled; latches once so a late-armed waiter still resolves. */ #initialRefreshSettled = false; /** Waiter armed before the initial background refresh starts (CLI kicks it off after the session is built, #10048). */ #initialRefreshWaiters = new Set<() => void>(); #credentialScopedCacheHydration?: Promise; #configuredDiscoveryInFlight: Map< DiscoveryProviderConfig, Map> > = new Map(); #policyReapply?: Promise; // Window tiers of models whose extended window is larger: per row as the // hardcoded policies produced it (custom overlays replacing that row never // inherit them), and per `contextWindowKey` as composition finalized them. #policyWindowTiers = new WeakMap, ContextWindowTiers>(); #windowTiers = new Map(); // A catalog row refitted to the other tier by `fitContextWindow`; a row has only one other tier. #refittedRows = new WeakMap, Model>(); #lastDiscoveryWarnings: Map = 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[] = []; // `fallback` ranks the key below stored login credentials (see registerProvider). #runtimeProviderApiKeys: Map = new Map(); #runtimeProviderOverrides: Map = new Map(); // Command-backed values from registerProvider (apiKey + provider/model // headers). Separate from #commandConfigsByProvider because static reload // rebuilds that map from models.yml only; runtime entries must survive. #runtimeCommandConfigsByProvider: Map> = 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 = new Map(); #lastModelModifierWarnings: Map = new Map(); #runtimeProvidersBySource: Map> = new Map(); #runtimeProviderSourceByName: Map = new Map(); // Runtime model managers registered by extensions via fetchDynamicModels. // Keyed by provider name; use the same SQLite cache path as builtins. #runtimeModelManagers: Map; sourceId: string }> = new Map(); #ignoreLocalModelConfig: boolean; #fetch: FetchImpl; #settings: Settings | undefined; /** Registry this one mirrors (on another thread); see the constructor option. */ #owner: ModelRegistryOwner | undefined; #mirrorInputs: ModelRegistryMirrorInputs; // Every `registerProvider` call whose provider has not been cleared since, in call order: // replaying them over the same disk inputs reproduces this registry's runtime providers. #runtimeRegistrations: ModelRegistryRegisterChange[] = []; #mirrorListeners = new Set<(batch: ModelRegistryMirrorBatch) => void>(); #pendingMirrorChanges: ModelRegistryMirrorChange[] = []; #mirrorFlushQueued = false; // Sequence number of the latest runtime change (owner) or the latest applied one (mirror). #mirrorSeq = 0; // Nesting depth of runtime-change entry points; only the outermost records a change. #mirrorChangeDepth = 0; // Discovery layer as last sent to mirrors; a flush resends it only when it moved. #mirroredDiscovery: ModelRegistryDiscoveryLayer | undefined; #captureCatalogMetrics(models: readonly Model[], replace: boolean): void { const scored = models.filter(model => catalogMetricsOf(model) !== undefined); if (replace) { if (scored.length > 0) { this.#catalogMetrics = new CatalogMetricsIndex(scored); this.#catalogMetricRows = []; this.#catalogMetricRowKeys.clear(); this.#recordCatalogMetricRows(scored); } return; } this.#catalogMetrics.add(scored); this.#recordCatalogMetricRows(scored); } #recordCatalogMetricRows(scored: readonly Model[]): void { const fresh = scored.filter(model => { const key = `${model.id.toLowerCase()}\u0000${model.int}\u0000${model.tps}`; if (this.#catalogMetricRowKeys.has(key)) return false; this.#catalogMetricRowKeys.add(key); return true; }); if (fresh.length > 0) this.#catalogMetricRows = [...this.#catalogMetricRows, ...fresh]; } /** Final pass over every published model list: catalog metrics, then implicit local providers last. */ #finalizeModels(models: Model[]): Model[] { return this.#orderImplicitProvidersLast(applyCatalogMetrics(models, this.#catalogMetrics)); } /** * Stable-partition implicit local provider models after all others. Model * resolution breaks ties between equally ranked candidates by list order, so * this keeps an auto-discovered endpoint (e.g. `OLLAMA_BASE_URL`) from * shadowing a same-named model the user configured explicitly. */ #orderImplicitProvidersLast(models: Model[]): Model[] { const implicit = this.#implicitLocalProviders; if (implicit.size === 0) return models; let sawImplicit = false; for (const model of models) { if (implicit.has(model.provider)) { sawImplicit = true; } else if (sawImplicit) { const explicit: Model[] = []; const deferred: Model[] = []; for (const candidate of models) (implicit.has(candidate.provider) ? deferred : explicit).push(candidate); return explicit.concat(deferred); } } return models; } /** * Accumulate every command-backed (`!cmd`) config value from an apiKey and a * header record into `target`. Used at load time to record which commands a * provider depends on so the 401 refresh path can invalidate all of them. */ #collectCommandConfigValues( target: Set, apiKey: string | undefined, headers: Record | undefined, ): void { if (isCommandConfigValue(apiKey)) target.add(apiKey); if (!headers) return; for (const key in headers) { const value = headers[key]; if (isCommandConfigValue(value)) target.add(value); } } /** * Drop the process-cached results of every command-backed config value a * provider carries (apiKey plus provider/model-override header commands) so * the next resolve re-runs them. Invoked on the 401 force-refresh path: the * apiKey alone was refreshed before, leaving command-backed header * credentials pinned to their stale value across the retry (#9760). */ #invalidateProviderCommandConfigs(provider: string): void { this.#owner?.invalidateCommandConfigs(provider); this.#invalidateLocalProviderCommandConfigs(provider); this.#recordMirrorChange({ kind: "invalidateCommands", provider }); } /** Drop only this process's cached results for `provider`'s command-backed values. */ #invalidateLocalProviderCommandConfigs(provider: string): void { invalidateCommandConfig(this.#customProviderApiKeys.get(provider)); const configs = this.#commandConfigsByProvider.get(provider); if (configs) { for (const config of configs) invalidateCommandConfig(config); } const runtimeConfigs = this.#runtimeCommandConfigsByProvider.get(provider); if (!runtimeConfigs) return; for (const config of runtimeConfigs) invalidateCommandConfig(config); } #recordRuntimeCommandConfigs(providerName: string, config: ProviderConfigInput): void { const target = this.#runtimeCommandConfigsByProvider.get(providerName) ?? new Set(); this.#collectCommandConfigValues(target, config.apiKey, config.headers); for (const modelDef of config.models ?? []) { this.#collectCommandConfigValues(target, undefined, modelDef.headers); } if (target.size > 0) this.#runtimeCommandConfigsByProvider.set(providerName, target); else this.#runtimeCommandConfigsByProvider.delete(providerName); } /** Fold `!command` headers from a live `fetchDynamicModels` payload into the runtime tracker. */ #recordRuntimeModelHeaderCommands( providerName: string, models: readonly { headers?: Record }[], ): void { if (models.length === 0) return; const target = this.#runtimeCommandConfigsByProvider.get(providerName) ?? new Set(); for (const modelDef of models) { this.#collectCommandConfigValues(target, undefined, modelDef.headers); } if (target.size > 0) this.#runtimeCommandConfigsByProvider.set(providerName, target); } #reloadStaticModelsForRefresh(options?: ModelRegistryRefreshOptions, providerId?: string): void { if (options?.refreshCommandCredentials) { if (providerId) this.#invalidateProviderCommandConfigs(providerId); else this.#invalidateAllCommandConfigs(); this.#reloadStaticModels({ force: true, preserveRuntimeDiscovery: true }); return; } this.#reloadStaticModels(); } #installProviderApiKey(provider: string, keyConfig: string, options?: { fallback?: boolean }): void { this.#customProviderApiKeys.set(provider, keyConfig); if (!this.#owner) this.authStorage.keys.setConfig(provider, keyConfig, options); } #invalidateAllCommandConfigs(): void { this.#owner?.invalidateCommandConfigs(); invalidateAllCommandConfigs(); this.#recordMirrorChange({ kind: "invalidateCommands", provider: undefined }); } /** * Drop this process's cached `!command` results (one provider's, or all) so * the next key or header resolve re-runs them. A mirror registry forwards * this to its {@link ModelRegistryOwner}, whose resolver mints the keys. */ invalidateCommandConfigs(provider?: string): void { if (provider === undefined) this.#invalidateAllCommandConfigs(); else this.#invalidateProviderCommandConfigs(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; /** Settings source for availability and context-window policies. */ settings?: Settings; /** Model discovery cache database. Defaults beside an explicit models config. */ cacheDbPath?: string; fetch?: FetchImpl; /** * Make this registry a mirror of `owner` (a worker thread's replica of a * main-thread registry built from its {@link mirrorInputs}). `authStorage` * is shared with the owner: read it, but never install the config-value * resolver, models.yml / runtime API keys, or runtime usage providers into * it — the owner already did. Runtime providers and discovery results come * only from the owner through {@link applyMirrorBatch}: local runtime * registration calls are ignored, and discovery and refresh calls run on * the owner. Command-cache invalidations are forwarded to the owner. */ owner?: ModelRegistryOwner; }, ) { this.#ignoreLocalModelConfig = options?.ignoreLocalModelConfig ?? false; this.#owner = options?.owner; this.#mirrorInputs = { modelsPath, ignoreLocalModelConfig: this.#ignoreLocalModelConfig, cacheDbPath: options?.cacheDbPath, settings: options?.settings, }; this.#settings = options?.settings; this.#fetch = options?.fetch ?? (isBunTestRuntime() ? () => Promise.reject(new Error("network disabled in model-registry runtime test")) : wrapFetchForExtraCa(fetch)); this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath ?? path.join(getAgentDir(), "models.yml")); this.#cacheDbPath = options?.cacheDbPath ?? (modelsPath ? path.join(path.dirname(modelsPath), "models.db") : undefined); if (!this.#owner) this.authStorage.keys.setResolver(resolveConfigValue); // Load config and cache-backed layers synchronously in the constructor. this.#loadModels(); } /** * Construction inputs another thread needs to build an equivalent registry * over the same {@link authStorage} (catalog read from the same disk sources). */ mirrorInputs(): ModelRegistryMirrorInputs { return this.#mirrorInputs; } /** * Follow this registry's runtime state from a mirror: `snapshot` reproduces * it now (every live runtime registration plus the discovery layer), and * `listener` then receives each later change batch in order, coalesced per * microtask, until `unsubscribe`. */ subscribeMirror(listener: (batch: ModelRegistryMirrorBatch) => void): { snapshot: ModelRegistryMirrorBatch; unsubscribe: () => void; } { const discovery = this.#discoveryLayer(); if (this.#mirrorListeners.size === 0) this.#mirroredDiscovery = discovery; this.#mirrorListeners.add(listener); return { snapshot: { seq: this.#mirrorSeq, changes: [...this.#runtimeRegistrations], discovery }, unsubscribe: () => { this.#mirrorListeners.delete(listener); }, }; } /** * Mirror only: apply a batch from the owner's {@link subscribeMirror} — the * snapshot first, then each change batch in order. Changes the mirror * already applied (by sequence number) are skipped. */ applyMirrorBatch(batch: ModelRegistryMirrorBatch): void { if (!this.#owner) throw new Error("applyMirrorBatch: this registry is not a mirror"); for (const change of batch.changes) { if (change.seq <= this.#mirrorSeq) continue; try { this.#applyMirrorChange(change); } catch (error) { // The owner hit the same failure applying it; its partial effect is mirrored. logger.warn("model registry mirror: owner change failed to apply", { change: change.kind, error: error instanceof Error ? error.message : String(error), }); } } this.#mirrorSeq = Math.max(this.#mirrorSeq, batch.seq); if (batch.discovery) this.#installDiscoveryLayer(batch.discovery); } #applyMirrorChange(change: ModelRegistryMirrorChange): void { switch (change.kind) { case "register": this.#registerProvider(change.name, change.config, change.sourceId); return; case "unregister": this.#unregisterProvider(change.name); return; case "clearSources": this.#clearSourceRegistrations(change.sourceIds); return; case "syncSources": this.#syncExtensionSources(change.activeSourceIds); return; case "reload": this.#reloadStaticModels({ force: true }); return; case "invalidateCommands": // Local caches only: forwarding to the owner would echo the invalidation back. if (change.provider === undefined) invalidateAllCommandConfigs(); else this.#invalidateLocalProviderCommandConfigs(change.provider); return; } } /** * Run one public runtime-change entry point; only the outermost one is * recorded for mirrors, and only when it completed. */ #runtimeChange(change: ModelRegistryMirrorChangeInput, apply: () => void): void { const outermost = this.#mirrorChangeDepth === 0; this.#mirrorChangeDepth++; try { apply(); } finally { this.#mirrorChangeDepth--; } if (outermost) this.#recordMirrorChange(change); } /** Owner only: number `change` and queue it for subscribed mirrors. A mirror's own sequence tracks its owner's. */ #recordMirrorChange(change: ModelRegistryMirrorChangeInput): void { if (this.#owner) return; const recorded: ModelRegistryMirrorChange = { ...change, seq: ++this.#mirrorSeq }; // After the registration ran: a source handoff inside it already dropped the provider's older entries. if (recorded.kind === "register") this.#runtimeRegistrations.push(recorded); if (this.#mirrorListeners.size === 0) return; this.#pendingMirrorChanges.push(recorded); this.#scheduleMirrorFlush(); } #scheduleMirrorFlush(): void { if (this.#mirrorFlushQueued || this.#mirrorListeners.size === 0) return; this.#mirrorFlushQueued = true; queueMicrotask(() => this.#flushMirror()); } #flushMirror(): void { this.#mirrorFlushQueued = false; const changes = this.#pendingMirrorChanges; this.#pendingMirrorChanges = []; if (this.#mirrorListeners.size === 0) return; const current = this.#discoveryLayer(); const previous = this.#mirroredDiscovery; this.#mirroredDiscovery = current; let discovery: ModelRegistryDiscoveryLayer | undefined = current; // A replayed change resets the mirror's layer (static reloads do), so it always gets the full layer. // Otherwise only what moved crosses: re-running discovery over unchanged caches rebuilds equal // states and rows, which must not cost every mirror a recompose. if (changes.length === 0 && previous?.catalog && current.catalog) { const catalogUnchanged = sameItems(previous.catalog.metricRows, current.catalog.metricRows) && sameItems(previous.catalog.authoritativeProviders, current.catalog.authoritativeProviders) && sameItems(previous.catalog.replacedCacheProviders, current.catalog.replacedCacheProviders) && sameDiscoveredRows(previous.catalog.models, current.catalog.models); const statesUnchanged = sameItems(previous.states, current.states); if (catalogUnchanged && statesUnchanged) return; discovery = catalogUnchanged ? { states: current.states } : current; } const batch: ModelRegistryMirrorBatch = { seq: this.#mirrorSeq, changes, discovery }; for (const listener of this.#mirrorListeners) { try { listener(batch); } catch (error) { logger.warn("model registry mirror listener failed", { error: error instanceof Error ? error.message : String(error), }); } } } #discoveryLayer(): ModelRegistryDiscoveryLayer { return { states: [...this.#providerDiscoveryStates.values()], catalog: { models: this.#runtimeDiscoveredModels, authoritativeProviders: [...this.#runtimeAuthoritativeProviders], replacedCacheProviders: [...this.#replacedCacheProviders], metricRows: this.#catalogMetricRows, }, }; } /** Mirror only: adopt the owner's discovery layer, recomposing the catalog when it came along. */ #installDiscoveryLayer(layer: ModelRegistryDiscoveryLayer): void { this.#providerDiscoveryStates = new Map(layer.states.map(state => [state.provider, state])); const catalog = layer.catalog; if (!catalog) return; if (catalog.replacedCacheProviders.length > 0) { const replaced = new Set(catalog.replacedCacheProviders); for (const provider of replaced) { this.#cachedStandardModelsByProvider.delete(provider); this.#pendingStandardCacheProviders.delete(provider); this.#cachedAuthoritativeProviders.delete(provider); } this.#cachedDiscoverableModels = this.#cachedDiscoverableModels.filter(model => !replaced.has(model.provider)); } this.#replacedCacheProviders = new Set(catalog.replacedCacheProviders); this.#runtimeDiscoveredModels = catalog.models; this.#runtimeAuthoritativeProviders = new Set(catalog.authoritativeProviders); this.#catalogMetrics = new CatalogMetricsIndex(catalog.metricRows); this.#catalogMetricRows = catalog.metricRows; const recompose = this.#hasFullSnapshot; this.#models = []; this.#unprojectedModels = []; this.#baseModels = []; this.#hasFullSnapshot = false; this.#internedStaticModels.clear(); this.#providerLookupSnapshots.clear(); // Like the owner after a discovery pass: a composed catalog is rebuilt as the update // lands, not on the next session's model resolution. if (recompose) this.#ensureFullSnapshot(); } /** * Reload models from disk (built-in + custom config). */ async refresh( strategy: ModelRefreshStrategy = "online-if-uncached", options?: ModelRegistryRefreshOptions, ): Promise { if (this.#owner) return this.#owner.refresh(strategy, options); // Credential minting is opt-in. `strategy: "online"` only means "hit the // network for catalogs" — the unscoped model hub opens with that strategy // as a background reconcile, and must not spawn `!command` helpers. this.#reloadStaticModelsForRefresh(options); this.#suppressedSelectors.clear(); await this.#refreshRuntimeDiscoveries(strategy); } /** * Hydrate credential-scoped built-in catalogs from their exact cache rows. * * The synchronous constructor cannot resolve credentials, so session startup * awaits this local-only, best-effort pass before validating model selectors. */ async hydrateCredentialScopedModelCaches(): Promise { if (this.#owner) return this.#owner.hydrateCredentialScopedModelCaches(); if (!this.#credentialScopedCacheHydration) { const providerIds = new Set(); for (const providerId of STARTUP_MODEL_CACHE_PROVIDER_IDS) { if (isCredentialScopedModelCacheProvider(providerId)) providerIds.add(providerId); } this.#credentialScopedCacheHydration = this.#refreshRuntimeDiscoveries("offline", providerIds).catch(error => { logger.debug("credential-scoped model cache hydration failed", { error: error instanceof Error ? error.message : String(error), }); }); } const hydration = this.#credentialScopedCacheHydration; try { await hydration; } finally { if (this.#credentialScopedCacheHydration === hydration) { this.#credentialScopedCacheHydration = undefined; } } } /** * Rebuild the catalog after a policy-affecting setting change (e.g. * `extendedContext`). Forces the static reload past the models.yml mtime * gate, then restores runtime-discovered models from the SQLite cache — * offline, a settings flip must never hit the network. Concurrent calls * coalesce onto one rebuild. */ reapplyModelPolicies(): Promise { if (this.#owner) return this.#owner.reapplyModelPolicies(); this.#policyReapply ??= this.#runPolicyReapply(); return this.#policyReapply; } async #runPolicyReapply(): Promise { try { this.#lastStaticLoadMtime = null; await this.refresh("offline"); } finally { this.#policyReapply = undefined; } } refreshInBackground(strategy: ModelRefreshStrategy = "online-if-uncached"): void { if (this.#owner) { this.#owner.refreshInBackground(strategy); return; } if (this.#backgroundRefresh) { return; } // `refresh()` synchronously reloads static config, which can reset startup // discovery states. Start it on a microtask so a caller can validate // against the hydrated startup catalog right after kicking the refresh off. const refreshPromise = Promise.resolve() .then(() => 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.#markInitialRefreshSettled(); }); this.#backgroundRefresh = refreshPromise; } /** Whether background discovery started by {@link refreshInBackground} is still running and may add models. */ isBackgroundRefreshPending(): boolean { if (this.#owner) return this.#owner.isBackgroundRefreshPending(); return this.#backgroundRefresh !== undefined; } /** * 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 { if (this.#owner) return this.#owner.awaitBackgroundRefresh(); if (this.#backgroundRefresh) { await this.#backgroundRefresh; } } /** * Catch the catalog up for a view that just read it, rebuilding only when it * is actually stale: waits out an in-flight background refresh, then runs an * offline {@link refresh} if models.yml changed on disk since the last load. * Resolves `true` when either may have changed the catalog (the view should * re-read it), `false` without any rebuild when the in-memory catalog is * already current. Rejects when the offline rebuild fails. */ async refreshIfStale(): Promise { if (this.#owner) return this.#owner.refreshIfStale(); let changed = false; if (this.#backgroundRefresh) { await this.#backgroundRefresh; changed = true; } if (this.#modelsConfigFile.getMtimeMs() !== this.#lastStaticLoadMtime) { await this.refresh("offline"); changed = true; } return changed; } /** * Resolve once the initial background discovery has settled, arming a waiter * even when the refresh has not started yet. In the CLI path * {@link refreshInBackground} runs after the session is constructed * (`main.ts`; interactive mode waits for the first frame), so a consumer * created in the constructor cannot rely on an * in-flight snapshot — it must observe the settle whenever it happens. * Resolves immediately once any background refresh has completed; never * rejects (discovery errors are swallowed by `refreshInBackground`). Stays * pending when no background refresh is ever started (e.g. an embedder that * manages discovery itself), which leaves startup suppression in place. * An optional abort signal releases a waiter when its owning session is * disposed before an embedder starts discovery. */ awaitInitialBackgroundRefresh(signal?: AbortSignal): Promise { if (this.#owner) return this.#owner.awaitInitialBackgroundRefresh(signal); if (this.#initialRefreshSettled) return Promise.resolve(); if (signal?.aborted) return Promise.resolve(); const { promise, resolve } = Promise.withResolvers(); const settle = () => { signal?.removeEventListener("abort", settle); this.#initialRefreshWaiters.delete(settle); resolve(); }; this.#initialRefreshWaiters.add(settle); signal?.addEventListener("abort", settle, { once: true }); // Close the race with a refresh or abort settling between the guards // above and listener registration. if (this.#initialRefreshSettled || signal?.aborted) settle(); return promise; } #markInitialRefreshSettled(): void { if (this.#initialRefreshSettled) return; this.#initialRefreshSettled = true; for (const settle of this.#initialRefreshWaiters) settle(); } async refreshProvider( providerId: string, strategy: ModelRefreshStrategy = "online", options?: ModelRegistryRefreshOptions, ): Promise { if (this.#owner) return this.#owner.refreshProvider(providerId, strategy, options); // Hover / auto-refresh uses `"online"` for a live catalog. Only F5 (and // other explicit callers) pass refreshCommandCredentials to re-mint // `!command` keys and headers for this provider. this.#reloadStaticModelsForRefresh(options, providerId); 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 only named discovery providers (configured `models.yml` providers or * extension `fetchDynamicModels` managers). Unlike {@link refreshProvider}, * this avoids a static reload and leaves unrelated runtime discovery alone. * Unknown ids have no effect. */ async refreshDiscoverableProviders( providerIds: Iterable, strategy: ModelRefreshStrategy = "online-if-uncached", ): Promise { if (this.#owner) return this.#owner.refreshDiscoverableProviders([...providerIds], strategy); const filter = new Set(providerIds); if (filter.size === 0) return; await this.#refreshRuntimeDiscoveries(strategy, filter); } /** * True when the provider's models expose context metadata that only appears * after a lazy load — llama.cpp's `meta.n_ctx` once a cold instance spins up * (#3310/#3311), LM Studio's `loaded_context_length` once it JIT-loads the * model on the first inference (#9001). Callers use this to decide whether a * post-first-response refresh is worth a native probe. */ hasLazyRuntimeMetadata(provider: string): boolean { return this.#findLazyRuntimeDiscovery(provider) !== undefined; } #findLazyRuntimeDiscovery(provider: string): DiscoveryProviderConfig | undefined { return this.#discoverableProviders.find( providerConfig => providerConfig.provider === provider && (providerConfig.discovery.type === "llama.cpp" || providerConfig.discovery.type === "lm-studio"), ); } /** * Refresh dynamic metadata that can appear only after a local model loads. * * llama.cpp exposes `meta.n_ctx` once a lazy-loaded instance is up * (#3310/#3311); LM Studio exposes `loaded_context_length` once it JIT-loads * the model on first inference (#9001). Both are captured only as a snapshot * at discovery time, so re-probe the selected model's native runtime metadata * and patch its context window to what the backend actually serves. */ async refreshSelectedModelMetadata(model: Model): Promise> { const discoveryConfig = this.#findLazyRuntimeDiscovery(model.provider); if (!discoveryConfig) { return model; } this.#ensureFullSnapshot(); const headers = await this.resolveModelHeaders(model); const { resolveHeaders: _resolveHeaders, ...plainModel } = model; const requestModel = { ...plainModel, headers }; const runtimeMetadata = discoveryConfig.discovery.type === "lm-studio" ? await discoverLmStudioModelRuntimeMetadata( requestModel, this.#nonResolvingDiscoveryContext(), discoveryConfig.discovery.timeoutMs, ) : await discoverLlamaCppModelRuntimeMetadata( requestModel, this.#nonResolvingDiscoveryContext(), discoveryConfig.discovery.timeoutMs, ); 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, ); // Later refreshes rebuild from the base snapshot; carry the patch there too. const base = resolveProviderModelReference(current.provider, current.id, this.#baseModels); if (base) { const patchedResolved = applyModelPatch(base, patch, "merge"); this.#baseModels = this.#baseModels.map(candidate => candidate.provider === base.provider && candidate.id === base.id ? patchedResolved : candidate, ); } this.#models = this.#finalizeModels(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 { if (this.#owner) return this.#owner.refreshRuntimeProviders(strategy); if (this.#runtimeModelManagers.size === 0) { return; } await this.#refreshRuntimeDiscoveries(strategy, new Set(this.#runtimeModelManagers.keys())); } #reloadStaticModels(options?: { force?: boolean; preserveRuntimeDiscovery?: boolean }): void { const currentMtime = this.#modelsConfigFile.getMtimeMs(); const staticConfigUnchanged = currentMtime === this.#lastStaticLoadMtime; if (!options?.force && currentMtime !== null && staticConfigUnchanged) { // Models config unchanged since last load; reloading would be redundant. return; } // Inside a runtime-change entry point the mirror replays the reload with the change itself. if (this.#mirrorChangeDepth === 0) this.#recordMirrorChange({ kind: "reload" }); let preservedRuntimeState: | { models: Model[]; authoritativeProviders: Set; discoveryStates: Map; runtimeProviderIds: Set; configuredProviders: Map; providerOverrides: Map; modelOverrides: Map>; keylessProviders: Set; } | undefined; if (options?.preserveRuntimeDiscovery) { preservedRuntimeState = { models: this.#runtimeDiscoveredModels, authoritativeProviders: new Set(this.#runtimeAuthoritativeProviders), discoveryStates: new Map(this.#providerDiscoveryStates), runtimeProviderIds: new Set(this.#runtimeModelManagers.keys()), configuredProviders: new Map(this.#discoverableProviders.map(config => [config.provider, config])), providerOverrides: new Map(this.#providerOverrides), modelOverrides: new Map(this.#modelOverrides), keylessProviders: new Set(this.#keylessProviders), }; } 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. if (!this.#owner) this.authStorage.keys.clearConfig(); // Restore runtime API keys before #loadModels — survives because // #loadModels only calls .set() on #customProviderApiKeys, never reassigns it. for (const [provider, { keyConfig, fallback }] of this.#runtimeProviderApiKeys) { this.#installProviderApiKey(provider, keyConfig, { fallback }); } this.#providerOverrides.clear(); this.#modelOverrides.clear(); this.#configError = undefined; this.#providerDiscoveryStates.clear(); this.#loadModels(); if (!preservedRuntimeState) return; const preservedProviderIds = preservedRuntimeState.runtimeProviderIds; const candidateProviderIds = new Set(preservedRuntimeState.models.map(model => model.provider)); for (const providerId of preservedRuntimeState.authoritativeProviders) candidateProviderIds.add(providerId); for (const providerId of preservedRuntimeState.discoveryStates.keys()) candidateProviderIds.add(providerId); if (staticConfigUnchanged) { for (const providerId of candidateProviderIds) preservedProviderIds.add(providerId); } else { const configuredProviders = new Map(this.#discoverableProviders.map(config => [config.provider, config])); const startupProviderIds = new Set(STARTUP_MODEL_CACHE_PROVIDER_IDS); for (const providerId of candidateProviderIds) { const previousConfig = preservedRuntimeState.configuredProviders.get(providerId); const currentConfig = configuredProviders.get(providerId); const providerSettingsUnchanged = Bun.deepEquals( preservedRuntimeState.providerOverrides.get(providerId), this.#providerOverrides.get(providerId), ) && Bun.deepEquals( preservedRuntimeState.modelOverrides.get(providerId), this.#modelOverrides.get(providerId), ) && preservedRuntimeState.keylessProviders.has(providerId) === this.#keylessProviders.has(providerId); const discoveryIdentityUnchanged = (previousConfig !== undefined && currentConfig !== undefined && Bun.deepEquals(previousConfig, currentConfig)) || (previousConfig === undefined && currentConfig === undefined && startupProviderIds.has(providerId)); if (providerSettingsUnchanged && discoveryIdentityUnchanged) { preservedProviderIds.add(providerId); } } } this.#runtimeDiscoveredModels = preservedRuntimeState.models.filter(model => preservedProviderIds.has(model.provider), ); this.#runtimeAuthoritativeProviders = new Set( [...preservedRuntimeState.authoritativeProviders].filter(providerId => preservedProviderIds.has(providerId)), ); for (const [providerId, state] of preservedRuntimeState.discoveryStates) { if (preservedProviderIds.has(providerId)) this.#providerDiscoveryStates.set(providerId, state); } } /** * Get any error from loading custom models config (undefined if no error). */ getError(): ConfigError | undefined { return this.#configError; } /** * Drain non-fatal diagnostics: models.yml key problems (once per key path) * and discovery failures of env-redirected built-in providers (once per * distinct error). */ drainConfigWarnings(): string[] { const warnings = this.#configWarnings; this.#configWarnings = []; return warnings; } #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, } = logger.time("modelRegistry:loadCustomModels", () => this.#loadCustomModels()); this.#configError = configError; this.#keylessProviders = keylessProviders; this.#discoverableProviders = discoverableProviders; this.#customModelOverlays = customModels; this.#providerOverrides = overrides; this.#modelOverrides = modelOverrides; this.#addImplicitDiscoverableProviders(configuredProviders); const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(provider => provider.provider)); this.#pendingStandardCacheProviders = new Set( STARTUP_MODEL_CACHE_PROVIDER_IDS.filter( providerId => !configuredDiscoveryProviders.has(providerId) && !isCredentialScopedModelCacheProvider(providerId), ), ); this.#cachedDiscoverableModels = logger.time("modelRegistry:loadDiscoverableModels", () => this.#applyHardcodedModelPolicies(this.#loadCachedDiscoverableModels()), ); this.#lastStaticLoadMtime = this.#modelsConfigFile.getMtimeMs(); } #resetStaticComposition(): void { this.#models = []; this.#unprojectedModels = []; this.#baseModels = []; this.#hasFullSnapshot = false; this.#cachedStandardModelsByProvider.clear(); this.#pendingStandardCacheProviders.clear(); this.#cachedAuthoritativeProviders.clear(); this.#replacedCacheProviders.clear(); this.#runtimeDiscoveredModels = []; this.#runtimeAuthoritativeProviders.clear(); this.#internedStaticModels.clear(); this.#providerLookupSnapshots.clear(); this.#commandConfigsByProvider.clear(); } #knownStaticProviders(): string[] { const providers = new Set(getBundledProviders()); for (const provider of this.#pendingStandardCacheProviders) providers.add(provider); for (const provider of this.#cachedStandardModelsByProvider.keys()) providers.add(provider); for (const model of this.#cachedDiscoverableModels) providers.add(model.provider); for (const model of this.#runtimeDiscoveredModels) 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[]): Model[] { 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; }); } #invalidateProviderModelCache(providerName: string): void { const prefix = `${providerName}\u0000`; for (const key of this.#internedStaticModels.keys()) { if (key.startsWith(prefix)) { this.#internedStaticModels.delete(key); } } this.#providerLookupSnapshots.delete(providerName); } /** * 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, and the * rows it returns for its own provider are re-materialized through * `buildModel` so the registry keeps its "every model is built" invariant. */ #applyRuntimeModelModifiers(models: Model[]): Model[] { if (this.#runtimeModelModifiers.size === 0) return models; let projected = models; for (const [providerName, modifyModels] of this.#runtimeModelModifiers) { const credential = this.authStorage.credentials.getOAuth(providerName); if (!credential) continue; try { // Clone mutable catalog data, retaining resolver functions as opaque // capabilities. Hooks can then rename models without losing their // request-time credentials or resolving them during catalog reads. const snapshot: Model[] = structuredClone( projected.map(({ resolveHeaders: _resolveHeaders, ...model }) => model), ); for (let index = 0; index < snapshot.length; index++) { if (projected[index].resolveHeaders) snapshot[index].resolveHeaders = projected[index].resolveHeaders; } // A hook owns its provider's rows and may synthesize them outright // (a credential-scoped catalog replacing the registered bootstrap) // or respell the ones it was handed. Extensions author `ModelSpec`s // — `identity`, `compat`, `thinking` and the rest of the resolved // surface are `Omit`ted from that type and only `buildModel` // produces them — so those rows are rebuilt rather than trusted: // otherwise the registry serves half-built models whose first // reader crashes, or an identity left over from a different id. // Rows of other providers pass through untouched; rebuilding a // full catalog on every projection costs more than it can fix. projected = modifyModels(snapshot, credential).map(model => { const withHeaders = model.resolveHeaders && model.headers ? { ...model, headers: undefined, resolveHeaders: createConfigHeaderResolver([model.resolveHeaders, model.headers]), } : model; if (withHeaders.provider !== providerName) return withHeaders as Model; // `identity` exists only on built rows. Without it the hook // authored a spec, where `compat` already is the sparse // override `toModelSpec` would drop while reading the absent // `compatConfig`; built rows keep resolving from that field // instead of feeding the resolved view back in as an override. return buildModel( withHeaders.identity === undefined ? (withHeaders as ModelSpec) : toModelSpec(withHeaders as Model), ); }); } 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 }); } /** Built-in and cached models, before runtime discoveries and every projection. */ #composeCachedBaseModels(providerFilter?: ReadonlySet): Model[] { const cachedStandardModels = this.#getCachedStandardModels(providerFilter); let builtInModels = this.#applyHardcodedModelPolicies( this.#loadBuiltInModels(this.#providerOverrides, providerFilter), ); if (this.#cachedAuthoritativeProviders.size > 0) { builtInModels = dropProviderModels(builtInModels, this.#cachedAuthoritativeProviders, { kind: "chat" }); } return this.#mergeResolvedModels( this.#mergeResolvedModels(builtInModels, cachedStandardModels), selectProviderModels(this.#cachedDiscoverableModels, providerFilter), ); } /** The `#baseModels` stage: cached base models plus runtime discoveries. */ #composeBaseModels(providerFilter?: ReadonlySet): Model[] { let resolvedDefaults = this.#composeCachedBaseModels(providerFilter); if (this.#runtimeAuthoritativeProviders.size > 0) { resolvedDefaults = dropProviderModels(resolvedDefaults, this.#runtimeAuthoritativeProviders, { kind: "chat" }); } return this.#mergeResolvedModels( resolvedDefaults, selectProviderModels(this.#runtimeDiscoveredModels, providerFilter), ); } /** * Entries a discovered model inherits headers and transport from: cached * base models with custom overlays, without earlier discoveries or override * projections. Referencing a projected or previously discovered row would * nest its header resolver one level deeper on every refresh. */ #composeDiscoveryReferenceModels(providerFilter: ReadonlySet): Model[] { return this.#mergeCustomModels( this.#mergeCustomModels( this.#composeCachedBaseModels(providerFilter), selectProviderModels(this.#customModelOverlays, providerFilter), ), selectProviderModels(this.#runtimeModelOverlays, providerFilter), ); } /** Applies custom overlays and every override projection to a `#baseModels` stage. */ #projectBaseModels(baseModels: Model[], providerFilter?: ReadonlySet): Model[] { const withConfigModels = this.#mergeCustomModels( baseModels, selectProviderModels(this.#customModelOverlays, providerFilter), ); const combined = this.#mergeCustomModels( withConfigModels, selectProviderModels(this.#runtimeModelOverlays, providerFilter), ); const withModelOverrides = this.#applyModelOverrides(collapseBuiltVariants(combined), this.#modelOverrides); const withProviderBedrock = this.#applyProviderBedrockOverrides(withModelOverrides); return this.#applyDiscoveryPolicies(this.#applyRuntimeProviderOverrides(withProviderBedrock)); } #composeStaticModels(providerFilter?: ReadonlySet): Model[] { // 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 compositionFilter = projectFullCatalog ? undefined : providerFilter; const unprojected = this.#projectBaseModels(this.#composeBaseModels(compositionFilter), compositionFilter); const projected = this.#finalizeModels(this.#applyRuntimeModelModifiers(unprojected)); const selected = projectFullCatalog ? projected.filter(model => providerFilter.has(model.provider)) : projected; return this.#internStaticModels(selected); } #ensureFullSnapshot(): Model[] { if (!this.#hasFullSnapshot) { this.#baseModels = this.#composeBaseModels(); this.#unprojectedModels = this.#projectBaseModels(this.#baseModels); this.#models = this.#internStaticModels( this.#finalizeModels(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, providerFilter?: ReadonlySet): Model[] { return getBundledProviders().flatMap(provider => { if (providerFilter && !providerFilter.has(provider)) return []; const models = getBundledModels(provider as Parameters[0]) as Model[]; const providerOverride = overrides.get(provider); return models.map(m => { if (!providerOverride) return m; const withTransportOverride = this.#applyProviderTransportOverride(toModelSpec(m), providerOverride); return buildModel({ ...withTransportOverride, compat: mergeCompat(m.compatConfig, providerOverride.compat), } as ModelSpec); }); }); } #mergeResolvedModels(baseModels: Model[], replacementModels: Model[]): Model[] { return mergeByModelKey(baseModels, replacementModels, (existing, replacementModel) => { if (!existing) return replacementModel; const contextWindow = replacementModel.contextWindow ?? existing.contextWindow; const maxTokens = replacementModel.maxTokens ?? existing.maxTokens; const omitMaxOutputTokens = replacementModel.omitMaxOutputTokens ?? existing.omitMaxOutputTokens; const supportsTools = replacementModel.supportsTools ?? existing.supportsTools; if ( contextWindow === replacementModel.contextWindow && maxTokens === replacementModel.maxTokens && omitMaxOutputTokens === replacementModel.omitMaxOutputTokens && supportsTools === replacementModel.supportsTools ) { return replacementModel; } return { ...replacementModel, contextWindow, maxTokens, omitMaxOutputTokens, ...(supportsTools !== undefined ? { supportsTools } : {}), }; }); } /** Merge custom models with built-in, replacing by provider+id match */ #mergeCustomModels(builtInModels: Model[], customModels: CustomModelOverlay[]): Model[] { return mergeByModelKey(builtInModels, customModels, (existingModel, customModel) => { // Same-id custom definitions replace bundled transport behavior, so the // patch is applied with the `replace` transport policy. const model = existingModel ? applyModelPatch( { ...existingModel, id: customModel.id, provider: customModel.provider, api: customModel.api, baseUrl: customModel.baseUrl, }, customModel, "replace", ) : finalizeCustomModel(customModel, { useDefaults: true }); const override = this.#providerOverrides.get(model.provider); // Custom composition already resolved headers and metadata. Reapply only // the provider transport and its gateway URL, without rebuilding the model. const merged = override?.transport ? this.#applyProviderTransportOverride(model, { baseUrl: override.baseUrl, transport: override.transport, }) : model; // An overlay that authors no window keeps the bundled row's window, so it // keeps that row's policy tiers too; one that authors a window owns its own. const inheritedTiers = existingModel && customModel.contextWindow === undefined && customModel.maxContextWindow === undefined ? this.#policyWindowTiers.get(existingModel) : undefined; if (inheritedTiers) this.#policyWindowTiers.set(merged, inheritedTiers); return merged; }); } #descriptorBaseUrl(providerId: string): string | undefined { return ( this.#runtimeProviderOverrides.get(providerId)?.baseUrl ?? this.#providerOverrides.get(providerId)?.baseUrl ?? (this.#hasFullSnapshot ? this.getProviderBaseUrl(providerId) : undefined) ); } /** * Credential-less cache namespace. Wrong for credential-scoped providers * (`isCredentialScopedModelCacheProvider`), whose discovery writes hash the * credential in; those load only through their model manager. */ #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(providerIds: readonly string[]): { modelsByProvider: Map[]>; authoritativeFreshProviders: Set; } { const modelsByProvider = new Map[]>(); const authoritativeFreshProviders = new Set(); using _cacheConnection = retainModelCacheConnection(this.#cacheDbPath); for (const providerId of providerIds) { const cacheProviderId = this.#resolveStartupModelCacheProviderId(providerId); const cache = readModelCache(cacheProviderId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath); const sharedCatalogProvider = MODELS_DEV_CATALOG_PROVIDER_ID_LOOKUP[providerId] === true; const additiveSharedCatalogProvider = ADDITIVE_MODELS_DEV_CATALOG_PROVIDER_ID_LOOKUP[providerId] === true; if (!cache) { const descriptor = PROVIDER_DESCRIPTORS.find(candidate => candidate.providerId === providerId); const discoveryExpected = sharedCatalogProvider || (descriptor !== undefined && (this.authStorage.keys.source(providerId) !== undefined || descriptor.allowUnauthenticated === true || this.#keylessProviders.has(providerId))); if (discoveryExpected) { this.#providerDiscoveryStates.set(providerId, { provider: providerId, status: "idle", optional: false, stale: false, source: "bundled", models: [], }); } continue; } if (cache.fresh && cache.authoritative) { authoritativeFreshProviders.add(providerId); } // The 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 bundledModels = omittedHeaderIds.size > 0 || sharedCatalogProvider ? (getBundledModels(providerId as Parameters[0]) as Model[]) : undefined; // Every shared-catalog manager layers the catalog additively // (`#collectBuiltInModelManagerOptions`), authoritative or not. const bundledFingerprint = bundledModels ? fingerprintStaticModels(bundledModels, { authoritative: sharedCatalogProvider && !additiveSharedCatalogProvider, additive: sharedCatalogProvider, }) : undefined; // A matching cache may contain provider-endpoint overrides. Strip // same-id rows only across a bundled-catalog upgrade, where the // additive shared catalog must not replace the new bundled metadata. const additiveCacheStaticMismatch = additiveSharedCatalogProvider && bundledFingerprint !== undefined && cache.staticFingerprint !== bundledFingerprint && !cache.staticFingerprint.startsWith(`${bundledFingerprint}:drop:`); const bundledById = bundledModels ? new Map(bundledModels.map(bundledModel => [bundledModel.id, bundledModel])) : undefined; const models: Model[] = []; for (const cachedModel of cache.models) { // Shared catalog rows can be projected under another provider id. // That changes policy inputs, so only this projection is rebuilt; // same-provider materialized cache rows stay on the zero-build path. const model = cachedModel.provider === providerId ? cachedModel : buildModel({ ...toModelSpec(cachedModel), provider: providerId }); if (additiveCacheStaticMismatch && bundledById?.has(model.id)) continue; if (!omittedHeaderIds.has(model.id)) { models.push(model); 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(model.id); const bundledHeaders = ( unrestorable ? cache.legacyHeaderRestoreMarkers && model.requestModelId ? bundledById?.get(model.requestModelId) : undefined : (bundledById?.get(model.id) ?? (model.requestModelId ? bundledById?.get(model.requestModelId) : undefined)) )?.headers; if (!bundledHeaders) continue; models.push({ ...model, headers: bundledHeaders }); } const providerOverride = this.#providerOverrides.get(providerId); const withCompat = providerOverride ? models.map(model => { const spec = this.#applyProviderTransportOverride(toModelSpec(model), providerOverride); return buildModel({ ...spec, compat: mergeCompat(model.compatConfig, providerOverride.compat), }); }) : models; const resolved = this.#applyProviderModelOverrides(providerId, withCompat); const cachedModels = this.#applyHardcodedModelPolicies(resolved); modelsByProvider.set(providerId, cachedModels); if (sharedCatalogProvider) { const cacheMatchesBundledFingerprint = bundledFingerprint !== undefined && (cache.staticFingerprint === bundledFingerprint || cache.staticFingerprint.startsWith(`${bundledFingerprint}:drop:`)); const cachedSnapshotMatchesBundled = bundledModels !== undefined && fingerprintStaticModels(cache.models) === fingerprintStaticModels(bundledModels); const cacheContributed = additiveSharedCatalogProvider ? cachedModels.some(model => bundledById?.has(model.id) !== true) : !(cacheMatchesBundledFingerprint && cachedSnapshotMatchesBundled); const stale = !cache.fresh || !cache.authoritative; this.#providerDiscoveryStates.set(providerId, { provider: providerId, status: cacheContributed ? "cached" : stale ? "unavailable" : "idle", optional: false, stale, ...(cacheContributed ? { fetchedAt: cache.updatedAt } : {}), source: cacheContributed ? "cache" : "bundled", models: cachedModels.map(model => model.id), }); } } return { modelsByProvider, authoritativeFreshProviders }; } /** * Materialize only the cached provider slices needed by this composition. * A full snapshot (`providerFilter` omitted) drains every pending provider in * descriptor order; provider-scoped lookups leave unrelated JSON rows * unparsed until their own first read. */ #getCachedStandardModels(providerFilter?: ReadonlySet): Model[] { const providerIds = STARTUP_MODEL_CACHE_PROVIDER_IDS.filter( providerId => this.#pendingStandardCacheProviders.has(providerId) && (providerFilter === undefined || providerFilter.has(providerId)), ); if (providerIds.length > 0) { for (const providerId of providerIds) this.#pendingStandardCacheProviders.delete(providerId); const loaded = logger.time("modelRegistry:loadCachedStandardModels", () => this.#loadCachedStandardProviderModels(providerIds), ); for (const [providerId, models] of loaded.modelsByProvider) { this.#cachedStandardModelsByProvider.set(providerId, models); } for (const providerId of loaded.authoritativeFreshProviders) { const models = loaded.modelsByProvider.get(providerId) ?? []; if ( providersWithAuthoritativeProjectCatalog(models).has(providerId) || AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS.has(providerId) ) { this.#cachedAuthoritativeProviders.add(providerId); } } } const models: Model[] = []; for (const providerId of STARTUP_MODEL_CACHE_PROVIDER_IDS) { if (providerFilter && !providerFilter.has(providerId)) continue; const providerModels = this.#cachedStandardModelsByProvider.get(providerId); if (providerModels) models.push(...providerModels); } return models; } #canRestoreConfiguredDiscoveryHeaders(providerId: string): boolean { const override = this.#providerOverrides.get(providerId); return override?.authHeader === true && override.apiKey !== undefined; } #loadCachedDiscoverableModels(): Model[] { const cachedModels: Model[] = []; using _cacheConnection = retainModelCacheConnection(this.#cacheDbPath); for (const providerConfig of this.#discoverableProviders) { const cacheProviderId = this.#configuredDiscoveryCacheProviderId(providerConfig); const cache = readModelCache(cacheProviderId, 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). A configured authHeader // is re-derived asynchronously at the request boundary, so its rows are // safe to retain without baking a credential snapshot into the cache. const canRestoreHeaders = this.#canRestoreConfiguredDiscoveryHeaders(providerConfig.provider); const omittedHeaderIds = new Set(cache.headerOmittedModelIds); const hasUnrestoredHeaders = omittedHeaderIds.size > 0 && !canRestoreHeaders; const usableCacheModels = omittedHeaderIds.size === 0 || canRestoreHeaders ? cache.models : cache.models.filter(model => !omittedHeaderIds.has(model.id)); const providerOverride = this.#providerOverrides.get(providerConfig.provider); const restoredCacheModels = providerOverride ? usableCacheModels.map(model => this.#applyProviderTransportOverrideToModel(model, providerOverride)) : usableCacheModels; const models = this.#applyProviderModelOverrides( providerConfig.provider, this.#normalizeDiscoverableModels( providerConfig, this.#applyProviderCompat(providerConfig.compat, restoredCacheModels), ), ); 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 || hasUnrestoredHeaders, fetchedAt: cache.updatedAt, models: models.map(model => model.id), }); } return cachedModels; } #applyProviderCompat(compat: ModelSpec["compat"] | undefined, models: Model[]): Model[] { if (!compat) return models; return models.map(model => buildModel({ ...model, compat: mergeCompat(model.compatConfig, compat) } as ModelSpec), ); } #normalizeDiscoverableModels(providerConfig: DiscoveryProviderConfig, models: Model[]): Model[] { 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), ) : models; const withRemoteCompaction = providerConfig.remoteCompaction ? withDecoderMetadata.map(model => buildModel({ ...model, remoteCompaction: mergeProviderRemoteCompactionConfig( model.remoteCompaction, providerConfig.remoteCompaction, ), compat: model.compatConfig, } as ModelSpec), ) : withDecoderMetadata; // User config plumbing, not catalog policy: an ollama provider configured // for Responses moves its completions-discovered rows onto that API. // KDL `api-routes` key on model ids and cannot see the provider config. 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) : model; if (contextLengthOverride === undefined) { return normalized; } return { ...normalized, contextWindow: contextLengthOverride, maxTokens: Math.min(contextLengthOverride, DISCOVERY_DEFAULT_MAX_TOKENS), }; }); } #addImplicitDiscoverableProviders(configuredProviders: Set): void { const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings); this.#implicitLocalProviders.clear(); this.#implicitProviderEndpointEnv.clear(); for (const provider of ["local", "web"]) { if (!disabledProviders.has(provider)) this.#keylessProviders.add(provider); } const ollamaEndpointEnv = Bun.env.OLLAMA_BASE_URL?.trim() ? "OLLAMA_BASE_URL" : Bun.env.OLLAMA_HOST?.trim() ? "OLLAMA_HOST" : undefined; if (!configuredProviders.has("ollama") && !disabledProviders.has("ollama")) { this.#discoverableProviders.push({ provider: "ollama", api: "openai-responses", baseUrl: getImplicitOllamaBaseUrl(), discovery: { type: "ollama" }, optional: ollamaEndpointEnv === undefined, }); this.#keylessProviders.add("ollama"); this.#implicitLocalProviders.add("ollama"); if (ollamaEndpointEnv) this.#implicitProviderEndpointEnv.set("ollama", ollamaEndpointEnv); } 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 || LLAMA_CPP_DEFAULT_BASE_URLS[0], discovery: { type: "llama.cpp" }, optional: !Bun.env.LLAMA_CPP_BASE_URL, fallbackBaseUrls: Bun.env.LLAMA_CPP_BASE_URL ? undefined : LLAMA_CPP_DEFAULT_BASE_URLS.slice(1), }); // Only mark as keyless if no API key is configured if (this.authStorage.keys.source("llama.cpp") === undefined) { this.#keylessProviders.add("llama.cpp"); } this.#implicitLocalProviders.add("llama.cpp"); if (Bun.env.LLAMA_CPP_BASE_URL) this.#implicitProviderEndpointEnv.set("llama.cpp", "LLAMA_CPP_BASE_URL"); } if ( process.platform === "darwin" && process.arch === "arm64" && !configuredProviders.has("apple") && !disabledProviders.has("apple") ) { this.#discoverableProviders.push({ provider: "apple", api: "apple-foundation-models", baseUrl: "local://apple-foundation-models", discovery: { type: "apple-foundation-models" }, optional: true, }); this.#keylessProviders.add("apple"); this.#implicitLocalProviders.add("apple"); } 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: !Bun.env.LM_STUDIO_BASE_URL, }); this.#keylessProviders.add("lm-studio"); this.#implicitLocalProviders.add("lm-studio"); if (Bun.env.LM_STUDIO_BASE_URL) this.#implicitProviderEndpointEnv.set("lm-studio", "LM_STUDIO_BASE_URL"); } } #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, }; } for (const keyPath of getUnknownCompatKeys(value)) { if (this.#warnedCompatKeys.has(keyPath)) continue; this.#warnedCompatKeys.add(keyPath); this.#configWarnings.push( `Unknown compat key in ${this.#modelsConfigFile.path()}: ${keyPath} (configuration still loaded).`, ); } const overrides = new Map(); const allModelOverrides = new Map>(); const keylessProviders = new Set(); const discoverableProviders: DiscoveryProviderConfig[] = []; const providerEntries = Object.entries(value.providers ?? {}); const configuredProviders = new Set(Object.keys(value.providers ?? {})); for (const [providerName, providerConfig] of providerEntries) { const commandConfigs = new Set(); this.#collectCommandConfigValues(commandConfigs, providerConfig.apiKey, providerConfig.headers); for (const modelDef of providerConfig.models ?? []) { this.#collectCommandConfigValues(commandConfigs, undefined, modelDef.headers); } // Scope: effective APIs of models inheriting the provider URL; a // provider-level api covers the override-only case; none is wide. const baseUrlApis = new Set(); for (const modelDef of providerConfig.models ?? []) { if (modelDef.baseUrl) continue; const modelApi = modelDef.api ?? providerConfig.api; if (modelApi) baseUrlApis.add(modelApi); } if (providerConfig.api && (providerConfig.models?.length ?? 0) === 0) { baseUrlApis.add(providerConfig.api); } const baseUrlScope = baseUrlApis.size > 0 ? [...baseUrlApis] : undefined; // Always set overrides when baseUrl/headers/apiKey/authHeader/compat/disableStrictTools/guardrail*/transport are present if ( providerConfig.baseUrl || providerConfig.headers || providerConfig.apiKey || providerConfig.authHeader !== undefined || providerConfig.compat || providerConfig.disableStrictTools || providerConfig.guardrailIdentifier || providerConfig.requestMetadata || providerConfig.remoteCompaction || providerConfig.transport ) { const disableStrictCompat = providerConfig.disableStrictTools ? { disableStrictTools: true } : undefined; overrides.set(providerName, { baseUrlApis: baseUrlScope, baseUrl: providerConfig.discovery?.type === "litellm" ? normalizeLiteLLMDiscoveryBaseUrl(providerConfig.baseUrl) : providerConfig.discovery?.type === "openai-models-list" && providerConfig.discovery.injectV1 === false ? normalizeBareDiscoveryBaseUrl(providerConfig.baseUrl) : providerConfig.baseUrl, headers: providerConfig.headers, apiKey: providerConfig.apiKey, authHeader: providerConfig.authHeader, compat: mergeCompat(providerConfig.compat, disableStrictCompat), remoteCompaction: providerConfig.remoteCompaction, transport: providerConfig.transport, guardrailIdentifier: providerConfig.guardrailIdentifier, guardrailVersion: providerConfig.guardrailVersion, guardrailTrace: providerConfig.guardrailTrace, requestMetadata: providerConfig.requestMetadata, }); } 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: providerConfig.headers, 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. Header values stay raw (`!cmd` intact) // until the async request boundary, letting a 401 refresh reach // header-carried credentials (#9760). if (providerConfig.modelOverrides) { const perModel = new Map(); for (const [modelId, override] of Object.entries(providerConfig.modelOverrides)) { this.#collectCommandConfigValues(commandConfigs, undefined, override.headers); perModel.set(modelId, override); } allModelOverrides.set(providerName, perModel); } if (commandConfigs.size > 0) this.#commandConfigsByProvider.set(providerName, commandConfigs); } return { models: this.#parseModels(value), overrides, modelOverrides: allModelOverrides, keylessProviders, discoverableProviders, configuredProviders, found: true, }; } async #refreshRuntimeDiscoveries( strategy: ModelRefreshStrategy, providerFilter?: ReadonlySet, ): Promise { try { await this.#runRuntimeDiscoveries(strategy, providerFilter); } finally { // Mirrors follow every discovery-layer change the pass made, including failure states. this.#scheduleMirrorFlush(); } } async #runRuntimeDiscoveries(strategy: ModelRefreshStrategy, providerFilter?: ReadonlySet): Promise { // One cache connection for the whole pass: every provider manager reads // (and may write) its row, which would otherwise reopen the database each time. using _cacheConnection = retainModelCacheConnection(this.#cacheDbPath); const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings); 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>([]) : Promise.all( selectedDiscoverableProviders.map(async provider => ({ provider, ...(await this.#discoverProviderModelsCoalesced(provider, strategy)), })), ); const [configuredDiscoveryResults, builtInDiscovery] = await Promise.all([ configuredDiscoveriesPromise, this.#discoverBuiltInProviderModels(strategy, providerFilter), ]); this.#captureCatalogMetrics(builtInDiscovery.models, providerFilter === undefined); const currentDiscoverableProviders = new Set(this.#discoverableProviders); const currentConfiguredDiscoveryResults = configuredDiscoveryResults.filter(result => currentDiscoverableProviders.has(result.provider), ); const configuredDiscovered = currentConfiguredDiscoveryResults.flatMap(result => result.models); const replacedConfiguredProviders = currentConfiguredDiscoveryResults.flatMap(result => result.replaceRuntimeModels ? [result.provider.provider] : [], ); const discovered = [...configuredDiscovered, ...builtInDiscovery.models]; if ( discovered.length === 0 && builtInDiscovery.authoritativeProviders.size === 0 && builtInDiscovery.replaceRuntimeProviders.size === 0 && replacedConfiguredProviders.length === 0 ) { return; } const touchedProviders = new Set(discovered.map(model => model.provider)); for (const provider of replacedConfiguredProviders) touchedProviders.add(provider); for (const provider of builtInDiscovery.replaceRuntimeProviders) touchedProviders.add(provider); for (const provider of builtInDiscovery.authoritativeProviders) touchedProviders.add(provider); const referenceModels = this.#composeDiscoveryReferenceModels(touchedProviders); const discoveredModels = this.#applyHardcodedModelPolicies( discovered.map(model => this.#mergeDiscoveredModelMemoized( model, resolveProviderModelReference(model.provider, model.id, referenceModels), this.#providerOverrides.get(model.provider), ), ), ); const authoritativeProviders = providersWithAuthoritativeProjectCatalog(discoveredModels); for (const provider of builtInDiscovery.authoritativeProviders) { authoritativeProviders.add(provider); } const replacedProviderSet = new Set(replacedConfiguredProviders); for (const provider of builtInDiscovery.replaceRuntimeProviders) { replacedProviderSet.add(provider); this.#replacedCacheProviders.add(provider); this.#cachedStandardModelsByProvider.delete(provider); this.#pendingStandardCacheProviders.delete(provider); this.#cachedAuthoritativeProviders.delete(provider); } if (replacedProviderSet.size > 0) { this.#cachedDiscoverableModels = this.#cachedDiscoverableModels.filter( model => !replacedProviderSet.has(model.provider), ); } this.#runtimeDiscoveredModels = this.#runtimeDiscoveredModels.filter( model => !touchedProviders.has(model.provider), ); this.#runtimeDiscoveredModels.push(...discoveredModels); for (const provider of touchedProviders) { if (authoritativeProviders.has(provider)) { this.#runtimeAuthoritativeProviders.add(provider); } else { this.#runtimeAuthoritativeProviders.delete(provider); } this.#invalidateProviderModelCache(provider); } if (!this.#hasFullSnapshot) return; let baseModels = this.#baseModels; if (replacedProviderSet.size > 0) { baseModels = this.#mergeResolvedModels( dropProviderModels(baseModels, replacedProviderSet), this.#composeBaseModels(replacedProviderSet), ); } if (authoritativeProviders.size > 0) { baseModels = dropProviderModels(baseModels, authoritativeProviders, { kind: "chat" }); } this.#baseModels = this.#mergeResolvedModels(baseModels, discoveredModels); this.#unprojectedModels = this.#projectBaseModels(this.#baseModels); this.#models = this.#finalizeModels(this.#applyRuntimeModelModifiers(this.#unprojectedModels)); } #mergeDiscoveredModelMemoized( model: Model, reference: Model | undefined, override: ProviderOverride | undefined, ): Model { const now = Date.now(); const memo = this.#discoveredMergeMemo.get(model); if (memo && memo.reference === reference && memo.override === override && now < memo.validUntil) { return memo.merged; } const merged = mergeDiscoveredModel(model, reference, override); // Measured before the build: a card falling due mid-build expires the memo at once. const validUntil = nextBuildPriceChange(model.cost, now); this.#discoveredMergeMemo.set(model, { reference, override, merged, validUntil }); return merged; } /** * Share a configured provider's discovery request between concurrent full * and provider-scoped refreshes using the same cache/network strategy. */ #discoverProviderModelsCoalesced( providerConfig: DiscoveryProviderConfig, strategy: ModelRefreshStrategy, ): Promise { let providerInFlight = this.#configuredDiscoveryInFlight.get(providerConfig); const inFlight = providerInFlight?.get(strategy); if (inFlight) return inFlight; providerInFlight ??= new Map(); const discovery = this.#discoverProviderModels(providerConfig, strategy).finally(() => { if (providerInFlight.get(strategy) === discovery) { providerInFlight.delete(strategy); if (providerInFlight.size === 0) { this.#configuredDiscoveryInFlight.delete(providerConfig); } } }); providerInFlight.set(strategy, discovery); this.#configuredDiscoveryInFlight.set(providerConfig, providerInFlight); return discovery; } /** * Namespace suffix for the `discovery.advertisedReasoning` opt-out. Rows built * under one mode must never satisfy the other's cache read: under the * `offline` strategy nothing else refetches them. */ advertisedReasoningCacheSuffix(providerConfig: DiscoveryProviderConfig): string { return providerConfig.discovery.advertisedReasoning === false ? "advertised-off-context-v6" : "context-v6"; } #configuredDiscoveryCacheProviderId(providerConfig: DiscoveryProviderConfig): string { if (providerConfig.discovery.type === "ollama") { return resolveOllamaModelCacheProviderId(providerConfig.provider, providerConfig.baseUrl); } if (providerConfig.discovery.type === "openai-models-list") { // context-v3 invalidates rows cached before server-advertised input // modalities were parsed from `/v1/models`; warm v2 rows pinned // vision-capable ids at `input: ["text"]` until a forced refresh. // context-v4 invalidates rows cached before // `top_provider.max_completion_tokens` was read; warm v3 rows kept // the bundled reference's larger output limit and the provider // rejected every request. // context-v5 invalidates rows cached before a row's advertised // reasoning ladder outranked the bundled reference; warm v4 rows kept // `reasoning: false` (ids absent from the catalog) or an inherited // `omitReasoningEffort`, so no effort dial appeared until a refresh. // context-v6 invalidates rows cached before a row's advertised // `reasoning_effort_map` / `reasoning_required` were kept on the // model; warm v5 rows stayed without the aliases, so an unsupported // requested effort kept clamping to the tier below and a thinking-off // request could reach a route that rejects it. // `injectV1: false` additionally splits off its own namespace: rows // cached from the `/v1`-injected URL can hold a different (smaller) // model set and must never satisfy a bare provider's cache read. return providerConfig.discovery.injectV1 === false ? `${providerConfig.provider}:openai-models-list-bare-${this.advertisedReasoningCacheSuffix(providerConfig)}` : `${providerConfig.provider}:openai-models-list-${this.advertisedReasoningCacheSuffix(providerConfig)}`; } if (providerConfig.discovery.type === "proxy") { // proxy-v2 invalidates rows cached before a proxy row's advertised // reasoning ladder was read; warm v1 rows (stored under the bare // provider id) pinned gateway ids at `reasoning: false`. The opt-out // gets its own namespace for the same reason it does above: under the // `offline` strategy nothing else would refetch the other mode's rows. return `${providerConfig.provider}:proxy-v2-${this.advertisedReasoningCacheSuffix(providerConfig)}`; } if (providerConfig.discovery.type === "litellm") { // rich-v7 invalidates incomplete /v2/model/info rosters cached before // pagination. rich-v6 moved decisions-mode groups to the Decisions // runner; rich-v5 filtered known non-conversational modes. Bump this // namespace alongside the catalog package's `litellm:rich-vN` when // discovery behavior changes. return `${providerConfig.provider}:litellm-rich-v7`; } 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 { const cacheProviderId = this.#configuredDiscoveryCacheProviderId(providerConfig); const cached = readModelCache(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 willFetch = effectiveStrategy === "online" || (effectiveStrategy === "online-if-uncached" && cached === null); const requiresAuth = !this.#keylessProviders.has(providerConfig.provider); if (requiresAuth && willFetch) { 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 { models: cached ? this.#normalizeDiscoverableModels(providerConfig, cached.models) : [], replaceRuntimeModels: false, }; } } const providerId = providerConfig.provider; let discoveryError: string | undefined; let discoveryAuthRejected = false; const fetchDynamicModels = async (): Promise[] | null> => { try { const resolvedHeaders = await resolveConfigHeaders(providerConfig.headers); const requestConfig = { ...providerConfig, headers: resolvedHeaders }; const models = this.#applyProviderModelOverrides( providerId, await discoverModelsByProviderType(requestConfig, this.#discoveryContext()), ); this.#lastDiscoveryWarnings.delete(providerId); return models.map(toModelSpec); } catch (error) { discoveryError = error instanceof Error ? error.message : String(error); // A 401/403 means the endpoint is reachable but rejected the // request's credentials (or the keyless assumption). Surface it // as an auth failure instead of a generic outage so the hub can // tell the user to sign in (issue #12281). discoveryAuthRejected = isDiscoveryAuthRejection(error); return null; } }; const providerOverride = this.#providerOverrides.get(providerId); const cachedHeaderResolver = this.#canRestoreConfiguredDiscoveryHeaders(providerId) ? createConfigHeaderResolver([providerOverride?.headers], { authHeader: providerOverride?.authHeader, apiKeyConfig: providerOverride?.apiKey, }) : undefined; const manager = createModelManager({ providerId, staticModels: [], restoreCachedHeaders: cachedHeaderResolver ? () => ({ resolveHeaders: cachedHeaderResolver }) : undefined, cacheDbPath: this.#cacheDbPath, cacheProviderId, cacheTtlMs: 24 * 60 * 60 * 1000, fetchDynamicModels, }); const result = await manager.refresh(effectiveStrategy); const status = discoveryError ? result.models.length > 0 ? "cached" : discoveryAuthRejected ? "unauthenticated" : "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 { models: this.#applyProviderModelOverrides( providerId, this.#normalizeDiscoverableModels( providerConfig, this.#applyProviderCompat(providerConfig.compat, result.models), ), ), replaceRuntimeModels: result.source === "provider", }; } #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); const { baseUrl } = providerConfig; const url = baseUrl && redactUrlForLog(baseUrl); logger.warn("model discovery failed for provider", { provider: providerConfig.provider, url, error, }); // An env var silently redirected a built-in provider the user never // configured; a log line alone leaves its failures untraceable. const endpointEnv = this.#implicitProviderEndpointEnv.get(providerConfig.provider); if (endpointEnv && baseUrl && url) { const provider = providerConfig.provider; // The endpoint env var may embed `user:pass@`, and fetch errors can echo it. const detail = error.replaceAll(baseUrl, url).replace(URL_USERINFO_RE, "$1").replace(/\s+/g, " ").trim(); this.#configWarnings.push( `Built-in ${provider} provider: model discovery at ${url} (from ${endpointEnv}) failed. Fix or unset ${endpointEnv}, or add "${provider}" to disabledProviders. Error: ${detail}`, ); } } async #discoverBuiltInProviderModels( strategy: ModelRefreshStrategy, providerFilter?: ReadonlySet, ): Promise { // 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(), replaceRuntimeProviders: new Set() }; } const writesBefore = getModelCacheWriteStats(); const discoveries = await Promise.all( managerOptions.map(options => this.#discoverWithModelManager(options, strategy)), ); const writesAfter = getModelCacheWriteStats(); const rewrittenRows = writesAfter.payloadWrites - writesBefore.payloadWrites; if (rewrittenRows > MODEL_CACHE_REWRITE_LOG_THRESHOLD) { // Unchanged snapshots only advance a small freshness row; a burst of // full payload rewrites means catalogs really changed (or a cache // policy switch replaced them) and is worth seeing in debug logs. logger.debug("model refresh rewrote many model cache rows", { rows: rewrittenRows, bytes: writesAfter.payloadBytes - writesBefore.payloadBytes, providers: writesAfter.recentPayloadProviders.slice(-rewrittenRows), strategy, }); } const authoritativeProviders = new Set(); const replaceRuntimeProviders = new Set(); const models: Model[] = []; for (const discovery of discoveries) { models.push(...discovery.models); for (const provider of discovery.authoritativeProviders) { authoritativeProviders.add(provider); } for (const provider of discovery.replaceRuntimeProviders) { replaceRuntimeProviders.add(provider); } } return { models, authoritativeProviders, replaceRuntimeProviders }; } /** * Resolve the key built-in discovery uses for `providerId`. `cacheProviderId` * is the namespace probed to skip an OAuth refresh while its cache is fresh; * `undefined` for a credential-scoped catalog, whose namespace only the * account's credential determines (the write side hashes it in). */ async #resolveBuiltInDiscoveryApiKey( providerId: string, strategy: ModelRefreshStrategy, cacheProviderId: string | undefined, authoritative: boolean, ): Promise { const peekedKey = await this.#peekApiKeyForProvider(providerId); if (isAuthenticated(peekedKey)) { return peekedKey; } if (strategy === "offline") { // Offline discovery only reads the cache. An expired access token still // names its account (Cursor scopes rows on the JWT subject), so it keys // the same row the last online pass wrote with the then-live token. if (cacheProviderId !== undefined) return peekedKey; return (await this.#peekApiKeyForProvider(providerId, { includeExpired: true })) ?? 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. // A credential-scoped catalog has no credential-less namespace to probe, // and only its manager can load the account's rows, so it always refreshes. if (strategy === "online-if-uncached" && !authoritative && cacheProviderId !== undefined) { // Mirror shouldFetchRemoteSources: built-in managers use the catalog's // default TTL, so only refresh when the manager will actually fetch. const cache = readModelCache( 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; } } /** * Resolve the GCP project id for Gemini CLI quota discovery from the stored * OAuth credential matched to the token in use. Used only as a fallback for * the discovery fast path, where `peekApiKey` returns the bare access token * (stripping the structured identity); matching strictly by `access` avoids * attaching an unrelated account's project. Workspace/Standard accounts * require the id because project-less `loadCodeAssist` cannot resolve one. */ #resolveGeminiCliDiscoveryProjectId(oauthToken: string): string | undefined { const credentials = getOAuthCredentialsForProvider(this.authStorage, "google-gemini-cli"); const projectId = credentials.find(credential => credential.access === oauthToken)?.projectId?.trim(); return projectId ? projectId : undefined; } async #collectBuiltInModelManagerOptions( strategy: ModelRefreshStrategy, providerFilter: ReadonlySet | undefined, configuredDiscoveryProviders: ReadonlySet, ): Promise[]> { const specialProviderDescriptors: Array<{ providerId: string; resolveKey: (value: string | undefined) => string | undefined; createOptions: (key: string, raw: string | undefined) => ModelManagerOptions; }> = [ { providerId: "google-antigravity", resolveKey: extractGoogleOAuthToken, createOptions: oauthToken => googleAntigravityModelManagerOptions({ resolveAccounts: async () => { const accounts = await resolveOAuthDiscoveryAccounts( this.authStorage, "google-antigravity", oauthToken, ); return ( accounts?.map(account => ({ accessToken: account.accessToken, accountKey: oauthAccountKey(account), projectId: account.projectId, })) ?? null ); }, endpoint: this.#descriptorBaseUrl("google-antigravity"), fetch: this.#fetch, }), }, { providerId: "google-gemini-cli", resolveKey: extractGoogleOAuthToken, createOptions: (oauthToken, raw) => googleGeminiCliModelManagerOptions({ oauthToken, projectId: extractGoogleOAuthProjectId(raw) ?? this.#resolveGeminiCliDiscoveryProjectId(oauthToken), endpoint: this.#descriptorBaseUrl("google-gemini-cli"), fetch: this.#fetch, }), }, { providerId: "openai-codex", resolveKey: value => value, createOptions: accessToken => { // A custom endpoint (models.yml `baseUrl`) receives only a configured, // runtime, or command key. Official credentials (stored ChatGPT OAuth or // the provider env token) keep discovering against chatgpt.com, as on a // relay setup chat still works with them. The check looks at the token // actually being sent: a runtime provider's command key is only a // fallback behind a live OAuth token, which `peek` returns first. const configuredBaseUrl = this.#descriptorBaseUrl("openai-codex"); const officialCredential = accessToken === getEnvApiKey("openai-codex") || getOAuthCredentialsForProvider(this.authStorage, "openai-codex").some( credential => credential.access === accessToken, ); if (officialCredential || isOfficialCodexApiUrl(configuredBaseUrl)) { return openaiCodexModelManagerOptions({ baseUrl: officialCredential ? undefined : configuredBaseUrl, resolveAccounts: () => resolveOAuthDiscoveryAccounts(this.authStorage, "openai-codex", accessToken), fetch: this.#fetch, }); } return openaiCodexModelManagerOptions({ baseUrl: configuredBaseUrl, resolveAccounts: async () => [{ accessToken }], fetch: this.#fetch, }); }, }, ]; const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings); const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(descriptor => { if (disabledProviders.has(descriptor.providerId)) return false; if (configuredDiscoveryProviders.has(descriptor.providerId)) return false; if (this.#runtimeModelManagers.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 = isCredentialScopedModelCacheProvider(descriptor.providerId) ? undefined : 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, AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS.has(descriptor.providerId), ), ), ); const options: ModelManagerOptions[] = []; 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)); const supportsSharedCatalog = MODELS_DEV_CATALOG_PROVIDER_ID_LOOKUP[descriptor.providerId] === true; const canUseSharedCatalogWithoutAuth = supportsSharedCatalog && !descriptor.dynamicModelsAuthoritative; if ( isAuthenticated(apiKey) || descriptor.allowUnauthenticated || hasExplicitVllmConfig || canUseSharedCatalogWithoutAuth ) { // Residency belongs to the token selected for discovery, not another // stored account that happens to appear first in the pool. const identity = getOAuthCredentialsForProvider(this.authStorage, descriptor.providerId).find( credential => credential.access === apiKey, ); const discoveryConfig = { apiKey: isDiscoveryBearerApiKey(apiKey) ? apiKey : undefined, baseUrl: this.#descriptorBaseUrl(descriptor.providerId), fetch: this.#fetch, region: identity?.region, inferenceRegion: identity?.inferenceRegion, orgId: identity?.orgId, }; const preparedConfig = getProviderDefinition(descriptor.providerId)?.prepareModelDiscovery?.(discoveryConfig) ?? discoveryConfig; const managerOptions = descriptor.createModelManagerOptions(preparedConfig); const modelsDev = managerOptions.modelsDev ? { ...managerOptions.modelsDev, additiveOnly: true } : modelsDevCatalogFallback(descriptor.providerId, this.#fetch); options.push(modelsDev ? { ...managerOptions, modelsDev } : managerOptions); } } 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, specialKeys[i])); } // Catalog-only providers have no endpoint manager. Give their bundled // slices the same shared remote layer without requiring credentials. const bundledProviderIds: Record = Object.create(null); for (const providerId of getBundledProviders()) bundledProviderIds[providerId] = true; for (const providerId of MODELS_DEV_CATALOG_PROVIDER_IDS) { if (BUILT_IN_MODEL_MANAGER_PROVIDER_IDS[providerId] === true) continue; if (bundledProviderIds[providerId] !== true) continue; if (disabledProviders.has(providerId) || configuredDiscoveryProviders.has(providerId)) continue; if (providerFilter && !providerFilter.has(providerId)) continue; if (this.#runtimeModelManagers.has(providerId)) continue; const modelsDev = modelsDevCatalogFallback(providerId, this.#fetch); if (!modelsDev) continue; options.push({ providerId, cacheProviderId: resolveModelCacheProviderId(providerId, { baseUrl: this.#descriptorBaseUrl(providerId), }), modelsDev, }); } // 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, strategy: ModelRefreshStrategy, ): Promise { let failure: string | undefined; try { const manager = createModelManager({ ...options, cacheDbPath: this.#cacheDbPath }); const result = await withModelDiscoveryTimeout(RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS, () => manager.refresh(strategy), ); const models = result.models.map(model => model.provider === options.providerId ? model : { ...model, provider: options.providerId }, ); // An offline pass only reads the cache: a missing row means "not fetched // yet" (a cold, credential-scoped namespace), not an unreachable provider. const status = result.source === "cache" ? "cached" : result.source === "bundled" && result.stale && strategy !== "offline" ? "unavailable" : result.source === "bundled" ? "idle" : result.models.length > 0 ? "ok" : "empty"; // A throwing fetcher still resolves (cache or bundled), but the failure must reach the state and the log. failure = result.fetchError; this.#providerDiscoveryStates.set(options.providerId, { provider: options.providerId, status, optional: false, stale: result.stale, fetchedAt: result.updatedAt, source: result.source, models: models.map(model => model.id), ...(failure !== undefined ? { error: failure } : {}), }); const authoritativeProviders = new Set(); if (options.dynamicModelsAuthoritative && !result.stale) { authoritativeProviders.add(options.providerId); } const replaceRuntimeProviders = new Set(); if (result.source === "provider") { replaceRuntimeProviders.add(options.providerId); } return { models, authoritativeProviders, replaceRuntimeProviders }; } catch (error) { failure = error instanceof Error ? error.message : String(error); const previous = this.#providerDiscoveryStates.get(options.providerId); // Same auth-rejection surfacing as the configured-discovery path: a // 401/403 with nothing to serve is a credential problem, not an // outage (issue #12281). const authRejected = (previous?.models.length ?? 0) === 0 && isDiscoveryAuthRejection(error); this.#providerDiscoveryStates.set(options.providerId, { provider: options.providerId, status: authRejected ? "unauthenticated" : "unavailable", optional: previous?.optional ?? false, stale: true, ...(previous?.fetchedAt !== undefined ? { fetchedAt: previous.fetchedAt } : {}), ...(previous?.source !== undefined ? { source: previous.source } : {}), models: previous?.models ?? [], error: failure, }); return { models: [], authoritativeProviders: new Set(), replaceRuntimeProviders: new Set() }; } finally { if (failure !== undefined) { logger.warn("model discovery failed for provider", { provider: options.providerId, error: failure }); } } } #applyProviderModelOverrides(provider: string, models: Model[]): Model[] { const overrides = this.#modelOverrides.get(provider); if (!overrides || overrides.size === 0) return models; let liveIds: Set | 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 this.#applyModelOverrideWithClamp(model, override); }); } // Reapply catalog policy after cache/config merges; native discovery URLs // are not request roots, while custom transports own their URL suffixes. #applyDiscoveryPolicies(models: Model[]): Model[] { const providers = new Map(this.#discoverableProviders.map(config => [config.provider, config.discovery.type])); return models.map(model => { const providerType = providers.get(model.provider); if (providerType === "litellm" && model.providerType !== providerType) { return buildDiscoveredModel(toModelSpec(model), providerType); } if (providerType !== "llama.cpp") return model; const spec = toModelSpec(model); if (!model.transport) { spec.baseUrl = ensureLlamaCppV1BaseUrl(normalizeLlamaCppBaseUrl(model.baseUrl)); } return buildDiscoveredModel(spec, providerType); }); } #mergeProviderOverride(baseOverride: ProviderOverride | undefined, override: ProviderOverride): ProviderOverride { return { baseUrl: override.baseUrl ?? baseOverride?.baseUrl, baseUrlApis: override.baseUrlApis ?? baseOverride?.baseUrlApis, apiKey: override.apiKey ?? baseOverride?.apiKey, authHeader: override.authHeader ?? baseOverride?.authHeader, headers: override.headers || baseOverride?.headers ? { ...baseOverride?.headers, ...override.headers } : undefined, compat: override.compat ? mergeCompat(baseOverride?.compat, override.compat) : baseOverride?.compat, remoteCompaction: mergeRemoteCompactionConfig(baseOverride?.remoteCompaction, override.remoteCompaction), transport: override.transport ?? baseOverride?.transport, }; } #applyProviderTransportOverride< T extends { api: Api; baseUrl?: string; headers?: Record; resolveHeaders?: Model["resolveHeaders"]; remoteCompaction?: RemoteCompactionConfig; }, >( entry: T, override: Pick< ProviderOverride, "baseUrl" | "baseUrlApis" | "headers" | "authHeader" | "apiKey" | "remoteCompaction" | "transport" >, ): T { const changesHeaders = override.headers !== undefined || (override.authHeader === true && override.apiKey !== undefined); const resolveHeaders = changesHeaders ? mergeAuthHeaderSources( override.headers ? [entry.resolveHeaders ?? entry.headers, override.headers] : [entry.resolveHeaders ?? entry.headers], override.authHeader, override.apiKey, ) : entry.resolveHeaders; return { ...entry, baseUrl: resolveProviderBaseUrl(entry.api, entry.baseUrl, override), headers: changesHeaders || entry.resolveHeaders ? undefined : entry.headers, resolveHeaders, // 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, override: Pick< ProviderOverride, "baseUrl" | "baseUrlApis" | "headers" | "authHeader" | "apiKey" | "remoteCompaction" | "transport" >, ): Model { return buildModel(this.#applyProviderTransportOverride(toModelSpec(model), override)); } #applyProviderBedrockOverrides(models: Model[]): Model[] { if (this.#providerOverrides.size === 0) return models; return models.map(model => { const override = this.#providerOverrides.get(model.provider); if (!override) return model; const bedrockFields = bedrockProviderFields(override); if ( bedrockFields.guardrailIdentifier === undefined && bedrockFields.guardrailVersion === undefined && bedrockFields.guardrailTrace === undefined && bedrockFields.requestMetadata === undefined ) { return model; } return buildModel({ ...toModelSpec(model), ...bedrockFields } as ModelSpec); }); } #applyRuntimeProviderOverrides(models: Model[]): Model[] { 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): 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): 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[], overrides: Map>): Model[] { const customWindows = new Map(); for (const overlays of [this.#customModelOverlays, this.#runtimeModelOverlays]) { for (const overlay of overlays) { if (overlay.maxContextWindow !== undefined) { customWindows.set(`${overlay.provider}\u0000${overlay.id}`, overlay.maxContextWindow); } } } if (overrides.size === 0 && customWindows.size === 0) { for (const model of models) this.#recordWindowTiers(model, this.#policyWindowTiers.get(model)); return models; } const extendedContext = isExtendedContextEnabledFromSettings(this.#settings); const compactionPoints = getModelCompactionPointsFromSettings(this.#settings); let liveKeys: Set | 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); const override = providerOverrides ? resolveModelOverrideWithAliases(providerOverrides, model, hasLiveModel) : undefined; const overridden = override ? this.#applyModelOverrideWithClamp(model, override) : model; // Resolve configuration before selecting a window. A context-only // override remains fixed; an unrelated override preserves the custom pair. const maximum = override?.maxContextWindow ?? (override?.contextWindow === undefined ? customWindows.get(contextWindowKey(model)) : undefined); if (override?.contextWindow === undefined && maximum === undefined) { this.#recordWindowTiers(model, this.#policyWindowTiers.get(model)); return overridden; } return this.#applyConfiguredExtendedWindow(overridden, maximum, model, { pinned: override?.contextWindow !== undefined, extendedContext, compactionPoints, }); }); } #recordWindowTiers(model: Model, tiers: ContextWindowTiers | undefined): void { if (tiers) this.#windowTiers.set(contextWindowKey(model), tiers); else this.#windowTiers.delete(contextWindowKey(model)); } /** * Finalizes the window of a row carrying an explicit `contextWindow` override * (`pinned`: the policy tiers no longer apply) and/or a configured maximum, * which extends from the policy's standard window when extended context is * on or the model's compaction point reaches past that window. Policy tiers * apply only to the row the hardcoded policies produced (`baseline`); a * custom overlay replacing it never went through them. */ #applyConfiguredExtendedWindow( model: Model, maximum: number | undefined, baseline: Model, options: { pinned: boolean; extendedContext: boolean; compactionPoints: unknown }, ): Model { const current = model.contextWindow; const policy = options.pinned ? undefined : this.#policyWindowTiers.get(baseline); const standard = policy?.standard ?? current; if (current === null || standard === null) { this.#recordWindowTiers(baseline, undefined); return model; } const window = maximum === undefined ? undefined : clampsContextOverride(baseline) ? clampCodexContextWindow(baseline, maximum) : maximum; // The window an opt-in actually runs with: the policy's extended row // (already applied upstream when opted in), widened by the configured maximum. const extended = Math.max(policy?.extended ?? current, window ?? 0); this.#recordWindowTiers(baseline, extended > standard ? { standard, extended } : undefined); if (window === undefined || window <= current) return model; if (!options.extendedContext && !compactionPointReaches(options.compactionPoints, baseline, standard)) { return model; } return applyModelOverride(model, { contextWindow: window }); } /** * Standard and extended windows of `model` when opting it into extended * context (globally, or through a compaction point past its standard window) * widens it; undefined when no larger window exists or an explicit * `contextWindow` override pins it. */ contextWindowTiers(model: { provider: string; id: string }): ContextWindowTiers | undefined { // Tiers are recorded as the provider's rows compose; compose them first. this.#modelsForProviderLookup(model.provider); return this.#windowTiers.get(contextWindowKey(model)); } /** * `model` with the tier `scope`'s own settings select: extended when its * `extendedContext` is on or its `compaction.modelThresholds` entry for the * model reaches past the standard window, else standard. Catalog rows follow * the registry's settings; a session whose settings differ (a subagent with a * `task.agentCompactionThresholdOverrides` entry runs with model entries off) * adopts rows through this so it never inherits another scope's opt-in. * Returns `model` itself when it already has that window. */ fitContextWindow(model: Model, scope: Settings): Model { const tiers = this.contextWindowTiers(model); if (!tiers || (model.contextWindow !== tiers.standard && model.contextWindow !== tiers.extended)) return model; const window = isExtendedContextEnabledFromSettings(scope) || compactionPointReaches(getModelCompactionPointsFromSettings(scope), model, tiers.standard) ? tiers.extended : tiers.standard; if (model.contextWindow === window) return model; const cached = this.#refittedRows.get(model); if (cached?.contextWindow === window) return cached; const refitted = applyModelOverride(model, { contextWindow: window }); this.#refittedRows.set(model, refitted); return refitted; } /** * Applies one explicit model override, clamping KDL-governed * (`clamp-context-override`) context windows to the server-honored maximum * instead of widening without bound — mirroring openai/codex * `with_config_overrides`. `model` is the pre-override row, so the ceiling * never shrinks the request below the window that already works. Shared by * every override pass (cache load and composition): overrides apply on * both, so the clamp must hold on both. */ #applyModelOverrideWithClamp(model: Model, override: ModelOverride): Model { this.#warnUnservedOverrideKind(model, override); const overridden = applyModelOverride(model, override); if ( override.contextWindow === undefined || overridden.contextWindow === null || !clampsContextOverride(overridden) ) { return overridden; } const clamped = clampCodexContextWindow(model, overridden.contextWindow); if (clamped === overridden.contextWindow) return overridden; return applyModelOverride(overridden, { contextWindow: clamped }); } /** Logs, once per model and kind, a `modelOverrides` kind that `applyModelOverride` ignores. */ #warnUnservedOverrideKind(model: Model, override: ModelOverride): void { const unservedKind = unservedOverrideKind(model, override); if (unservedKind === undefined) return; const warningKey = `${model.provider}/${model.id}:${unservedKind}`; if (this.#warnedUnservedOverrideKinds.has(warningKey)) return; this.#warnedUnservedOverrideKinds.add(warningKey); logger.warn("modelOverrides kind ignored: the model's api does not serve it", { provider: model.provider, model: model.id, kind: unservedKind, api: override.api ?? model.api, }); } #applyHardcodedModelPolicies(models: Model[]): Model[] { const extendedContext = isExtendedContextEnabledFromSettings(this.#settings); const compactionPoints = getModelCompactionPointsFromSettings(this.#settings); return models.map(model => { let tiers: ContextWindowTiers | undefined; // Hosts whose context window is authoritative (subscription limits that // carry public price tiers only as estimates) skip every inferred // window policy. if (!model.contextWindowAuthoritative) { ({ model, tiers } = this.#applyContextWindowPolicies(model, extendedContext, compactionPoints)); } if (tiers) this.#policyWindowTiers.set(model, tiers); return model; }); } /** * Resolves both window tiers and picks one: extended when the setting is on * or the model's own compaction point reaches past its standard window * (an explicit per-model opt-in, confirmed in the model hub). `tiers` is * absent when no larger window exists. */ #applyContextWindowPolicies( model: Model, extendedContext: boolean, compactionPoints: unknown, ): { model: Model; tiers?: ContextWindowTiers } { // Without a maximum or a long-context tier both resolutions are identical. if (resolveMaxContextWindow(model) === undefined && model.cost.longContext?.inputThreshold === undefined) { return { model: this.#resolveContextWindowPolicies(model, false) }; } const standard = this.#resolveContextWindowPolicies(model, false); const extended = this.#resolveContextWindowPolicies(model, true); const standardWindow = standard.contextWindow; const extendedWindow = extended.contextWindow; if (standardWindow === null || extendedWindow === null || extendedWindow <= standardWindow) { return { model: extendedContext ? extended : standard }; } const optedIn = extendedContext || compactionPointReaches(compactionPoints, model, standardWindow); return { model: optedIn ? extended : standard, tiers: { standard: standardWindow, extended: extendedWindow }, }; } #resolveContextWindowPolicies(model: Model, extendedContext: boolean): Model { const maximum = resolveMaxContextWindow(model); if (maximum !== undefined && model.contextWindow !== null) { // Only extended-window models need a fresh policy baseline: a // materialized cache row may carry an earlier applied window. // Preserve valid standard capacity when an advertised maximum is // smaller, without retaining an obsolete extended window. const standardWindow = buildModel(toModelSpec(model)).contextWindow ?? model.contextWindow; if (extendedContext) { const window = Math.max(standardWindow, maximum); if (window !== model.contextWindow) { model = applyModelOverride(model, { contextWindow: window }); } } else if (standardWindow < model.contextWindow) { model = { ...model, contextWindow: standardWindow }; } } // Extended context off: cap models with a premium long-context price // tier (e.g. GPT-5.6 bills 2x input above 272K) at the standard-pricing // threshold so compaction fires before a request crosses into the tier. // Rows flagged `longContextWindowUncapped` (catalog // `long-context-window-uncapped`, e.g. subscription-backed hosts whose // tier prices are estimates) keep their full window. Explicit per-model // `contextWindow` overrides reapply later in composition and win over // this cap. if (!extendedContext && model.longContextWindowUncapped !== true) { const threshold = model.cost.longContext?.inputThreshold; if (threshold !== undefined && model.contextWindow !== null && model.contextWindow > threshold) { model = applyModelOverride(model, { contextWindow: threshold }); } } return model; } #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 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, providerConfig.headers, 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[] { 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) of one catalog kind. * If custom config had errors, returns only built-in models. The default * excludes role-specific runners so existing session callers remain chat-only. * Pass `"all"` to retrieve the complete catalog. */ getAll(kind: ModelKind | "all" = "chat"): Model[] { const models = this.#ensureFullSnapshot(); if (kind === "all") return models; let snapshots = this.#fullKindSnapshots.get(models); if (!snapshots) { snapshots = {}; this.#fullKindSnapshots.set(models, snapshots); } const cached = snapshots[kind]; if (cached) return cached; const filtered = models.filter(model => modelKind(model) === kind); snapshots[kind] = filtered; return filtered; } /** * 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(this.#settings); const byProvider = new Map(); return provider => { let available = byProvider.get(provider); if (available === undefined) { // A provider whose only credential is a keyless-fallback marker // (empty paste at an optional-key login, e.g. `vllm-local`) is // configured-but-keyless: `hasAuth` no longer counts it, but its // models stay usable exactly like an `auth: none` endpoint's // (issue #12281). Implicit local providers are already covered // by `#keylessProviders`. available = !disabledProviders.has(provider) && (this.#keylessProviders.has(provider) || this.authStorage.keys.source(provider) !== undefined || this.authStorage.keys.keyless(provider)); byProvider.set(provider, available); } return available; }; } /** * Get available models for an explicit provider set without materializing * unrelated cached catalogs. Startup role resolution uses this before the * full model picker is needed. The default excludes role-specific runners; * pass `"all"` when the caller is intentionally selecting across kinds. */ getAvailableForProviders(providers: ReadonlySet, kind: ModelKind | "all" = "chat"): Model[] { const requested = new Set([...providers].map(provider => provider.trim().toLowerCase()).filter(Boolean)); const isProviderAvailable = this.#createProviderAvailabilityCheck(); if (this.#hasFullSnapshot) { return this.#models.filter( model => requested.has(model.provider.toLowerCase()) && isProviderAvailable(model.provider) && (kind === "all" || modelKind(model) === kind), ); } const availableProviders = new Set( this.#knownStaticProviders().filter( provider => requested.has(provider.toLowerCase()) && isProviderAvailable(provider), ), ); // Reuse the same provider snapshot as find()/getProviderModels(). The // availability gate above stays live, while catalog invalidation remains // owned by refresh, discovery and runtime provider registration. Whole- // catalog modifiers can inspect live credentials, so keep reprojecting // those rather than extending the lifetime of their output. if (availableProviders.size === 1 && this.#runtimeModelModifiers.size === 0) { const models = this.#modelsForProviderLookup(availableProviders.values().next().value!); return kind === "all" ? models.slice() : models.filter(model => modelKind(model) === kind); } const models = this.#composeStaticModels(availableProviders); return kind === "all" ? models : models.filter(model => modelKind(model) === kind); } /** * Get available models of one catalog kind. * This is a fast auth check that doesn't refresh OAuth tokens. The default * is chat-only; pass `"all"` to include authenticated and keyless runners. */ getAvailable(kind: ModelKind | "all" = "chat"): Model[] { return this.getAvailableForProviders(new Set(this.#knownStaticProviders()), kind); } /** * 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.hasResolvableAuth`). 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}. * * Cross-provider env aliases count here (`xai-oauth` can borrow `XAI_API_KEY`) * so an explicit `xai-oauth/…` selector does not fail with "No API key". * Default-model availability still uses {@link AuthStorage.keys.source}, which * ignores that alias so SuperGrok is not auto-selected from a paid key. */ hasConfiguredAuth(model: Model): boolean { const keyConfig = this.#customProviderApiKeys.get(model.provider); return ( keyConfig !== undefined || this.#keylessProviders.has(model.provider) || this.authStorage.keys.source(model.provider, { env: "aliases" }) !== undefined || this.authStorage.keys.keyless(model.provider) ); } /** * Whether `provider` has a *concrete* credential — a stored login, a * command/config/runtime key, or a keyless local endpoint — as opposed to a * self-resolving AWS/Vertex sentinel that only signals an ambient credential * *source* exists. Default-model auto-selection prefers concretely-authed * providers so an ambiently-available Bedrock/Vertex provider never displaces * the provider the user actually signed into. See {@link AuthStorage.keys.source} * and issue #9967. */ hasConcreteAuth(provider: string): boolean { const keyConfig = this.#customProviderApiKeys.get(provider); return ( keyConfig !== undefined || this.#keylessProviders.has(provider) || this.authStorage.keys.source(provider)?.concrete === true || this.authStorage.keys.keyless(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(this.#settings); return this.#discoverableProviders .filter(provider => !disabledProviders.has(provider.provider)) .map(provider => provider.provider); } /** Canonical id of a configured or extension-backed discovery provider. */ getDiscoveryProviderId(requestedId: string): string | undefined { const normalized = requestedId.toLowerCase(); for (const { provider } of this.#discoverableProviders) { if (provider.toLowerCase() === normalized) return provider; } for (const provider of this.#runtimeModelManagers.keys()) { if (provider.toLowerCase() === normalized) return provider; } return undefined; } /** * 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(this.#settings).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); } /** * Discovery states of extension providers (`fetchDynamicModels`) whose last * discovery attempt failed. `omp models` reports them so a failed fetch is * distinguishable from a provider with no models (issue #17925). */ getRuntimeDiscoveryFailures(): ProviderDiscoveryState[] { const failures: ProviderDiscoveryState[] = []; for (const provider of this.#runtimeModelManagers.keys()) { const state = this.#providerDiscoveryStates.get(provider); if (state?.error !== undefined) failures.push(state); } return failures; } /** * Whether an unresolved selector on `provider` may still be supplied by * discovery this process has not finished. Two states qualify: * - `idle`: a config-declared provider with a cold cache has no catalog yet * (e.g. after `omp update` bumps the cache namespace, #10048). * - `cached`/`unavailable` while the initial background refresh is in * flight: startup replayed a cache row or fell back to the bundled * catalog, and that refresh may still add ids (a cold namespace after an * upgrade, or a cache row keyed to another credential). With no refresh * running (an embedder that manages discovery itself) these states are * final, so unresolved selectors warn immediately. */ isProviderDiscoveryPending(provider: string): boolean { const status = this.#providerDiscoveryStates.get(provider)?.status; if (status === "idle") return true; if (status !== "cached" && status !== "unavailable") return false; return this.#backgroundRefresh !== undefined && !this.#initialRefreshSettled; } /** * Find a model by provider and ID. A provider disabled in settings has no * models to find: every caller that falls back to a literal lookup when * availability-filtered resolution misses (retry fallback candidates, * advisors, restored and CLI models) would otherwise reach it anyway. */ find(provider: string, modelId: string): Model | undefined { if (this.#isProviderDisabled(provider)) return undefined; return resolveProviderModelReference(provider, modelId, this.#modelsForProviderLookup(provider)); } /** Whether settings disable `provider` (`disabledProviders`). */ #isProviderDisabled(provider: string): boolean { return getDisabledProviderIdsFromSettings(this.#settings).has(provider); } /** * One provider's full catalog (every kind, credentials ignored) without * materializing the whole bundled catalog. Startup validation of * provider-qualified selectors uses this: `getAll()` composes ~5k models * through the compat classifier, which costs ~80ms on the first paint path. */ getProviderModels(provider: string): Model[] { const normalizedProvider = provider.trim().toLowerCase(); return this.#modelsForProviderLookup(provider).filter( model => model.provider.toLowerCase() === normalizedProvider, ); } /** * Provider-level base URL: explicit runtime/config overrides first, then any * discovered model that defines one. * * The overrides lead because a model-derived answer is only available once * discovery has populated the registry. `omp usage` builds a `ModelRegistry` * and probes credentials immediately, and providers whose roster is * discovery-only (no bundled rows) have no model to read a URL from at that * point — so deriving solely from models returned `undefined` cache-cold and * let a usage probe send a proxy-scoped key to the provider's canonical host. */ getProviderBaseUrl(provider: string): string | undefined { return ( this.#runtimeProviderOverrides.get(provider)?.baseUrl ?? this.#providerOverrides.get(provider)?.baseUrl ?? this.#modelsForProviderLookup(provider).find(m => m.provider === provider && m.baseUrl)?.baseUrl ); } /** * Materialize provider-level config headers for one outbound request. * Catalog inspection never executes command-backed values. */ async getProviderHeaders(provider: string): Promise | undefined> { const resolver = createConfigHeaderResolver([ this.#providerOverrides.get(provider)?.headers, this.#runtimeProviderOverrides.get(provider)?.headers, ]); return await resolver?.(); } /** Materialize a model's complete configured header chain for one request. */ async resolveModelHeaders(model: Model, signal?: AbortSignal): Promise | undefined> { if (model.resolveHeaders) return await model.resolveHeaders(signal); return model.headers ? { ...model.headers } : undefined; } #isKeylessProvider(provider: string): boolean { return ( (this.#keylessProviders.has(provider) || this.authStorage.keys.keyless(provider)) && this.authStorage.keys.source(provider) === undefined ); } /** Resolve a model's request credential or the no-auth sentinel. */ async getApiKey( model: Model, sessionId?: string, options?: { signal?: AbortSignal }, ): Promise { // A disabled provider gets no credential, so no request reaches it however // its model was obtained. if (this.#isProviderDisabled(model.provider)) return undefined; if (this.#isKeylessProvider(model.provider)) { return kNoAuth; } return this.authStorage.keys.get(model.provider, sessionId, { baseUrl: model.baseUrl, modelId: model.id, accountIds: model.accountAccess && Object.keys(model.accountAccess), signal: options?.signal, }); } /** Resolve request authentication through the historical Pi extension facade. */ async getApiKeyAndHeaders(model: Model): Promise { try { const apiKey = await this.getApiKey(model); if (apiKey === undefined) { return { ok: false, error: `No API key found for "${model.provider}"` }; } const headers = await this.getProviderHeaders(model.provider); return { ok: true, apiKey, headers }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } } /** * Resolve a provider's credential or the no-auth sentinel, for availability * checks. A transient OAuth refresh failure resolves `undefined` (like * `authStorage.keys.get`); request paths use * {@link getApiKeyWithCredentialForProvider}, which surfaces it. * * `options.forceRefresh` 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?: AuthApiKeyOptions, ): Promise { try { return (await this.getApiKeyWithCredentialForProvider(provider, sessionId, options))?.apiKey; } catch (error) { if (error instanceof OAuthRefreshUnavailableError) return undefined; throw error; } } /** * Resolve a provider's request credential or the no-auth sentinel. * * `options.forceRefresh` powers step (b) of the auth-retry policy. Rejects * with `OAuthRefreshUnavailableError` (transient, retryable) when OAuth * refresh failed with a retryable error and nothing else can serve. */ async getApiKeyWithCredentialForProvider( provider: string, sessionId?: string, options?: AuthApiKeyOptions, ): Promise { if (this.#isProviderDisabled(provider)) return undefined; if (options?.forceRefresh) this.#invalidateProviderCommandConfigs(provider); if (this.#isKeylessProvider(provider)) { return { apiKey: kNoAuth }; } const accountAccess = options?.modelId ? this.find(provider, options.modelId)?.accountAccess : undefined; return this.authStorage.keys.getWithCredential(provider, sessionId, { baseUrl: options?.baseUrl, modelId: options?.modelId, accountIds: accountAccess && Object.keys(accountAccess), forceRefresh: options?.forceRefresh, refreshReason: options?.refreshReason, rejectedAccessToken: options?.rejectedAccessToken, 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, options?: AuthPeekOptions): Promise { if (this.#keylessProviders.has(provider) && this.authStorage.keys.source(provider) === undefined) { return kNoAuth; } return this.authStorage.keys.peek(provider, options); } /** Whether a model uses OAuth credentials. Kept separate from subscription billing for cache-tier decisions. */ isUsingOAuth(model: Model): boolean { return this.authStorage.credentials.hasOAuth(model.provider); } /** Whether a model's credentials represent subscription billing. */ isUsingSubscription(model: Model): boolean { return this.authStorage.credentials.hasSubscription(model.provider); } #clearRuntimeProviderState(providerName: string): void { this.#runtimeProviderApiKeys.delete(providerName); this.#runtimeProviderOverrides.delete(providerName); this.#runtimeCommandConfigsByProvider.delete(providerName); this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(overlay => overlay.provider !== providerName); this.#runtimeModelManagers.delete(providerName); this.#runtimeModelModifiers.delete(providerName); this.#lastModelModifierWarnings.delete(providerName); // A credential-scoped built-in provider (e.g. opencode-go) populates its // slice of #runtimeDiscoveredModels from startup cache hydration, not from // this extension. #reloadStaticModels excludes credential-scoped providers // from synchronous cache loading and refreshRuntimeProviders only refetches // registered managers, so neither restores that slice — discarding it here // makes account-specific models vanish until a full refresh. Keep it for // credential-scoped providers; discard for everyone else, where the slice is // the extension's own discovery (or a non-credential-scoped built-in slice // that baked this provider's now-removed override and must be recomposed). if (!isCredentialScopedModelCacheProvider(providerName)) { this.#runtimeDiscoveredModels = this.#runtimeDiscoveredModels.filter(model => model.provider !== providerName); this.#runtimeAuthoritativeProviders.delete(providerName); this.#providerDiscoveryStates.delete(providerName); } this.#invalidateProviderModelCache(providerName); this.#runtimeRegistrations = this.#runtimeRegistrations.filter(change => change.name !== providerName); if (this.#owner) return; this.authStorage.keys.removeConfig(providerName); this.authStorage.usage.removeProvider(providerName); } /** * Remove custom API/OAuth registrations for one or more extension sources. * The model catalog is rebuilt once, however many sources are cleared. */ clearSourceRegistrations(sourceIds: string | Iterable): void { if (this.#ignoreMirrorLocalChange("clearSourceRegistrations")) return; const ids = typeof sourceIds === "string" ? [sourceIds] : [...sourceIds]; this.#runtimeChange({ kind: "clearSources", sourceIds: ids }, () => this.#clearSourceRegistrations(ids)); } #clearSourceRegistrations(sourceIds: readonly string[]): void { let cleared = false; for (const sourceId of sourceIds) { unregisterCustomApis(sourceId); unregisterOAuthProviders(sourceId); const sourceProviders = this.#runtimeProvidersBySource.get(sourceId); if (!sourceProviders || sourceProviders.size === 0) { continue; } 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); } cleared = true; } if (cleared) { this.#reloadStaticModels({ force: true, preserveRuntimeDiscovery: true }); } } /** * Remove one extension-registered provider and restore its static models. */ unregisterProvider(providerName: string): void { if (this.#ignoreMirrorLocalChange("unregisterProvider")) return; this.#runtimeChange({ kind: "unregister", name: providerName }, () => this.#unregisterProvider(providerName)); } #unregisterProvider(providerName: string): void { const sourceId = this.#runtimeProviderSourceByName.get(providerName); if (sourceId) { const sourceProviders = this.#runtimeProvidersBySource.get(sourceId); sourceProviders?.delete(providerName); if (sourceProviders?.size === 0) { this.#runtimeProvidersBySource.delete(sourceId); } this.#runtimeProviderSourceByName.delete(providerName); } unregisterOAuthProvider(providerName); this.#ensureFullSnapshot(); this.#clearRuntimeProviderState(providerName); this.#reloadStaticModels({ force: true, preserveRuntimeDiscovery: true }); } /** * Remove registrations for extension sources that are no longer active. */ syncExtensionSources(activeSourceIds: string[]): void { if (this.#ignoreMirrorLocalChange("syncExtensionSources")) return; const ids = [...activeSourceIds]; this.#runtimeChange({ kind: "syncSources", activeSourceIds: ids }, () => this.#syncExtensionSources(ids)); } #syncExtensionSources(activeSourceIds: readonly string[]): void { const activeSources = new Set(activeSourceIds); const inactiveSources = [...this.#registeredProviderSources].filter(sourceId => !activeSources.has(sourceId)); this.#clearSourceRegistrations(inactiveSources); for (const sourceId of inactiveSources) { 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 (this.#ignoreMirrorLocalChange("registerProvider")) return; this.#runtimeChange({ kind: "register", name: providerName, config, sourceId }, () => this.#registerProvider(providerName, config, sourceId), ); } /** * Whether a runtime registration call on this registry is dropped because it * is a mirror: its owner holds every runtime registration (worker-side * extension instances re-bound from the owner's prepared extensions repeat * what the owner already registered) and pushes them through * {@link applyMirrorBatch}, so the owner stays the single source of truth. */ #ignoreMirrorLocalChange(method: string): boolean { if (!this.#owner) return false; logger.debug(`ModelRegistry.${method} ignored on a mirror registry; its owner holds runtime registrations`); return true; } #registerProvider(providerName: string, config: ProviderConfigInput, sourceId: string | undefined): 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(); sourceProviders.add(providerName); this.#runtimeProvidersBySource.set(sourceId, sourceProviders); this.#runtimeProviderSourceByName.set(providerName, sourceId); } if (sourceHandoff) { this.#reloadStaticModels({ force: true, preserveRuntimeDiscovery: true }); } // Extension usage providers override built-ins/configured resolvers for the // provider lifetime. #clearRuntimeProviderState removes this override when // the owning extension is unregistered or replaced. if (config.usage && !this.#owner) { this.authStorage.usage.setProvider(providerName, config.usage, config.apiKey); } if (config.apiKey) { // A provider that owns a /login flow must not let its default key // reference shadow the credential that login stores. Its apiKey is // typically an env-var name; unset, it resolves to its literal text // and would otherwise be sent (and passed to fetchDynamicModels) // instead of the saved key. const fallback = config.oauth !== undefined; this.#installProviderApiKey(providerName, config.apiKey, { fallback }); // Persist runtime API keys so they survive #reloadStaticModels() cycles this.#runtimeProviderApiKeys.set(providerName, { keyConfig: config.apiKey, fallback }); } this.#recordRuntimeCommandConfigs(providerName, config); 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); // A modifier is explicitly a whole-catalog transform and may throw // based on another provider's models. Preserve registration-time // execution/error reporting by materializing only for this case. if (config.oauth?.modifyModels && !this.#hasFullSnapshot) { logger.time(`modelRegistry:materializeModifier:${providerName}`, () => this.#ensureFullSnapshot()); } if (config.oauth?.modifyModels) { this.#runtimeModelModifiers.set(providerName, config.oauth.modifyModels); } else { this.#runtimeModelModifiers.delete(providerName); } if (!this.#hasFullSnapshot) { // Lazy compositions read runtime overlays directly. A whole-catalog // modifier can affect providers beyond its owner, so discard every // provider slice; an ordinary overlay only invalidates its provider. if (config.oauth?.modifyModels) { this.#providerLookupSnapshots.clear(); this.#internedStaticModels.clear(); } else { this.#invalidateProviderModelCache(providerName); } if (!config.fetchDynamicModels) return; } // 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. // Refresh projections re-add this provider's runtime overlays to the base. this.#baseModels = this.#baseModels.filter(model => model.provider !== providerName); 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); const nextModelsWithTransport = runtimeTransportOverride ? nextModels.map(model => { if (model.provider !== providerName) return model; return this.#applyProviderTransportOverrideToModel(model, runtimeTransportOverride); }) : nextModels; this.#unprojectedModels = this.#applyProviderBedrockOverrides(nextModelsWithTransport); this.#models = this.#finalizeModels(this.#applyRuntimeModelModifiers(this.#unprojectedModels)); this.#invalidateProviderModelCache(providerName); if (!config.fetchDynamicModels) return; } if (config.fetchDynamicModels) { const fetcher = config.fetchDynamicModels; const providerBaseUrl = config.baseUrl ?? ""; const providerApi = config.api; const providerCompat = config.compat; const managerOptions: ModelManagerOptions = { providerId: providerName as Parameters[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 withModelDiscoveryTimeout(RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS, () => fetcher(resolvedKey), ); // Dynamic rows can carry `!command` headers that are not on the // registerProvider() config; track them so F5 / 401 can evict the cache. this.#recordRuntimeModelHeaderCommands(providerName, modelDefs); const results: Model[] = []; for (const modelDef of modelDefs) { // Provider-level headers/authHeader stay off live rows: this // registration's #runtimeProviderOverrides entry applies them at // projection. Baked in, every row would be header-bearing, which the // model cache cannot persist, so a failed refresh would find no // restorable rows and empty the catalog (#18227). const overlay = buildCustomModelOverlay( providerName, modelDef.baseUrl ?? providerBaseUrl, modelDef.api ?? providerApi, undefined, undefined, undefined, providerCompat, undefined, config.remoteCompaction, modelDef as CustomModelDefinitionLike, ); if (!overlay) continue; // Static registrations fail validation on this mismatch; a live row is dropped instead. if (overlay.kind !== undefined && !apiServesKind(overlay.api, overlay.kind)) { logger.warn("fetchDynamicModels row dropped: its api does not serve its kind", { provider: providerName, model: overlay.id, kind: overlay.kind, api: overlay.api, }); continue; } 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); if (this.#hasFullSnapshot) { this.#unprojectedModels = this.#applyDiscoveryPolicies( this.#unprojectedModels.map(model => { if (model.provider !== providerName) return model; return this.#applyProviderTransportOverrideToModel(model, transportOverride); }), ); this.#models = this.#finalizeModels(this.#applyRuntimeModelModifiers(this.#unprojectedModels)); } this.#invalidateProviderModelCache(providerName); } } /** * 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(); } } /** * The registry a mirror reflects (on another thread), as the mirror sees it: * it owns the shared AuthStorage's key config and runs every discovery and * refresh, whose results reach the mirror through change batches. */ export interface ModelRegistryOwner { /** Drop the owner's cached `!command` results for `provider` (every provider when omitted). */ invalidateCommandConfigs(provider?: string): void; /** {@link ModelRegistry.refresh} on the owner. */ refresh(strategy: ModelRefreshStrategy, options?: ModelRegistryRefreshOptions): Promise; /** {@link ModelRegistry.refreshProvider} on the owner. */ refreshProvider( providerId: string, strategy: ModelRefreshStrategy, options?: ModelRegistryRefreshOptions, ): Promise; /** {@link ModelRegistry.refreshDiscoverableProviders} on the owner. */ refreshDiscoverableProviders(providerIds: string[], strategy: ModelRefreshStrategy): Promise; /** {@link ModelRegistry.refreshRuntimeProviders} on the owner. */ refreshRuntimeProviders(strategy: ModelRefreshStrategy): Promise; /** {@link ModelRegistry.hydrateCredentialScopedModelCaches} on the owner. */ hydrateCredentialScopedModelCaches(): Promise; /** {@link ModelRegistry.reapplyModelPolicies} on the owner. */ reapplyModelPolicies(): Promise; /** {@link ModelRegistry.refreshInBackground} on the owner. */ refreshInBackground(strategy: ModelRefreshStrategy): void; /** {@link ModelRegistry.isBackgroundRefreshPending} on the owner. */ isBackgroundRefreshPending(): boolean; /** {@link ModelRegistry.awaitBackgroundRefresh} on the owner. */ awaitBackgroundRefresh(): Promise; /** {@link ModelRegistry.refreshIfStale} on the owner. */ refreshIfStale(): Promise; /** {@link ModelRegistry.awaitInitialBackgroundRefresh} on the owner. */ awaitInitialBackgroundRefresh(signal?: AbortSignal): Promise; } /** What {@link ModelRegistry.mirrorInputs} reports: the construction inputs a mirror reuses. */ export interface ModelRegistryMirrorInputs { /** Explicit models.yml path, or undefined for the agent-dir default. */ readonly modelsPath: string | undefined; readonly ignoreLocalModelConfig: boolean; /** Explicit model cache database, or undefined for the default beside `modelsPath`. */ readonly cacheDbPath: string | undefined; /** Explicit settings source, or undefined for the process settings. */ readonly settings: Settings | undefined; } /** A runtime change of an owner registry before it is numbered. */ type ModelRegistryMirrorChangeInput = | { readonly kind: "register"; readonly name: string; readonly config: ProviderConfigInput; readonly sourceId: string | undefined; } | { readonly kind: "unregister"; readonly name: string } | { readonly kind: "clearSources"; readonly sourceIds: readonly string[] } | { readonly kind: "syncSources"; readonly activeSourceIds: readonly string[] } /** A static (models.yml + cache) reload outside the other changes. */ | { readonly kind: "reload" } /** Owner dropped cached `!command` results (one provider's, or all when `provider` is undefined). */ | { readonly kind: "invalidateCommands"; readonly provider: string | undefined }; /** A runtime change of an owner registry, as {@link ModelRegistry.applyMirrorBatch} replays it. */ export type ModelRegistryMirrorChange = ModelRegistryMirrorChangeInput & { readonly seq: number }; /** A recorded `registerProvider` call. */ type ModelRegistryRegisterChange = Extract; /** An owner registry's in-memory discovery results, adopted wholesale by its mirrors. */ export interface ModelRegistryDiscoveryLayer { readonly states: readonly ProviderDiscoveryState[]; /** The catalog-shaping part; omitted from a batch when only discovery states moved. */ readonly catalog?: { /** Runtime-discovered models, merged and policy-applied. */ readonly models: Model[]; /** Providers whose discovered catalog replaces their cached and bundled chat models. */ readonly authoritativeProviders: readonly string[]; /** Providers whose cache-backed rows discovery replaced since the last static reload. */ readonly replacedCacheProviders: readonly string[]; /** Scored rows the catalog metrics index was built from, in order. */ readonly metricRows: Model[]; }; } /** One delivery to a mirror: changes in order, then the discovery layer when it moved. */ export interface ModelRegistryMirrorBatch { /** Owner sequence number of the latest change this batch reflects. */ readonly seq: number; readonly changes: readonly ModelRegistryMirrorChange[]; readonly discovery?: ModelRegistryDiscoveryLayer; } /** Same length and pairwise identical or deeply equal (functions by identity). */ function sameItems(a: readonly T[], b: readonly T[]): boolean { return a.length === b.length && a.every((item, index) => item === b[index] || Bun.deepEquals(item, b[index])); } /** * {@link sameItems} for discovered rows, except `resolveHeaders` compares by presence: every * discovery pass re-merges its rows and rebuilds those closures from the same inputs (reference * rows and provider overrides), and what changes those inputs reaches mirrors as a change of its * own (`reload`, `register`). Comparing them by identity made each pass recompose every mirror. */ function sameDiscoveredRows(a: readonly Model[], b: readonly Model[]): boolean { return ( a.length === b.length && a.every((row, index) => { const other = b[index]; if (row === other) return true; if ((row.resolveHeaders === undefined) !== (other.resolveHeaders === undefined)) return false; const { resolveHeaders: _rowResolver, ...rowData } = row; const { resolveHeaders: _otherResolver, ...otherData } = other; return Bun.deepEquals(rowData, otherData); }) ); } /** * Input type for registerProvider API (from extensions). */ export interface ProviderConfigInput { baseUrl?: string; apiKey?: string; api?: Api; streamSimple?: (model: Model, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream; headers?: Record; compat?: ModelSpec["compat"]; remoteCompaction?: RemoteCompactionConfig; authHeader?: boolean; /** Streaming transport override — see {@link Model.transport}. */ transport?: Model["transport"]; /** Optional normalized usage fetcher; takes precedence over built-in usage providers. */ usage?: UsageProvider; oauth?: { name: string; login(callbacks: OAuthLoginCallbacks): Promise; refreshToken?(credentials: OAuthCredentials): Promise; getApiKey?(credentials: OAuthCredentials): string; modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; }; /** * 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[number][]>; models?: Array<{ id: string; name: string; api?: Api; kind?: ModelKind; baseUrl?: string; reasoning: boolean; thinking?: ThinkingConfig; input: ("text" | "image")[]; supportsTools?: boolean; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow: number; maxTokens: number; promptCache?: Model["promptCache"]; /** WebSocket preference within catalog policy; explicit false disables transport selection. */ preferWebsockets?: boolean; headers?: Record; compat?: ModelSpec["compat"]; contextPromotionTarget?: string; compactionModel?: string; remoteCompaction?: RemoteCompactionConfig; premiumMultiplier?: number; }>; }