import { applyFinalCodexGpt56ContextCap } from "./context-cap-policy"; import { readModelCache, writeModelCache } from "./model-cache"; import { isRetiredModel, isRetiredModelKey } from "./model-retirements"; import { applyGeneratedModelPolicies, enrichModelThinking } from "./model-thinking"; import { type GeneratedProvider, getBundledModels } from "./models"; import type { Api, Model, Provider } from "./types"; 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 models.dev fallback data into canonical model objects. */ export interface ModelsDevFallback { /** Fetches raw fallback payload (for example from models.dev). */ fetch(): Promise; /** Maps payload into provider models. */ map(payload: TPayload, providerId: Provider): readonly Model[]; } /** * Configuration for provider model resolution. */ export interface ModelManagerOptions { /** Provider id used for static lookup and cache namespacing. */ providerId: Provider; /** Optional static list override. When omitted, bundled models.json is used. */ staticModels?: readonly Model[]; /** Optional override for the cache database path. Default: /models.db. */ cacheDbPath?: string; /** Maximum cache age in milliseconds before considered stale. Default: 24h. */ cacheTtlMs?: number; /** Optional dynamic endpoint fetcher. */ fetchDynamicModels?: () => Promise[] | null>; /** Optional models.dev fallback hook. */ modelsDev?: ModelsDevFallback; /** Clock override for deterministic tests. */ now?: () => number; /** Optional guard that must permit cache publication. Default: writes are permitted. */ canPublishCache?: () => boolean; } /** * Resolution result. * * `stale` is false when the resolved catalog is authoritative for the selected provider: * - dynamic endpoint data was fetched in this call, * - a still-fresh authoritative cache was reused in `online-if-uncached` mode, or * - the provider has no dynamic fetcher configured. */ export interface ModelResolutionResult { models: Model[]; stale: boolean; } /** * 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); }, }; } /** * Cheap fast path for trusted model sources (bundled literals, our own cache rows). * Skips per-field validation; only guards against catastrophically corrupt rows. */ function passModelList(value: unknown): Model[] { if (!Array.isArray(value)) { return []; } const out: Model[] = []; for (const item of value) { if (item === null || typeof item !== "object") { continue; } const candidate = item as { id?: unknown; provider?: unknown }; if (typeof candidate.id !== "string") { continue; } if (typeof candidate.provider === "string" && isRetiredModelKey(candidate.provider, candidate.id)) { continue; } out.push(enrichModelThinking(item as Model)); } applyGeneratedModelPolicies(out as Model[]); return applyFinalCodexGpt56ContextCap(out); } /** * Resolves provider models with source precedence: * static -> models.dev -> cache -> dynamic. * * Later sources override earlier ones by model id. */ export async function resolveProviderModels( options: ModelManagerOptions, strategy: ModelRefreshStrategy = "online-if-uncached", ): Promise> { const now = options.now ?? Date.now; const ttlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; const dbPath = options.cacheDbPath; const staticModels = passModelList( options.staticModels ?? getBundledModels(options.providerId as GeneratedProvider), ); const cache = readModelCache(options.providerId, ttlMs, now, dbPath); const dynamicFetcher = options.fetchDynamicModels; const hasDynamicFetcher = typeof dynamicFetcher === "function"; const hasAuthoritativeCache = (cache?.authoritative ?? false) || !hasDynamicFetcher; const cacheAgeMs = cache ? now() - cache.updatedAt : Number.POSITIVE_INFINITY; const shouldFetchFromNetwork = shouldFetchRemoteSources( strategy, cache?.fresh ?? false, hasAuthoritativeCache, cacheAgeMs, ); const staticFingerprint = fingerprintStatic(staticModels); // 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. // Re-running `mergeDynamicModels(static, cache)` would just rebuild the same // objects (~800ms in the steady-state cold-start profile for `skc -p hi`). if ( !shouldFetchFromNetwork && cache?.fresh && hasAuthoritativeCache && cache.staticFingerprint === staticFingerprint && cache.staticFingerprint.length > 0 ) { const cachedModels = passModelList(cache.models); if (!hasStaticTransportDrift(staticModels, cachedModels)) { return { models: cachedModels, stale: false }; } const repairedModels = mergeDynamicModels(staticModels, cachedModels); if (options.canPublishCache?.() ?? true) { writeModelCache(options.providerId, now(), repairedModels, true, staticFingerprint, dbPath); } return { models: repairedModels, stale: false }; } const [fetchedModelsDevModels, fetchedDynamicModels] = shouldFetchFromNetwork ? await Promise.all([fetchModelsDev(options), dynamicFetcher ? fetchDynamicModels(dynamicFetcher) : null]) : [null, null]; const modelsDevModels = normalizeModelList(fetchedModelsDevModels ?? []); const shouldUseFreshCacheAsAuthoritative = strategy === "online-if-uncached" && (cache?.fresh ?? false) && hasAuthoritativeCache; const dynamicFetchSucceeded = fetchedDynamicModels !== null; const cacheModels = dynamicFetchSucceeded ? [] : normalizeModelList(cache?.models ?? []); const dynamicModels = fetchedDynamicModels ?? []; const mergedWithCache = mergeDynamicModels(mergeModelSources(staticModels, modelsDevModels), cacheModels); const models = applyFinalCodexGpt56ContextCap(mergeDynamicModels(mergedWithCache, dynamicModels)); const dynamicAuthoritative = !hasDynamicFetcher || dynamicFetchSucceeded || shouldUseFreshCacheAsAuthoritative; if (shouldFetchFromNetwork) { if (dynamicFetchSucceeded) { const snapshotModels = applyFinalCodexGpt56ContextCap( mergeDynamicModels(mergeModelSources(staticModels, modelsDevModels), dynamicModels), ); if (options.canPublishCache?.() ?? true) { writeModelCache(options.providerId, now(), snapshotModels, true, staticFingerprint, dbPath); } } else { // Dynamic fetch failed — update cache with a non-authoritative snapshot so // stale state remains visible while retry backoff still applies. const latestCache = readModelCache(options.providerId, ttlMs, now, dbPath); if (options.canPublishCache?.() ?? true) { writeModelCache( options.providerId, now(), applyFinalCodexGpt56ContextCap( mergeDynamicModels( mergeModelSources(staticModels, modelsDevModels), normalizeModelList(latestCache?.models ?? cache?.models ?? []), ), ), false, staticFingerprint, dbPath, ); } } } return { models, stale: !dynamicAuthoritative, }; } async function fetchModelsDev( options: ModelManagerOptions, ): Promise[] | null> { if (!options.modelsDev) { return null; } try { const payload = await options.modelsDev.fetch(); return normalizeModelList(options.modelsDev.map(payload, options.providerId)); } catch { return null; } } async function fetchDynamicModels( fetcher: () => Promise[] | null>, ): Promise[] | null> { try { const models = await fetcher(); if (models === null) { return null; } return normalizeModelList(models); } catch { return null; } } 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 hasStaticTransportDrift( staticModels: readonly Model[], cachedModels: readonly Model[], ): boolean { if (staticModels.length === 0 || cachedModels.length === 0) return false; const cachedById = new Map(cachedModels.map(model => [model.id, model])); for (const staticModel of staticModels) { const cachedModel = cachedById.get(staticModel.id); if (!cachedModel) continue; if (cachedModel.api !== staticModel.api) return true; } return false; } function mergeModelSources(...sources: readonly (readonly Model[])[]): Model[] { // Strip out empty/missing sources up front. The hot path is `(static, [])` // (modelsDev disabled / failed) — a single non-empty source means we can // skip the Map churn entirely and just hand back the array. const nonEmpty = sources.filter(source => source.length > 0); if (nonEmpty.length === 0) return []; if (nonEmpty.length === 1) return [...nonEmpty[0]]; const merged = new Map>(); for (const source of nonEmpty) { for (const model of source) { if (!model?.id) continue; merged.set(model.id, model); } } return Array.from(merged.values()); } function mergeDynamicModels( baseModels: readonly Model[], dynamicModels: readonly Model[], ): 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; } merged.set(dynamicModel.id, mergeDynamicModel(existingModel, dynamicModel)); } return Array.from(merged.values()); } /** * Stable, low-collision fingerprint of a static catalog slice. Cached by * reference so repeat calls in the same process (e.g. multiple cold-start * arms calling `resolveProviderModels` with the same `staticModels` array) * skip the JSON+hash work after the first call. */ const kStaticFingerprint = Symbol("model-manager.staticFingerprint"); type ModelArrayWithFingerprint = readonly Model[] & { [kStaticFingerprint]?: string }; function fingerprintStatic(models: readonly Model[]): string { if (models.length === 0) return "empty"; const tagged = models as ModelArrayWithFingerprint; const cached = tagged[kStaticFingerprint]; if (cached !== undefined) return cached; // `Bun.hash` returns a `bigint`; base36 keeps the string short for the // SQLite column without sacrificing distinguishability. const fingerprint = Bun.hash(JSON.stringify(models)).toString(36); tagged[kStaticFingerprint] = fingerprint; return fingerprint; } function mergeDynamicModel(existingModel: Model, dynamicModel: Model): Model { const supportsImage = existingModel.input.includes("image") || dynamicModel.input.includes("image"); // The static catalog is authoritative for transport: `api` (and its // api-specific `baseUrl`). Dynamic discovery enumerates ids via a single // hardcoded api (e.g. fetchOpenAICompatibleModels always tags // `openai-completions`), so spreading it blindly would clobber catalog // entries that route through a different format — e.g. opencode-go // qwen3.7-max is `anthropic-messages` but would be downgraded to // `openai-completions` and 401 with `not supported for format oa-compat` // (issue #489). Keep the existing api, and only take the dynamic baseUrl // when the api matches (same transport, same URL shape). const baseUrl = existingModel.api === dynamicModel.api ? dynamicModel.baseUrl : existingModel.baseUrl; const merged = enrichModelThinking({ ...existingModel, ...dynamicModel, api: existingModel.api, baseUrl, name: preferDiscoveryName(dynamicModel.name, existingModel.name, dynamicModel.id), reasoning: existingModel.reasoning || dynamicModel.reasoning, input: supportsImage ? ["text", "image"] : ["text"], cost: { input: preferDiscoveryCost(dynamicModel.cost.input, existingModel.cost.input), output: preferDiscoveryCost(dynamicModel.cost.output, existingModel.cost.output), cacheRead: preferDiscoveryCost(dynamicModel.cost.cacheRead, existingModel.cost.cacheRead), cacheWrite: preferDiscoveryCost(dynamicModel.cost.cacheWrite, existingModel.cost.cacheWrite), }, contextWindow: preferDiscoveryLimit(dynamicModel.contextWindow, existingModel.contextWindow), maxTokens: preferDiscoveryLimit(dynamicModel.maxTokens, existingModel.maxTokens), headers: dynamicModel.headers ? { ...existingModel.headers, ...dynamicModel.headers } : existingModel.headers, compat: dynamicModel.compat ?? existingModel.compat, contextPromotionTarget: dynamicModel.contextPromotionTarget ?? existingModel.contextPromotionTarget, }); const policyModels = [merged as Model]; applyGeneratedModelPolicies(policyModels); return policyModels[0] as Model; } function preferDiscoveryCost(discoveryCost: number, fallbackCost: number): number { if (Number.isFinite(discoveryCost) && 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 { if (!Number.isFinite(discoveryLimit) || discoveryLimit <= 0) { return fallbackLimit; } if (discoveryLimit === 4096 && fallbackLimit > discoveryLimit) { return fallbackLimit; } return discoveryLimit; } function normalizeModelList(value: unknown): Model[] { if (!Array.isArray(value)) { return []; } const models: Model[] = []; for (const item of value) { if (isModelLike(item) && !isRetiredModel(item)) { models.push(enrichModelThinking(item as Model)); } } return applyFinalCodexGpt56ContextCap(models); } function isModelLike(value: unknown): value is Model { if (!isRecord(value)) { return false; } const v = value as { id?: unknown; name?: unknown; api?: unknown; provider?: unknown; baseUrl?: unknown; reasoning?: unknown; input?: unknown; cost?: unknown; contextWindow?: unknown; maxTokens?: unknown; }; if (typeof v.id !== "string" || v.id.length === 0) { return false; } if (typeof v.name !== "string" || v.name.length === 0) { return false; } if (typeof v.api !== "string" || v.api.length === 0) { return false; } if (typeof v.provider !== "string" || v.provider.length === 0) { return false; } if (typeof v.baseUrl !== "string" || v.baseUrl.length === 0) { return false; } if (typeof v.reasoning !== "boolean") { return false; } if (!isModelInputArray(v.input)) { return false; } if (!isModelCost(v.cost)) { return false; } // Finite positive: NaN > 0 is false, +Infinity < Infinity is false. const cw = v.contextWindow; if (typeof cw !== "number" || !(cw > 0 && cw < Infinity)) { return false; } const mt = v.maxTokens; if (typeof mt !== "number" || !(mt > 0 && mt < Infinity)) { return false; } return true; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function isModelInputArray(value: unknown): value is ("text" | "image")[] { if (!Array.isArray(value) || value.length === 0) { return false; } for (let i = 0; i < value.length; i++) { const item = value[i]; if (item !== "text" && item !== "image") { return false; } } return true; } function isModelCost(value: unknown): value is Model["cost"] { if (!isRecord(value)) { return false; } const c = value as { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown; }; // Finite (NaN-safe): -Infinity < x < Infinity rejects NaN and both infinities. // Preserves original behavior: 0 and negatives remain valid. const ci = c.input; if (typeof ci !== "number" || !(ci > -Infinity && ci < Infinity)) { return false; } const co = c.output; if (typeof co !== "number" || !(co > -Infinity && co < Infinity)) { return false; } const cr = c.cacheRead; if (typeof cr !== "number" || !(cr > -Infinity && cr < Infinity)) { return false; } const cw = c.cacheWrite; if (typeof cw !== "number" || !(cw > -Infinity && cw < Infinity)) { return false; } return true; }