import { type } from "@oh-my-pi-zen/omptype"; import { logger, VERSION } from "@oh-my-pi-zen/pi-utils"; import { buildModel } from "./build"; import { collapseBuiltVariants } from "./compat/collapse"; import { applyCatalogMetrics, CatalogMetricsIndex } from "./identity/metrics"; import { readModelCache, writeModelCache } from "./model-cache"; import { type GeneratedProvider, getBundledModels } from "./models"; import { TimeBasedCostShape } from "./pricing"; import { type Api, type Model, modelKind, type ModelSpec, type Provider } from "./types"; import { isRecord } from "./utils"; const DEFAULT_CACHE_TTL_MS = 2 * 60 * 60 * 1000; const NON_AUTHORITATIVE_RETRY_MS = 5 * 60 * 1000; /** * Controls when dynamic endpoint models should be fetched. */ export type ModelRefreshStrategy = "online" | "offline" | "online-if-uncached"; /** * Hook for loading and mapping stencil.so fallback data into canonical model objects. */ export interface ModelsDevFallback { /** Fetches raw fallback payload (for example from stencil.so). */ fetch(): Promise; /** Maps payload into provider models. */ map(payload: TPayload, providerId: Provider): readonly ModelSpec[]; /** When true, mapped rows can add model ids but cannot replace static metadata. */ additiveOnly?: boolean; } /** * Configuration for provider model resolution. */ export interface ModelManagerOptions { /** Provider id used for static lookup and cache namespacing. */ providerId: Provider; /** * Optional static override. Raw ModelSpec rows are materialized with * `buildModel`; explicitly prebuilt Model rows are consumed as trusted * materializations. When omitted, generator-materialized models.json rows * are consumed directly. */ staticModels?: readonly (ModelSpec | Model)[]; /** Optional override for the cache database path. Default: /models.db. */ cacheDbPath?: string; /** Optional provider id override for cache namespacing. Defaults to providerId. */ cacheProviderId?: string; /** Maximum cache age in milliseconds before considered stale. Default: 2h (`DEFAULT_CACHE_TTL_MS`). */ cacheTtlMs?: number; /** When true, a successful dynamic fetch is the complete provider catalog and prunes static-only models. */ dynamicModelsAuthoritative?: boolean; /** * When true, a provider's dynamic rows are the deployment's whole truth * for input modality: a live text-only row strips the bundled reference's * image support instead of OR-merging it back. Declared by providers whose * capability flags are authoritative and whose endpoints reject * unsupported content (`github-copilot`, `deepinfra`, `coralbricks`). */ dynamicInputAuthoritative?: boolean; /** When true, live reasoning capability replaces the fallback, including an explicit false. */ dynamicReasoningAuthoritative?: boolean; /** * When true, a live per-million price replaces the fallback even when it is * an explicit `0` (a free model or a dropped cache-write charge). Without it, * a zero discovery price is treated as unknown and the bundled rate is kept. */ dynamicCostAuthoritative?: boolean; /** * When true, a static row's display name wins over the cached or live row * for the same id, so a model keeps one name before and after discovery. * Set from the provider's `keep-bundled-names` catalog entry. */ keepStaticNames?: boolean; /** * When true, a fresh cache never satisfies an online-eligible refresh: the * dynamic fetch always runs (an explicit `"offline"` strategy is still * honored), and the cache serves only as the fetch-failure fallback. For * providers whose dynamic result encodes fast-changing live state that a * TTL cache would replay wrongly — e.g. Factory discovery includes live * organization model policy and upstream blocks. Cached eligibility is * an offline snapshot, not current entitlement. */ alwaysRefetchDynamicModels?: boolean; /** Cached model ids whose presence forces refresh when the static or migration-policy fingerprint changes. */ dropCachedModelIdsOnStaticMismatch?: readonly string[]; /** * Trusted, provider-wide request headers that can be safely re-derived at * cache-read time. The cache never persists their values. Models whose live * headers matched this record at write time can survive offline reads without * a bundled static source, including configured discovery providers whose * bearer header comes from the current local config. */ restorableHeaderFallback?: Record; /** * Reconstruct omitted request headers from authoritative local configuration * when no trusted static source remains. Return undefined to keep the row * unavailable. This hook must not perform I/O; attach `resolveHeaders` for * command-backed credentials that must wait until an actual request. */ restoreCachedHeaders?: (model: Readonly) => Pick | undefined; /** Optional dynamic endpoint fetcher. */ fetchDynamicModels?: () => Promise[] | null>; /** Optional stencil.so fallback hook. */ modelsDev?: ModelsDevFallback; /** Clock override for deterministic tests. */ now?: () => number; } /** * Catalog source that most recently refreshed the resolved provider snapshot. */ export type ModelResolutionSource = "bundled" | "cache" | "models.dev" | "provider"; /** * Resolution result. * * `stale` is false when the resolved catalog is authoritative for the selected provider: * - a provider endpoint fetch succeeded in this call (an empty catalog is still * authoritative for the cycle, so downstream pruning of removed models runs), * - a models.dev fetch succeeded for a provider without endpoint discovery, * - a still-fresh authoritative cache was reused in `online-if-uncached` mode, or * - the provider has no remote fetcher configured. */ export interface ModelResolutionResult { models: Model[]; stale: boolean; source: ModelResolutionSource; updatedAt?: number; /** Why the provider's endpoint fetcher threw this call; the models then come from cache, models.dev or bundled data. */ fetchError?: string; } /** * Stateful facade over provider model resolution. */ export interface ModelManager { refresh(strategy?: ModelRefreshStrategy): Promise>; } /** * Creates a reusable provider model manager. */ export function createModelManager( options: ModelManagerOptions, ): ModelManager { return { refresh(strategy: ModelRefreshStrategy = "online-if-uncached") { return resolveProviderModels(options, strategy); }, }; } function isBuiltModel(value: ModelSpec | Model): value is Model { return isRecord(value) && isRecord(value.identity) && typeof value.identity.class === "string"; } /** * Materialize caller-provided raw static specs while preserving explicitly * prebuilt override rows. Cache and bundled rows use separate trusted paths. */ function materializeStaticModels(value: readonly (ModelSpec | Model)[]): Model[] { if (!Array.isArray(value)) { return []; } const out: Model[] = []; for (let index = 0; index < value.length; index++) { const item: ModelSpec | Model = value[index]; if (item === null || typeof item !== "object" || typeof item.id !== "string") continue; out.push(isBuiltModel(item) ? item : buildModel(item)); } return out; } interface CachedHeaderRestoreResult { models: Model[]; unresolvedModelIds: ReadonlySet; } /** * Restore cache-omitted headers from the current static source. * * A same-id static match is trusted only when the row did not flag the model * unrestorable (its live headers matched static when cached). Request-model * fallback also honors that marker for current rows. Only legacy rows written * before request-model header matching may bypass it: their id-only writer * necessarily marked every synthesized variant unrestorable (#6037, #6284). * Header-bearing models without a trusted source cannot be reconstructed * safely without persisting arbitrary credential values; callers must refetch * them online or omit them rather than return a broken model. */ function restoreCachedModelHeaders( cachedModels: readonly Model[], staticModels: readonly Model[], headerOmittedModelIds: readonly string[], unrestorableHeaderModelIds: readonly string[], legacyHeaderRestoreMarkers: boolean, restorableHeaderFallback: Record | undefined, restoreCachedHeaders: ModelManagerOptions["restoreCachedHeaders"], ): CachedHeaderRestoreResult { if (headerOmittedModelIds.length === 0) { return { models: [...cachedModels], unresolvedModelIds: new Set() }; } const omittedIds = new Set(headerOmittedModelIds); const unrestorableIds = new Set(unrestorableHeaderModelIds); const staticById = new Map(staticModels.map(model => [model.id, model])); const unresolvedModelIds = new Set(); const restored = cachedModels.map(model => { if (!omittedIds.has(model.id)) return model; const unrestorable = unrestorableIds.has(model.id); // Current unrestorable markers prove that neither same-id nor request-model // static headers matched the live model. Only the old id-only writer's // markers may recover a synthesized variant through `requestModelId`. const staticModel = unrestorable ? legacyHeaderRestoreMarkers && model.requestModelId ? staticById.get(model.requestModelId) : undefined : (staticById.get(model.id) ?? (model.requestModelId ? staticById.get(model.requestModelId) : undefined)); if (!staticModel?.headers && !staticModel?.resolveHeaders) { // A non-unrestorable row whose static source is gone was cached with // headers matching the provider's trusted constant (e.g. a Copilot // model with no bundled entry). Reattach the constant by value instead // of dropping the model on this offline read. if (!unrestorable && restorableHeaderFallback) { return { ...model, headers: { ...restorableHeaderFallback } }; } const configured = restoreCachedHeaders?.(model); if (configured?.headers || configured?.resolveHeaders) return { ...model, ...configured }; unresolvedModelIds.add(model.id); return model; } return { ...model, headers: staticModel.headers, resolveHeaders: staticModel.resolveHeaders }; }); return { models: restored, unresolvedModelIds }; } /** * Resolves provider models with source precedence: * static -> cached fallback -> stencil.so -> dynamic. * * Later sources override earlier ones by model id. Cached rows participate only * when at least one configured remote source did not refresh successfully. */ export async function resolveProviderModels( options: ModelManagerOptions, strategy: ModelRefreshStrategy = "online-if-uncached", ): Promise> { if (options.alwaysRefetchDynamicModels && strategy === "online-if-uncached") { strategy = "online"; } const cacheProviderId = options.cacheProviderId ?? options.providerId; const now = options.now ?? Date.now; const ttlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; const dbPath = options.cacheDbPath; const restorableHeaderFallback = options.restorableHeaderFallback; const staticModels = options.staticModels ? materializeStaticModels(options.staticModels) : (getBundledModels(options.providerId as GeneratedProvider) as Model[]); const modelsDevAdditiveStaticModelIds = options.modelsDev?.additiveOnly && staticModels.length > 0 ? new Set(staticModels.map(model => model.id)) : undefined; const cache = readModelCache(cacheProviderId, ttlMs, now, dbPath); const dynamicModelsAuthoritative = options.dynamicModelsAuthoritative ?? false; const isAuthoritativeDynamicCache = dynamicModelsAuthoritative && (cache?.payloadAuthoritative ?? false); const restoredCache = restoreCachedModelHeaders( cache?.models ?? [], staticModels, cache?.headerOmittedModelIds ?? [], cache?.unrestorableHeaderModelIds ?? [], cache?.legacyHeaderRestoreMarkers ?? false, restorableHeaderFallback, options.restoreCachedHeaders, ); const usableCachedModels = restoredCache.models.filter(model => !restoredCache.unresolvedModelIds.has(model.id)); const cacheHasUnresolvedHeaders = restoredCache.unresolvedModelIds.size > 0; const mergeAuthority: DynamicMergeAuthority = { input: options.dynamicInputAuthoritative ?? false, reasoning: options.dynamicReasoningAuthoritative ?? false, cost: options.dynamicCostAuthoritative ?? false, staticNames: options.keepStaticNames ? new Map(staticModels.map(model => [model.id, model.name])) : undefined, }; const cacheDropIds = options.dropCachedModelIdsOnStaticMismatch; // Additive mode is cache identity: a snapshot written while shared-catalog // rows could replace bundled metadata must not match an additive resolver. const staticCatalogFingerprint = fingerprintStaticModels(staticModels, { authoritative: dynamicModelsAuthoritative, additive: modelsDevAdditiveStaticModelIds !== undefined, }); // Endpoint-migration policy is cache identity: adding an id must invalidate // matching-static-catalog caches written by the prior resolver. const staticFingerprint = cacheDropIds && cacheDropIds.length > 0 ? `${staticCatalogFingerprint}:drop:${Bun.hash(cacheDropIds.join("\0")).toString(36)}` : staticCatalogFingerprint; const cacheFingerprintMatches = cache?.staticFingerprint === staticFingerprint && staticFingerprint.length > 0; // A matching cache was written against this static catalog under the same // additive policy, so its same-id rows are live discovery merged over the // bundled rows, never shared-catalog overrides (#17481). Strip them only on // a mismatch: a static-catalog upgrade or a cache written before additive // semantics were enabled. const cacheAdditiveStaticModelIds = isAuthoritativeDynamicCache || cacheFingerprintMatches ? undefined : modelsDevAdditiveStaticModelIds; const cacheNeedsModelMigration = !cacheFingerprintMatches && cacheDropIds !== undefined && usableCachedModels.some(model => cacheDropIds.includes(model.id)); const hasUsableFreshCache = (cache?.fresh ?? false) && !cacheHasUnresolvedHeaders && !cacheNeedsModelMigration && (!dynamicModelsAuthoritative || cacheFingerprintMatches); const dynamicFetcher = options.fetchDynamicModels; const hasDynamicFetcher = typeof dynamicFetcher === "function"; const hasModelsDevFetcher = options.modelsDev !== undefined; const hasRemoteFetcher = hasDynamicFetcher || hasModelsDevFetcher; const hasAuthoritativeCache = ((cache?.authoritative ?? false) && hasUsableFreshCache) || !hasRemoteFetcher; const cacheAgeMs = cache ? now() - cache.updatedAt : Number.POSITIVE_INFINITY; const shouldFetchFromNetwork = hasRemoteFetcher && shouldFetchRemoteSources(strategy, hasUsableFreshCache, hasAuthoritativeCache, cacheAgeMs); // Cold-start fast path: when a fresh, authoritative cache exists, the network // fetch is skipped, AND the static catalog slice is byte-identical to what // was merged in last time, the cache row IS the authoritative merge result. // A mismatched additive cache never reaches here; its same-id rows are // stripped below. if ( !shouldFetchFromNetwork && cache?.fresh && hasAuthoritativeCache && cacheFingerprintMatches && !cacheHasUnresolvedHeaders ) { const source: ModelResolutionSource = restoredCache.models.length > 0 ? "cache" : "bundled"; return { models: collapseBuiltVariants(restoredCache.models), stale: false, source, ...(source === "cache" ? { updatedAt: cache.updatedAt } : {}), }; } const [fetchedModelsDevModels, dynamicFetch] = shouldFetchFromNetwork ? await Promise.all([fetchModelsDev(options), dynamicFetcher ? fetchDynamicModels(dynamicFetcher) : undefined]) : [null, undefined]; const fetchedDynamicModels = dynamicFetch?.models ?? null; const modelsDevFetchSucceeded = fetchedModelsDevModels !== null; const normalizedModelsDevModels = fetchedModelsDevModels?.models ?? []; const modelsDevModels = modelsDevAdditiveStaticModelIds ? normalizedModelsDevModels.filter(model => !modelsDevAdditiveStaticModelIds.has(model.id)) : normalizedModelsDevModels; const shouldUseFreshCacheAsAuthoritative = strategy === "online-if-uncached" && hasUsableFreshCache && hasAuthoritativeCache; const dynamicFetchSucceeded = fetchedDynamicModels !== null; const anyRemoteFetchSucceeded = modelsDevFetchSucceeded || dynamicFetchSucceeded; const allConfiguredRemoteFetchesSucceeded = hasRemoteFetcher && (!hasModelsDevFetcher || modelsDevFetchSucceeded) && (!hasDynamicFetcher || dynamicFetchSucceeded); const authoritativeDynamicFetchSucceeded = dynamicModelsAuthoritative && dynamicFetchSucceeded; const remoteResolutionComplete = authoritativeDynamicFetchSucceeded || allConfiguredRemoteFetchesSucceeded; const preparedCacheModels = remoteResolutionComplete ? [] : prepareCacheModelsForStaticMismatch( usableCachedModels, staticModels, cacheFingerprintMatches, options.dropCachedModelIdsOnStaticMismatch, ); const cacheModels = cacheAdditiveStaticModelIds ? preparedCacheModels.filter(model => !cacheAdditiveStaticModelIds.has(model.id)) : preparedCacheModels; const dynamicModels = fetchedDynamicModels?.models ?? []; // A successful empty endpoint result stays authoritative for THIS cycle (so an // intentional catalog emptying still prunes removed models downstream), but // is NOT pinned into the cache as authoritative — that would suppress the // short retry that recovers a transient empty response (#6620). Shared // models.dev snapshots may be empty for one provider and remain authoritative. const cacheAuthoritative = hasDynamicFetcher ? dynamicFetchSucceeded && dynamicModels.length > 0 && (dynamicModelsAuthoritative || !hasModelsDevFetcher || modelsDevFetchSucceeded) : modelsDevFetchSucceeded; const mergedWithCache = mergeCachedModels(staticModels, cacheModels, { authority: mergeAuthority, matchesStatic: cacheFingerprintMatches, }); const mergedWithModelsDev = mergeDynamicModels( mergedWithCache, modelsDevModels, mergeAuthority, fetchedModelsDevModels?.explicitKindModels, ); const catalogMetricsSource = modelsDevFetchSucceeded ? normalizedModelsDevModels : preparedCacheModels; const mergedWithCatalogMetrics = ( modelsDevFetchSucceeded ? modelsDevAdditiveStaticModelIds : cacheAdditiveStaticModelIds ) ? mergeCatalogMetrics(mergedWithModelsDev, catalogMetricsSource) : mergedWithModelsDev; const mergedModels = mergeDynamicModels( mergedWithCatalogMetrics, dynamicModels, mergeAuthority, fetchedDynamicModels?.explicitKindModels, ); const models = collapseBuiltVariants( authoritativeDynamicFetchSucceeded ? retainModelIds(mergedModels, dynamicModels) : !dynamicFetchSucceeded && isAuthoritativeDynamicCache && usableCachedModels.length > 0 ? retainModelIds(mergedModels, usableCachedModels) : mergedModels, ); const resolutionAuthoritative = !hasRemoteFetcher || remoteResolutionComplete || shouldUseFreshCacheAsAuthoritative; const remoteUpdatedAt = anyRemoteFetchSucceeded ? now() : undefined; if (shouldFetchFromNetwork) { if (anyRemoteFetchSucceeded) { writeModelCache( cacheProviderId, remoteUpdatedAt!, models, cacheAuthoritative, staticFingerprint, dbPath, staticModels, restorableHeaderFallback, ); } else { // Remote fetch failed. Re-persist any prior catalog as a // non-authoritative snapshot so stale state stays visible while the // retry backoff applies. When there is no prior cache and nothing to // preserve (a discovery-only provider on its first failed fetch), leave // the row absent rather than writing an empty snapshot: an empty // non-authoritative row reads back as a fresh catalog and suppresses // refetch for NON_AUTHORITATIVE_RETRY_MS, hiding every discovery-only // model until it ages out. Writing nothing lets the next launch retry // immediately (#10964). Likewise keep a prior row whose header-bearing // models cannot be restored offline: rewriting it would drop them and // mark the remainder fresh, while the untouched row keeps forcing a // refetch that recovers them (#18227). const latestCache = readModelCache(cacheProviderId, ttlMs, now, dbPath); const latestRestoredCache = restoreCachedModelHeaders( latestCache?.models ?? cache?.models ?? [], staticModels, latestCache?.headerOmittedModelIds ?? cache?.headerOmittedModelIds ?? [], latestCache?.unrestorableHeaderModelIds ?? cache?.unrestorableHeaderModelIds ?? [], latestCache?.legacyHeaderRestoreMarkers ?? cache?.legacyHeaderRestoreMarkers ?? false, restorableHeaderFallback, options.restoreCachedHeaders, ); const latestUsableCacheModels = latestRestoredCache.models.filter( model => !latestRestoredCache.unresolvedModelIds.has(model.id), ); const preparedLatestCacheModels = prepareCacheModelsForStaticMismatch( latestUsableCacheModels, staticModels, cacheFingerprintMatches, options.dropCachedModelIdsOnStaticMismatch, ); const latestCacheModels = cacheAdditiveStaticModelIds ? preparedLatestCacheModels.filter(model => !cacheAdditiveStaticModelIds.has(model.id)) : preparedLatestCacheModels; const mergedFallbackModels = mergeDynamicModels( mergeCachedModels(staticModels, latestCacheModels, { authority: mergeAuthority, matchesStatic: cacheFingerprintMatches, }), modelsDevModels, mergeAuthority, ); const fallbackSnapshotModels = collapseBuiltVariants( isAuthoritativeDynamicCache && latestUsableCacheModels.length > 0 ? retainModelIds(mergedFallbackModels, latestUsableCacheModels) : mergedFallbackModels, ); if ( latestRestoredCache.unresolvedModelIds.size === 0 && (fallbackSnapshotModels.length > 0 || latestCache !== null || cache !== null) ) { writeModelCache( cacheProviderId, now(), fallbackSnapshotModels, false, staticFingerprint, dbPath, staticModels, restorableHeaderFallback, ); } } } const cacheContributed = cacheModels.length > 0; const source: ModelResolutionSource = dynamicFetchSucceeded ? "provider" : modelsDevFetchSucceeded ? "models.dev" : cacheContributed ? "cache" : "bundled"; return { models, stale: !resolutionAuthoritative, source, ...(remoteUpdatedAt !== undefined ? { updatedAt: remoteUpdatedAt } : cacheContributed && cache ? { updatedAt: cache.updatedAt } : {}), ...(dynamicFetch?.error !== undefined ? { fetchError: dynamicFetch.error } : {}), }; } /** Materialized discovery rows plus kind provenance captured before policy can supply a KDL kind. */ interface DiscoveredModelSet { models: Model[]; explicitKindModels: ReadonlySet>; } async function fetchModelsDev( options: ModelManagerOptions, ): Promise | null> { if (!options.modelsDev) { return null; } try { const payload = await options.modelsDev.fetch(); return buildDiscoveredModelSpecs(options.modelsDev.map(payload, options.providerId)); } catch { return null; } } /** The endpoint's catalog (`models: null` when it answered none), and why it threw, if it did. */ async function fetchDynamicModels( fetcher: () => Promise[] | null>, ): Promise<{ models: DiscoveredModelSet | null; error?: string }> { try { const models = await fetcher(); return { models: models === null ? null : buildDiscoveredModelSpecs(models) }; } catch (error) { return { models: null, error: error instanceof Error ? error.message : String(error) }; } } function shouldFetchRemoteSources( strategy: ModelRefreshStrategy, hasFreshCache: boolean, hasAuthoritativeCache: boolean, cacheAgeMs: number, ): boolean { if (strategy === "offline") { return false; } if (strategy === "online") { return true; } // online-if-uncached: skip fetch if cache is fresh. // For non-authoritative caches (dynamic fetch previously failed), // use a shorter retry interval instead of retrying every startup. if (!hasFreshCache) { return true; } if (!hasAuthoritativeCache) { return cacheAgeMs >= NON_AUTHORITATIVE_RETRY_MS; } return false; } function prepareCacheModelsForStaticMismatch( models: readonly Model[], staticModels: readonly Model[], cacheFingerprintMatches: boolean, ids: readonly string[] | undefined, ): Model[] { if (models.length === 0) { return []; } if (cacheFingerprintMatches) { return [...models]; } const droppedIds = ids && ids.length > 0 ? new Set(ids) : undefined; const staticIds = staticModels.length > 0 ? new Set(staticModels.map(model => model.id)) : undefined; const sanitizedModels: Model[] = []; for (const model of models) { if (droppedIds?.has(model.id)) { continue; } sanitizedModels.push(staticIds?.has(model.id) ? { ...model, contextWindow: null, maxTokens: null } : model); } return sanitizedModels; } function mergeCatalogMetrics( models: Model[], catalogModels: readonly Model[], ): Model[] { if (models.length === 0 || catalogModels.length === 0) return models; return applyCatalogMetrics(models, new CatalogMetricsIndex(catalogModels)); } /** * Which dynamic-row fields replace the existing row outright instead of * merging additively. Set per provider by `ModelManagerOptions`. */ interface DynamicMergeAuthority { readonly input: boolean; readonly reasoning: boolean; readonly cost: boolean; /** Static display names by id that override dynamic rows (`keepStaticNames`). */ readonly staticNames: ReadonlyMap | undefined; } function mergeDynamicModels( baseModels: readonly Model[], dynamicModels: readonly Model[], authority: DynamicMergeAuthority, explicitKindModels?: ReadonlySet>, ): Model[] { // Empty-side fast paths: `mergeDynamicModels(base, [])` is the common shape // after we've already merged the first pair, and `(...)` with no base // happens for providers without static catalogs. if (dynamicModels.length === 0) return baseModels.length === 0 ? [] : [...baseModels]; if (baseModels.length === 0) return [...dynamicModels]; const merged = new Map>(baseModels.map(model => [model.id, model])); for (const dynamicModel of dynamicModels) { if (!dynamicModel?.id) { continue; } const existingModel = merged.get(dynamicModel.id); if (!existingModel) { merged.set(dynamicModel.id, dynamicModel); continue; } // A policy-derived kind on a chat row is not permission to replace an // authored runner. Only a kind present before materialization can do so. if (modelKind(existingModel) !== "chat" && !explicitKindModels?.has(dynamicModel)) continue; merged.set(dynamicModel.id, mergeDynamicModel(existingModel, dynamicModel, authority)); } return Array.from(merged.values()); } /** * Lays cached rows over the static catalog. A snapshot written against this * static catalog (`matchesStatic`) is already that catalog merged with live * discovery, so its rows replace same-id static rows verbatim; re-merging would * only rebuild them and churn the persisted payload. A snapshot from another * static catalog re-merges so upgraded bundled metadata applies. */ function mergeCachedModels( staticModels: readonly Model[], cacheModels: readonly Model[], options: { authority: DynamicMergeAuthority; matchesStatic: boolean }, ): Model[] { if (!options.matchesStatic) return mergeDynamicModels(staticModels, cacheModels, options.authority); const merged = new Map>(staticModels.map(model => [model.id, model])); for (const cachedModel of cacheModels) merged.set(cachedModel.id, cachedModel); return Array.from(merged.values()); } function retainModelIds( models: readonly Model[], retainedModels: readonly Model[], ): Model[] { if (models.length === 0) return []; const retainedIds = new Set(retainedModels.map(model => model.id)); return models.filter(model => modelKind(model) !== "chat" || retainedIds.has(model.id)); } const MODEL_CACHE_FINGERPRINT_VERSION = `merge-v5:${VERSION}`; /** * Merge policy that is part of a cache's identity in * {@link fingerprintStaticModels}: `authoritative` when dynamic discovery owns * the catalog, `additive` when the shared catalog may only add ids. The flags * compose; snapshots written under different policies never match. */ export interface StaticFingerprintPolicy { authoritative?: boolean; additive?: boolean; } /** * Return the versioned content identity for a static provider slice. Callers * may mutate or freeze their arrays, so every call observes current content * and never attaches a stale sidecar fingerprint to caller-owned data. */ export function fingerprintStaticModels( models: readonly (ModelSpec | Model)[], policy: StaticFingerprintPolicy = {}, ): string { const policyKey = `${policy.authoritative ? ":authoritative" : ""}${policy.additive ? ":additive" : ""}`; if (models.length === 0) return `${MODEL_CACHE_FINGERPRINT_VERSION}${policyKey}:empty`; return `${MODEL_CACHE_FINGERPRINT_VERSION}${policyKey}:${Bun.hash(JSON.stringify(models)).toString(36)}`; } function mergeDynamicModel( existingModel: Model, dynamicModel: Model, authority: DynamicMergeAuthority, ): Model { // When discovery resolves the same model id to a different endpoint (e.g. // a GitHub Copilot business/enterprise host), the bundled reference's // capabilities are pinned to another endpoint and no longer apply. // `authority.input` carries the descriptor's declaration that a // provider's discovery rows are the deployment's whole truth for input // modality (Copilot's pre-applied `supports.vision` fallback, DeepInfra's // `vision`/`vlm` tags, CoralBricks' authoritative `supports_image_input` // flag): without it, a live row that dropped image support would keep the // bundled reference's image support and the agent would go on sending // images to a now text-only route. const endpointChanged = existingModel.baseUrl !== dynamicModel.baseUrl; const dynamicInputAuthoritative = endpointChanged || authority.input; const supportsImage = dynamicInputAuthoritative ? dynamicModel.input.includes("image") : existingModel.input.includes("image") || dynamicModel.input.includes("image"); // Providers with authoritative reasoning metadata must not regain a // stale bundled dial after explicitly reporting no reasoning. Others // retain the additive fallback for discovery that omits capabilities. const reasoning = authority.reasoning ? dynamicModel.reasoning : existingModel.reasoning || dynamicModel.reasoning; const longContextCost = dynamicModel.cost.longContext ?? existingModel.cost.longContext; const timeBasedCost = dynamicModel.cost.timeBased ?? existingModel.cost.timeBased; const existingHeaders = existingModel.resolveHeaders ?? existingModel.headers; const dynamicHeaders = dynamicModel.resolveHeaders ?? dynamicModel.headers; let resolveHeaders = dynamicModel.resolveHeaders ?? existingModel.resolveHeaders; if (resolveHeaders && existingHeaders && dynamicHeaders && existingHeaders !== dynamicHeaders) { resolveHeaders = async signal => { const previous = typeof existingHeaders === "function" ? await existingHeaders(signal) : existingHeaders; const next = typeof dynamicHeaders === "function" ? await dynamicHeaders(signal) : dynamicHeaders; return { ...previous, ...next }; }; } // Re-build from spec stage: sparse compat comes from `compatConfig` (the // verbatim override vocabulary), never the resolved `compat` record. The // override is transport-scoped, so a bundled row authored for one API must // not follow an id whose discovered route moved: Copilot's chat-completions // rows carry `supportsReasoningEffort: false`, which would silently strip // the effort dial once the id is pinned to Responses (#12901). const compat = dynamicModel.compatConfig ?? (dynamicModel.api === existingModel.api ? existingModel.compatConfig : undefined); // A live row that trusts only its own thinking (e.g. CoralBricks' advertised // ladder) must not inherit the fallback's dial. Cached rows lose an // explicit `thinking: undefined` to JSON, so the spread alone would. const liveCompat = dynamicModel.compatConfig; const trustsOwnThinking = liveCompat !== undefined && "trustExplicitThinkingOnly" in liveCompat && liveCompat.trustExplicitThinkingOnly === true; return buildModel({ ...existingModel, ...dynamicModel, name: authority.staticNames?.get(dynamicModel.id) ?? preferDiscoveryName(dynamicModel.name, existingModel.name, dynamicModel.id), reasoning, ...(trustsOwnThinking ? { thinking: dynamicModel.thinking } : {}), input: supportsImage ? ["text", "image"] : ["text"], cost: { input: preferDiscoveryCost(dynamicModel.cost.input, existingModel.cost.input, authority.cost), output: preferDiscoveryCost(dynamicModel.cost.output, existingModel.cost.output, authority.cost), cacheRead: preferDiscoveryCost(dynamicModel.cost.cacheRead, existingModel.cost.cacheRead, authority.cost), cacheWrite: preferDiscoveryCost(dynamicModel.cost.cacheWrite, existingModel.cost.cacheWrite, authority.cost), ...(longContextCost ? { longContext: longContextCost } : {}), ...(timeBasedCost ? { timeBased: timeBasedCost } : {}), }, contextWindow: preferDiscoveryLimit(dynamicModel.contextWindow, existingModel.contextWindow), maxTokens: preferDiscoveryLimit(dynamicModel.maxTokens, existingModel.maxTokens), headers: resolveHeaders ? undefined : dynamicModel.headers ? { ...existingModel.headers, ...dynamicModel.headers } : existingModel.headers, resolveHeaders, compat, contextPromotionTarget: dynamicModel.contextPromotionTarget ?? existingModel.contextPromotionTarget, } as ModelSpec); } /** * A zero discovery price usually means "not reported", so the fallback wins * unless the provider declared its live prices authoritative (`zeroIsPrice`). */ function preferDiscoveryCost(discoveryCost: number, fallbackCost: number, zeroIsPrice: boolean): number { if (Number.isFinite(discoveryCost) && (discoveryCost > 0 || (zeroIsPrice && discoveryCost === 0))) { return discoveryCost; } return fallbackCost; } function preferDiscoveryName(discoveryName: string, fallbackName: string, modelId: string): string { const normalizedDiscoveryName = discoveryName.trim(); if (normalizedDiscoveryName.length === 0) { return fallbackName; } if (normalizedDiscoveryName === modelId && fallbackName !== modelId) { return fallbackName; } return normalizedDiscoveryName; } function preferDiscoveryLimit(discoveryLimit: number, fallbackLimit: number): number; function preferDiscoveryLimit(discoveryLimit: number | null, fallbackLimit: number | null): number | null; function preferDiscoveryLimit(discoveryLimit: number | null, fallbackLimit: number | null): number | null { if (discoveryLimit === null || !Number.isFinite(discoveryLimit) || discoveryLimit <= 0) { return fallbackLimit; } if (discoveryLimit === 4096 && fallbackLimit !== null && fallbackLimit > discoveryLimit) { return fallbackLimit; } return discoveryLimit; } /** * Materializes a remote source's rows. A row `buildModel` rejects (an id newer * than this build's rule tree that lands on an equal-rank overlap) is skipped * with a warning instead of discarding the source's other rows. * * @throws the first build error when rows were present but none built, so the * caller treats the source as failed (cache/static fallback) rather than as an * intentionally empty catalog that an authoritative provider would prune to. */ function buildDiscoveredModelSpecs(values: unknown): DiscoveredModelSet { const models: Model[] = []; const explicitKindModels = new Set>(); if (!Array.isArray(values)) { return { models, explicitKindModels }; } const failures: unknown[] = []; for (const value of values) { if (!ModelSpecShape.allows(value)) continue; const item = value as ModelSpec; let model: Model; try { model = buildModel(item); } catch (error) { failures.push(error); logger.warn("model discovery skipped a model that failed to build", { provider: item.provider, model: item.id, error: error instanceof Error ? error.message : String(error), }); continue; } models.push(model); if (item.kind !== undefined) explicitKindModels.add(model); } if (models.length === 0 && failures.length > 0) throw failures[0]; return { models, explicitKindModels }; } /** Finite rates; 0 and negatives stay valid. */ const FiniteTokenCost = type({ input: "number", output: "number", cacheRead: "number", cacheWrite: "number" }); /** Discovery output is built in memory, so optional fields may be present as `undefined`. */ const ModelSpecShape = type({ id: "string > 0", name: "string > 0", api: "string > 0", provider: "string > 0", // Providers such as Azure resolve the deployment endpoint at request time. baseUrl: "string", reasoning: "boolean", input: "('text' | 'image')[] > 0", cost: FiniteTokenCost.and({ "timeBased?": TimeBasedCostShape.or("undefined"), "longContext?": FiniteTokenCost.and({ inputThreshold: "number > 0" }).or("undefined"), }), contextWindow: "number > 0 | null", maxTokens: "number > 0 | null", });