diff --git a/changelog.d/fixes/8728-model-catalog-swr.md b/changelog.d/fixes/8728-model-catalog-swr.md new file mode 100644 index 0000000000..9272b3f0b7 --- /dev/null +++ b/changelog.d/fixes/8728-model-catalog-swr.md @@ -0,0 +1 @@ +- **fix(api):** make `/v1/models` stale refresh response-safe and generation-safe, with narrow synced-model invalidation ([#8728](https://github.com/diegosouzapw/OmniRoute/pull/8728)). Related to #8697. diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 8dc554f056..8733341495 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -109,18 +109,25 @@ export { getCustomVisionCapabilityFields }; // lives in ./catalogCache. Re-exported here because the existing tests import the // hooks from this module, and CATALOG_STALE_WHILE_REVALIDATE_MS is part of the // documented behavior of this endpoint. -import { CATALOG_CACHE_TTL_MS_DEFAULT, resolveCachedCatalogResponse } from "./catalogCache"; +import { + CATALOG_CACHE_TTL_MS_DEFAULT, + resolveCachedCatalogResponse, + type CatalogCachePolicy, +} from "./catalogCache"; export { CATALOG_STALE_WHILE_REVALIDATE_MS, + getCatalogStaleWhileRevalidateMs, __resetCatalogBuilderRunsForTest, __getCatalogBuilderRunsForTest, __expireCatalogCacheForTest, __setCatalogCacheEntryForTest, __flushCatalogBackgroundRefreshForTest, __forceCatalogInFlightRejectionForTest, + __setCatalogStaleWhileRevalidateAccessorForTest, + __setCatalogStaleWhileRevalidateMsForTest, } from "./catalogCache"; -export type { CachedCatalog } from "./catalogCache"; +export type { CachedCatalog, CatalogCachePolicy } from "./catalogCache"; /** * Build unified OpenAI-compatible model catalog response. @@ -128,7 +135,8 @@ export type { CachedCatalog } from "./catalogCache"; */ export async function getUnifiedModelsResponse( request: Request, - corsHeaders: Record = {} + corsHeaders: Record = {}, + cachePolicy: CatalogCachePolicy = {} ) { const diagnosticHeaders = getCatalogDiagnosticsHeaders({ request }); @@ -160,7 +168,8 @@ export async function getUnifiedModelsResponse( return await resolveCachedCatalogResponse( request, { corsHeaders, diagnosticHeaders }, - buildCatalogPayload + buildCatalogPayload, + cachePolicy ); } catch (err) { // Hard rule #12: never put a raw err.message/err.stack in a response body. diff --git a/src/app/api/v1/models/catalogCache.ts b/src/app/api/v1/models/catalogCache.ts index 1cff2fa64f..da08307bd5 100644 --- a/src/app/api/v1/models/catalogCache.ts +++ b/src/app/api/v1/models/catalogCache.ts @@ -5,15 +5,16 @@ * builder walks 8 registries and hits SQLite for connections, combos, custom * models and aliases; under Next.js's single-threaded App Router request * handling, N concurrent calls execute back-to-back and the Nth completes at - * N × single-request latency. So identical concurrent requests are coalesced - * onto one in-flight promise and the serialized body is memoized for a short - * window. + * N × single-request latency. Identical concurrent requests are therefore + * coalesced onto one in-flight promise and successful serialized bodies are + * memoized for a short fresh window. * * Auth rejection is NOT handled here and must stay in the caller: it depends on * live per-request state (dashboard cookie, API key) and must never be cached. */ import { getModelCatalogCacheVersion } from "@/lib/db/readCache"; import { extractApiKey } from "@/sse/services/auth"; +import { after } from "next/server"; import { isCodexModelCatalogClient } from "./catalogRequest"; @@ -24,7 +25,7 @@ export type CachedCatalog = { expiresAt: number; }; -/** Payload shape returned by the builder the caller injects. */ +/** Payload shape returned by the shared builder primitive the caller injects. */ export type CatalogPayload = { body: string; headers: Record; @@ -32,54 +33,65 @@ export type CatalogPayload = { cacheTTL: number; }; +export type CatalogRefreshTask = () => Promise; +export type CatalogRefreshScheduler = (task: CatalogRefreshTask) => void; + /** - * A client with a short discovery timeout (Claude Code allows 3 s) must never - * wait on a full rebuild. Once a cached 200 expires it is still served - * immediately for up to this long while a background refresh repopulates it. - * Bounded so a refresh that keeps failing cannot pin an old catalog forever — - * past this window callers fall back to waiting, same as a cold cache. + * Per-call cache policy. Request-context routes inject Next.js `after()` as the + * scheduler; unit tests and direct non-framework callers can inject a deterministic + * scheduler without making the cache branch on runner-specific environment variables. */ -export const CATALOG_STALE_WHILE_REVALIDATE_MS = 30_000; +export type CatalogCachePolicy = { + getStaleWhileRevalidateMs?: () => number; + scheduleBackgroundRefresh?: CatalogRefreshScheduler; +}; + +/** + * Production stale-while-revalidate window. + * + * A successful snapshot remains eligible indefinitely after the 60-second fresh TTL. + * TTL expiry requests return that last success and schedule one refresh. Database state + * changes are different: the version signal below hard-invalidates every snapshot and + * makes the next request await a current-generation build. + */ +export const CATALOG_STALE_WHILE_REVALIDATE_MS = Number.POSITIVE_INFINITY; /** * Fallback memoization window; overridden by `settings.cache.modelCatalogCacheTtlMs`. * - * This does NOT govern post-write freshness — `invalidateDbCache()` bumps - * `modelCatalogCacheVersion` on every settings/connections/combos/pricing write and - * `dropCatalogCacheIfStateChanged()` drops the whole cache the moment it moves, so a - * write is reflected on the very next read regardless of this value. What it governs is - * the "nothing was written" case, where replaying a body built seconds ago is precisely - * the point of the cache. - * - * It was 1500 ms, which was shorter than a single build: measured 2026-07-28 on the - * production VPS, the builder takes ~49 s for a 1.3 MB / 2645-model catalog. Any two - * requests more than 1.5 s apart therefore both missed the fresh window, and the second - * fell into stale-while-revalidate — which rebuilds via `setTimeout(…, 0)` and, because - * the builder is overwhelmingly synchronous under the single-threaded App Router, pins - * the event loop so even the "served immediately" stale body only reaches the client - * once the rebuild finishes. Net effect: ~50 s on essentially every call. - * - * Held at 60 s to match the ceiling the settings schema already allows for the override - * (`settingsSchemas.ts`, `.max(60000)`), so the default can never exceed what an - * operator is permitted to configure. + * This is only the fresh window. Ordinary expiry serves the last successful snapshot + * while refreshing. `modelCatalogCacheVersion` changes bypass stale serving entirely. */ export const CATALOG_CACHE_TTL_MS_DEFAULT = 60_000; +type CatalogInFlight = { + generation: number; + promise: Promise; +}; + const catalogCache = new Map(); +const catalogInFlight = new Map(); -/** - * An in-flight build is bound to the catalog-state generation it started from - * (`getModelCatalogCacheVersion()` at launch). After a write invalidates the - * catalog, the generation moves on: a stale in-flight build must neither be - * joined by new requests nor repopulate the now-current cache when it finishes. - * It still resolves to its own original caller (that request legitimately waits - * on it), just without being persisted. - */ -type InFlightBuild = { generation: number; promise: Promise }; -const catalogInFlight = new Map(); - +let catalogGeneration = 0; +let lastSeenCatalogCacheVersion = getModelCatalogCacheVersion(); +let staleWhileRevalidateMsAccessor = () => CATALOG_STALE_WHILE_REVALIDATE_MS; let _catalogBuilderRuns = 0; +function defaultBackgroundRefreshScheduler(task: CatalogRefreshTask): void { + // All production routes run in Next.js request context, including callers that transform the + // shared response. Direct test/startup callers have no request store and need a safe fallback. + try { + after(task); + } catch { + setImmediate(() => void task()); + } +} + +/** Current SWR policy value; production defaults to unbounded stale serving. */ +export function getCatalogStaleWhileRevalidateMs(): number { + return staleWhileRevalidateMsAccessor(); +} + function buildCatalogCacheKey(request: Request): string { const url = new URL(request.url); const prefix = url.searchParams.get("prefix") || ""; @@ -89,32 +101,28 @@ function buildCatalogCacheKey(request: Request): string { return `${prefix}|${isCodex}|${apiKey}|${configuredOnly}`; } -// Tracks the model-catalog cache version (src/lib/db/readCache.ts) as of the last -// cache access. invalidateDbCache() bumps that version on every settings/connections/ -// combos/pricing write; when it moves on, every memoized entry here was built from -// state that no longer holds, so drop them all rather than keying by version (which -// would leak one Map entry per version forever instead of ever pruning old ones). -let lastSeenCatalogCacheVersion = getModelCatalogCacheVersion(); -function dropCatalogCacheIfStateChanged(): void { +/** + * Observe the DB-side invalidation signal. + * + * Every observed version transition is hard invalidation: snapshots are cleared, + * the local generation advances, and old work is detached. Completion guards also + * call this function, so a version change that occurs while a builder is running + * prevents that builder from writing even before another request arrives. + */ +function synchronizeCatalogGeneration(): void { const currentVersion = getModelCatalogCacheVersion(); if (currentVersion === lastSeenCatalogCacheVersion) return; + lastSeenCatalogCacheVersion = currentVersion; + catalogGeneration++; catalogCache.clear(); - // Deliberately NOT clearing catalogInFlight: an in-flight build bound to the - // previous generation is left to finish for its original caller, but the - // generation check in the join path (below) keeps new requests from joining - // it, and the generation check in storePayload keeps it from repopulating - // the now-current cache. Clearing it here would just detach the entry while - // the build still ran — wasted work with no correctness gain. + catalogInFlight.clear(); } // Header sources mix Title-Case keys (diagnostic/cors headers built by app code) with -// lower-case ones (payload headers captured via the Fetch `Headers` iterator). A plain -// object spread keeps both casings as distinct keys, and the `Response` constructor -// then *appends* rather than overwrites them, producing comma-joined duplicates (e.g. -// request-id echoing "foo, foo"). Merge through a real `Headers` so `.set()` overwrites -// case-insensitively. Earlier sources are the base; the caller passes diagnostics last -// so per-request fields reflect the current request, not whichever one filled the cache. +// lower-case ones (payload headers captured via the Fetch `Headers` iterator). Merge +// through a real Headers so the caller's per-request diagnostics overwrite cached values +// case-insensitively. export function mergeCatalogHeaders( ...sources: Array | undefined> ): Headers { @@ -128,81 +136,26 @@ export function mergeCatalogHeaders( return merged; } -/** - * Persist a freshly built payload — but only when the build still belongs to the - * current catalog-state generation. A build that started before a write - * invalidation (its `buildGeneration` is older than `getModelCatalogCacheVersion()`) - * returns its entry to its original caller but must NOT repopulate the cache: the - * payload reflects pre-write state and caching it would serve stale data. - */ -function storePayload( +function isSuccessfulPayload(payload: CatalogPayload): boolean { + return payload.status >= 200 && payload.status < 300; +} + +function storeSuccessfulPayload( cacheKey: string, payload: CatalogPayload, - buildGeneration: number -): CachedCatalog { - const entry: CachedCatalog = { + inFlight: CatalogInFlight +): void { + synchronizeCatalogGeneration(); + if (!isSuccessfulPayload(payload)) return; + if (inFlight.generation !== catalogGeneration) return; + if (catalogInFlight.get(cacheKey) !== inFlight) return; + + catalogCache.set(cacheKey, { body: payload.body, headers: payload.headers, status: payload.status, expiresAt: Date.now() + payload.cacheTTL, - }; - if (buildGeneration === getModelCatalogCacheVersion()) { - catalogCache.set(cacheKey, entry); - } - return entry; -} - -/** - * Kick off a background rebuild so an expired-but-stale-eligible entry can be - * refreshed without the current request waiting on it. Reuses catalogInFlight — - * no second coalescing mechanism — so a concurrent cold/stale request for the - * same key joins this refresh instead of starting another. - * - * The builder runs one macrotask later so the stale response that triggered this - * call is handed back before the builder's synchronous prologue runs; the whole - * point of this path is that the caller does not pay for the rebuild. - * - * The tracked promise **rejects** on failure. catalogInFlight is shared with the - * cold path: a caller whose entry aged past the stale window skips the stale - * branch and awaits whatever promise it finds here, and resolving with the stale - * entry would hand it a body it was no longer entitled to while disguising a - * build failure as a 200. The rejection is pre-handled so this path can never - * raise an unhandledRejection; a failed refresh simply never overwrites the entry. - */ -function scheduleBackgroundRefresh( - cacheKey: string, - request: Request, - buildPayload: (request: Request) => Promise -): void { - if (catalogInFlight.has(cacheKey)) return; // a refresh for this key is already running - - const generation = getModelCatalogCacheVersion(); - const refreshPromise: Promise = new Promise((resolve, reject) => { - setTimeout(() => { - runBuilder(buildPayload, request) - .then((payload) => resolve(storePayload(cacheKey, payload, generation))) - .catch((err) => { - console.error( - `[catalog] Background stale-while-revalidate refresh failed for key "${cacheKey}":`, - err - ); - reject(err); - }); - }, 0); }); - - // Nobody on the stale path awaits this, so pre-handle the rejection; a cold-path - // caller that joins it via catalogInFlight attaches its own handler and still - // observes the failure. - refreshPromise.catch(() => {}); - - catalogInFlight.set(cacheKey, { generation, promise: refreshPromise }); - refreshPromise - .catch(() => {}) - .finally(() => { - if (catalogInFlight.get(cacheKey)?.promise === refreshPromise) - catalogInFlight.delete(cacheKey); - }); } function runBuilder( @@ -210,23 +163,104 @@ function runBuilder( request: Request ): Promise { _catalogBuilderRuns++; - return buildPayload(request); + try { + return Promise.resolve(buildPayload(request)); + } catch (error) { + return Promise.reject(error); + } +} + +function cleanInFlight(cacheKey: string, inFlight: CatalogInFlight): void { + if (catalogInFlight.get(cacheKey) === inFlight) { + catalogInFlight.delete(cacheKey); + } +} + +function startSynchronousBuild( + cacheKey: string, + request: Request, + buildPayload: (request: Request) => Promise +): CatalogInFlight { + const generation = catalogGeneration; + let inFlight!: CatalogInFlight; + const promise = runBuilder(buildPayload, request).then((payload) => { + storeSuccessfulPayload(cacheKey, payload, inFlight); + return payload; + }); + inFlight = { generation, promise }; + catalogInFlight.set(cacheKey, inFlight); + inFlight.promise.then( + () => cleanInFlight(cacheKey, inFlight), + () => cleanInFlight(cacheKey, inFlight) + ); + return inFlight; +} + +function scheduleBackgroundRefresh( + cacheKey: string, + request: Request, + buildPayload: (request: Request) => Promise, + schedule: CatalogRefreshScheduler +): void { + if (catalogInFlight.has(cacheKey)) return; + + let resolveRefresh!: (payload: CatalogPayload) => void; + let rejectRefresh!: (error: unknown) => void; + const inFlight: CatalogInFlight = { + generation: catalogGeneration, + promise: new Promise((resolve, reject) => { + resolveRefresh = resolve; + rejectRefresh = reject; + }), + }; + + // Reserve the key before handing the task to the scheduler. Multiple stale reads in + // the same request turn therefore cannot enqueue duplicate refreshes. + catalogInFlight.set(cacheKey, inFlight); + void inFlight.promise.catch(() => {}); // background failures are always handled + + const task: CatalogRefreshTask = async () => { + synchronizeCatalogGeneration(); + if (inFlight.generation !== catalogGeneration || catalogInFlight.get(cacheKey) !== inFlight) { + // Hard invalidation or a deterministic test reset detached this scheduled task + // before it started. Resolve its private bookkeeping promise without rebuilding. + resolveRefresh({ body: "", headers: {}, status: 204, cacheTTL: 0 }); + return; + } + + try { + const payload = await runBuilder(buildPayload, request); + storeSuccessfulPayload(cacheKey, payload, inFlight); + resolveRefresh(payload); + } catch (error) { + console.error("[catalog] Background stale-while-revalidate refresh failed:", error); + rejectRefresh(error); + } finally { + cleanInFlight(cacheKey, inFlight); + } + }; + + try { + schedule(task); + } catch (error) { + cleanInFlight(cacheKey, inFlight); + rejectRefresh(error); + console.error("[catalog] Failed to schedule background refresh:", error); + } } /** - * Resolve the cached catalog response for `request`, building it through - * `buildPayload` when there is nothing fresh to serve. - * - * Returns `null` when the caller must build and handle errors itself — i.e. the - * in-flight build rejected — so the error-response shape stays in the caller. + * Resolve the cached catalog response for `request`, building it through the shared + * `buildPayload` primitive when there is no current snapshot. */ export async function resolveCachedCatalogResponse( request: Request, headerSources: { corsHeaders: Record; diagnosticHeaders: Record }, - buildPayload: (request: Request) => Promise + buildPayload: (request: Request) => Promise, + policy: CatalogCachePolicy = {} ): Promise { const { corsHeaders, diagnosticHeaders } = headerSources; - dropCatalogCacheIfStateChanged(); + synchronizeCatalogGeneration(); const cacheKey = buildCatalogCacheKey(request); const now = Date.now(); @@ -239,41 +273,32 @@ export async function resolveCachedCatalogResponse( }); } - // Stale-while-revalidate: an expired entry is still served immediately as long as - // (a) it was a successful build — a cached error replayed as "stale" would mask an - // intermittent failure behind a fake success forever — and (b) it is within the - // staleness window, so a refresh that keeps failing eventually falls through to the - // cold-path wait instead of pinning ancient data. + const staleWhileRevalidateMs = + policy.getStaleWhileRevalidateMs?.() ?? getCatalogStaleWhileRevalidateMs(); if ( cached && - cached.status === 200 && - now - cached.expiresAt <= CATALOG_STALE_WHILE_REVALIDATE_MS + cached.status >= 200 && + cached.status < 300 && + now - cached.expiresAt <= staleWhileRevalidateMs ) { - scheduleBackgroundRefresh(cacheKey, request, buildPayload); + scheduleBackgroundRefresh( + cacheKey, + request, + buildPayload, + policy.scheduleBackgroundRefresh ?? defaultBackgroundRefreshScheduler + ); return new Response(cached.body, { status: cached.status, headers: mergeCatalogHeaders(corsHeaders, cached.headers, diagnosticHeaders), }); } - const currentGeneration = getModelCatalogCacheVersion(); - let inflight = catalogInFlight.get(cacheKey); - // Only join an in-flight build from the CURRENT generation. A build bound to an - // older (pre-write) generation reflects stale state, so a new request starts a - // fresh build instead of joining it. - if (!inflight || inflight.generation !== currentGeneration) { - const generation = currentGeneration; - const promise = runBuilder(buildPayload, request).then((payload) => - storePayload(cacheKey, payload, generation) - ); - inflight = { generation, promise }; - catalogInFlight.set(cacheKey, inflight); - promise.finally(() => { - if (catalogInFlight.get(cacheKey)?.promise === promise) catalogInFlight.delete(cacheKey); - }); + let inFlight = catalogInFlight.get(cacheKey); + if (!inFlight) { + inFlight = startSynchronousBuild(cacheKey, request, buildPayload); } - const payload = await inflight.promise; + const payload = await inFlight.promise; return new Response(payload.body, { status: payload.status, headers: mergeCatalogHeaders(corsHeaders, payload.headers, diagnosticHeaders), @@ -281,14 +306,26 @@ export async function resolveCachedCatalogResponse( } // ── Test hooks ─────────────────────────────────────────────────────────────── -// Not part of the public API; do not read from app code. +// Not part of the public application API. -/** Resets the builder counter and every cached/in-flight entry. */ +/** Deterministically resets counters, policy, snapshots, generations, and old work. */ export function __resetCatalogBuilderRunsForTest(): void { _catalogBuilderRuns = 0; + catalogGeneration++; catalogCache.clear(); catalogInFlight.clear(); lastSeenCatalogCacheVersion = getModelCatalogCacheVersion(); + staleWhileRevalidateMsAccessor = () => CATALOG_STALE_WHILE_REVALIDATE_MS; +} + +/** Injects the SWR policy accessor without environment-dependent behavior. */ +export function __setCatalogStaleWhileRevalidateAccessorForTest(accessor: () => number): void { + staleWhileRevalidateMsAccessor = accessor; +} + +/** Backward-compatible scalar policy hook retained for focused tests. */ +export function __setCatalogStaleWhileRevalidateMsForTest(ms: number): void { + staleWhileRevalidateMsAccessor = () => ms; } /** Counts full builder executions — proves concurrent requests share one run (#6408). */ @@ -296,11 +333,7 @@ export function __getCatalogBuilderRunsForTest(): number { return _catalogBuilderRuns; } -/** - * Marks every cached entry as expired `msAgo` milliseconds ago instead of sleeping - * out the real TTL. Pass more than CATALOG_STALE_WHILE_REVALIDATE_MS to simulate an - * entry that has aged past the stale-serving window. - */ +/** Marks every successful snapshot expired without sleeping out the real TTL. */ export function __expireCatalogCacheForTest(msAgo = 1): void { const expiresAt = Date.now() - msAgo; for (const [key, entry] of catalogCache.entries()) { @@ -308,38 +341,22 @@ export function __expireCatalogCacheForTest(msAgo = 1): void { } } -/** - * Seeds the entry a given request would read, for status/staleness combinations the - * intentionally exception-resistant builder cannot be made to produce (e.g. a cached - * non-200). Takes the Request so the cache-key format stays private to this module. - */ +/** Seeds a request-keyed snapshot for status/staleness compatibility tests. */ export function __setCatalogCacheEntryForTest(request: Request, entry: CachedCatalog): void { catalogCache.set(buildCatalogCacheKey(request), entry); } -/** Awaits any background refresh in flight, instead of guessing at a real-time sleep. */ +/** Awaits any currently running or scheduled refresh without real-time sleeps. */ export async function __flushCatalogBackgroundRefreshForTest(): Promise { - await Promise.all([...catalogInFlight.values()].map((entry) => entry.promise.catch(() => {}))); + await Promise.all([...catalogInFlight.values()].map(({ promise }) => promise.catch(() => {}))); } -/** - * Injects a synthetic in-flight rejection so the caller's catch branch (sanitized - * error body) can be exercised deterministically — the builder core try/catches every - * registry and DB read individually, so it is not a practical error-injection point. - * - * Deliberately does not self-clean the way production entries do: this promise is - * already rejected at creation, so a cleanup callback would delete the map entry - * within a microtask or two — before the caller's several-await auth check finishes — - * silently swapping in a fresh cold build instead of the intended failure. The next - * __resetCatalogBuilderRunsForTest() clears it. - */ +/** Injects a handled in-flight rejection for the catalog error-shape regression test. */ export function __forceCatalogInFlightRejectionForTest(request: Request, error: unknown): void { - const rejected: Promise = Promise.reject(error); - rejected.catch(() => {}); // mark as handled — avoids an unhandledRejection warning - // Bind to the current generation so the cold path still joins it (a stale - // generation would be skipped as pre-write state and never awaited). + const promise: Promise = Promise.reject(error); + void promise.catch(() => {}); catalogInFlight.set(buildCatalogCacheKey(request), { - generation: getModelCatalogCacheVersion(), - promise: rejected, + generation: catalogGeneration, + promise, }); } diff --git a/src/app/api/v1/models/route.ts b/src/app/api/v1/models/route.ts index f884388ae8..da37ca65e8 100644 --- a/src/app/api/v1/models/route.ts +++ b/src/app/api/v1/models/route.ts @@ -1,3 +1,5 @@ +import { after } from "next/server"; + import { getUnifiedModelsResponse } from "./catalog"; /** @@ -31,5 +33,11 @@ export async function HEAD() { * GET /v1/models - OpenAI compatible models list */ export async function GET(request: Request) { - return getUnifiedModelsResponse(request); + return getUnifiedModelsResponse( + request, + {}, + { + scheduleBackgroundRefresh: (task) => after(task), + } + ); } diff --git a/src/lib/catalog/openrouterCatalog.ts b/src/lib/catalog/openrouterCatalog.ts index cb60ec0f96..47a0891eb0 100644 --- a/src/lib/catalog/openrouterCatalog.ts +++ b/src/lib/catalog/openrouterCatalog.ts @@ -9,6 +9,7 @@ import fs from "fs"; import path from "path"; +import { invalidateModelCatalogCache } from "@/lib/db/readCache"; const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models"; const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -169,6 +170,7 @@ export async function refreshOpenRouterCatalog(): Promise<{ try { const data = await fetchFromAPI(); writeCache(data); + invalidateModelCatalogCache(); return { data, ok: true }; } catch (err) { const error = err instanceof Error ? err.message : String(err); diff --git a/src/lib/db/apiKeyGroups.ts b/src/lib/db/apiKeyGroups.ts index d14d69974b..22a85ac3ef 100644 --- a/src/lib/db/apiKeyGroups.ts +++ b/src/lib/db/apiKeyGroups.ts @@ -9,6 +9,7 @@ import { getDbInstance } from "@/lib/db/core"; import { randomUUID } from "crypto"; +import { invalidateModelCatalogCache } from "./readCache"; // ── Types ──────────────────────────────────────────────────────────────── @@ -71,7 +72,7 @@ export function createKeyGroup(name: string, description = ""): KeyGroup { const now = new Date().toISOString(); db.prepare( - "INSERT INTO key_groups (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)" + "INSERT INTO key_groups (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", ).run(id, name, description, now, now); return getKeyGroup(id)!; @@ -79,7 +80,7 @@ export function createKeyGroup(name: string, description = ""): KeyGroup { export function updateKeyGroup( id: string, - updates: { name?: string; description?: string; isActive?: boolean } + updates: { name?: string; description?: string; isActive?: boolean }, ): KeyGroup | undefined { const existing = getKeyGroup(id); if (!existing) return undefined; @@ -102,17 +103,27 @@ export function updateKeyGroup( } if (sets.length === 0) return existing; + const catalogInvalidationNeeded = + updates.isActive !== undefined && updates.isActive !== existing.isActive; + sets.push("updated_at = datetime('now')"); - db.prepare(`UPDATE key_groups SET ${sets.join(", ")} WHERE id = @id`).run(params); - return getKeyGroup(id); + const result = db.prepare(`UPDATE key_groups SET ${sets.join(", ")} WHERE id = @id`).run(params); + if (catalogInvalidationNeeded && result.changes > 0) { + invalidateModelCatalogCache(); + } + return result.changes > 0 ? getKeyGroup(id) : existing; } export function deleteKeyGroup(id: string): boolean { const db = getDbInstance() as any; // CASCADE deletes permissions and members const result = db.prepare("DELETE FROM key_groups WHERE id = ?").run(id); - return result.changes > 0; + if (result.changes > 0) { + invalidateModelCatalogCache(); + return true; + } + return false; } // ── Group Permissions ──────────────────────────────────────────────────── @@ -121,7 +132,7 @@ export function getGroupPermissions(groupId: string): GroupModelPermission[] { const db = getDbInstance() as any; const rows = db .prepare( - "SELECT * FROM group_model_permissions WHERE group_id = ? ORDER BY access_type ASC, model_pattern ASC" + "SELECT * FROM group_model_permissions WHERE group_id = ? ORDER BY access_type ASC, model_pattern ASC", ) .all(groupId) as any[]; return rows.map(rowToPermission); @@ -131,15 +142,21 @@ export function addGroupPermission( groupId: string, modelPattern: string, accessType: "allow" | "deny", - provider?: string + provider?: string, ): GroupModelPermission { const db = getDbInstance() as any; const id = randomUUID(); const now = new Date().toISOString(); - db.prepare( - "INSERT INTO group_model_permissions (id, group_id, model_pattern, provider, access_type, created_at) VALUES (?, ?, ?, ?, ?, ?)" - ).run(id, groupId, modelPattern, provider || null, accessType, now); + const result = db + .prepare( + "INSERT INTO group_model_permissions (id, group_id, model_pattern, provider, access_type, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run(id, groupId, modelPattern, provider || null, accessType, now); + + if (result.changes > 0) { + invalidateModelCatalogCache(); + } return getGroupPermissions(groupId).find((p) => p.id === id)!; } @@ -147,12 +164,18 @@ export function addGroupPermission( export function removeGroupPermission(permissionId: string): boolean { const db = getDbInstance() as any; const result = db.prepare("DELETE FROM group_model_permissions WHERE id = ?").run(permissionId); + if (result.changes > 0) { + invalidateModelCatalogCache(); + } return result.changes > 0; } export function clearGroupPermissions(groupId: string): void { const db = getDbInstance() as any; - db.prepare("DELETE FROM group_model_permissions WHERE group_id = ?").run(groupId); + const result = db.prepare("DELETE FROM group_model_permissions WHERE group_id = ?").run(groupId); + if (result.changes > 0) { + invalidateModelCatalogCache(); + } } // ── Key Group Members ──────────────────────────────────────────────────── @@ -174,7 +197,7 @@ export function getKeyGroupsForApiKey(keyId: string): KeyGroup[] { INNER JOIN key_group_members m ON g.id = m.group_id WHERE m.key_id = ? AND g.is_active = 1 ORDER BY g.name ASC - ` + `, ) .all(keyId) as any[]; return rows.map(rowToGroup); @@ -183,10 +206,12 @@ export function getKeyGroupsForApiKey(keyId: string): KeyGroup[] { export function addKeyToGroup(keyId: string, groupId: string): boolean { const db = getDbInstance() as any; try { - db.prepare("INSERT OR IGNORE INTO key_group_members (key_id, group_id) VALUES (?, ?)").run( - keyId, - groupId - ); + const result = db + .prepare("INSERT OR IGNORE INTO key_group_members (key_id, group_id) VALUES (?, ?)") + .run(keyId, groupId); + if (result.changes > 0) { + invalidateModelCatalogCache(); + } return true; } catch { return false; @@ -198,6 +223,9 @@ export function removeKeyFromGroup(keyId: string, groupId: string): boolean { const result = db .prepare("DELETE FROM key_group_members WHERE key_id = ? AND group_id = ?") .run(keyId, groupId); + if (result.changes > 0) { + invalidateModelCatalogCache(); + } return result.changes > 0; } @@ -224,7 +252,7 @@ export interface ModelAccessCheck { export function checkKeyModelAccess( keyId: string, model: string, - provider?: string + provider?: string, ): ModelAccessCheck { const groups = getKeyGroupsForApiKey(keyId); if (groups.length === 0) { @@ -242,7 +270,7 @@ export function checkKeyModelAccess( SELECT * FROM group_model_permissions WHERE group_id IN (${placeholders}) ORDER BY access_type ASC - ` + `, ) .all(...groupIds) as any[]; @@ -253,7 +281,7 @@ export function checkKeyModelAccess( (p) => p.accessType === "deny" && matchesModelPattern(p.modelPattern, model) && - (!p.provider || p.provider === provider) + (!p.provider || p.provider === provider), ); if (denyRules.length > 0) { @@ -265,7 +293,7 @@ export function checkKeyModelAccess( (p) => p.accessType === "allow" && matchesModelPattern(p.modelPattern, model) && - (!p.provider || p.provider === provider) + (!p.provider || p.provider === provider), ); if (allowRules.length > 0) { diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 0b43e699a9..2cb2cce347 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -48,6 +48,13 @@ import { parseStreamDefaultMode, parseChaosModeEnabled, } from "./apiKeys/rowParsers"; +import { + clearModelPermissionCache, + getCachedModelPermission, + setCachedModelPermission, + evictModelPermissionCache, +} from "./apiKeys/modelPermissionCache"; +import { getModelCatalogCacheVersion, invalidateModelCatalogCache } from "./readCache"; import type { AccessSchedule, RateLimitRule } from "./apiKeys/types"; // ──────────────── Performance Optimizations ──────────────── @@ -62,7 +69,6 @@ interface CacheEntry { value: TValue; } -// Re-exported for the historical public surface (moved to ./apiKeys/types). export type { AccessSchedule, RateLimitRule } from "./apiKeys/types"; interface ApiKeyMetadata { @@ -82,9 +88,7 @@ interface ApiKeyMetadata { maxRequestsPerMinute: number | null; throttleDelayMs: number | null; rateLimits: RateLimitRule[] | null; - // T08: Per-key max concurrent sticky sessions (0 = unlimited) maxSessions: number; - // Phase 3 lifecycle/policy fields revokedAt: string | null; expiresAt: string | null; ipAllowlist: string[]; @@ -198,12 +202,6 @@ const CACHE_TTL = 60 * 1000; // 1 minute TTL const LAST_USED_UPDATE_TTL = 5 * 60 * 1000; const MAX_CACHE_SIZE = 1000; -// Wildcard scope matching is now handled by `matchesWildcardPattern` -// (deterministic, no RegExp from dynamic strings). - -// Cache for model permission checks -const _modelPermissionCache = new Map(); - // Prepared statements cache let _stmtGetAllKeys: ApiKeysStatements["getAllKeys"] | null = null; let _stmtGetKeyById: ApiKeysStatements["getKeyById"] | null = null; @@ -218,7 +216,7 @@ let _stmtDeleteKey: ApiKeysStatements["deleteKey"] | null = null; function invalidateCaches() { _keyValidationCache.clear(); _keyMetadataCache.clear(); - _modelPermissionCache.clear(); + clearModelPermissionCache(); _lastUsedUpdateCache.clear(); } @@ -278,12 +276,8 @@ function markApiKeyUsed(db: ApiKeysDbLike, id: unknown, now: number): void { _lastUsedUpdateCache.set(id, now); } -/** - * LRU eviction for cache - */ function evictIfNeeded(cache: Map) { if (cache.size > MAX_CACHE_SIZE) { - // Remove oldest 20% of entries const entriesToRemove = Math.floor(MAX_CACHE_SIZE * 0.2); let i = 0; for (const key of cache.keys()) { @@ -315,7 +309,7 @@ async function getModelPermissionCandidates(modelId: string): Promise providerOrAlias, providerScopedModel, resolveProviderId, - getProviderAlias + getProviderAlias, ); } return Array.from(candidates); @@ -333,7 +327,7 @@ async function getModelPermissionCandidates(modelId: string): Promise } async function getPublishedModelLookupTarget( - modelId: string + modelId: string, ): Promise<{ providerId: string; modelId: string } | null> { const cleanModelId = stripExtendedContextSuffix(modelId.trim()); if (!cleanModelId) return null; @@ -362,14 +356,13 @@ async function getPublishedModelLookupTarget( function ensureApiKeyColumn( db: ApiKeysDbLike, columnNames: Set, - column: (typeof API_KEY_COLUMN_FALLBACKS)[number] + column: (typeof API_KEY_COLUMN_FALLBACKS)[number], ): void { if (columnNames.has(column.name)) return; db.exec(`ALTER TABLE api_keys ADD COLUMN ${column.definition}`); console.log(`[DB] Added api_keys.${column.name} column`); } -// Ensure api_keys extension columns exist (memoized) function ensureApiKeysColumns(db: ApiKeysDbLike) { if (_schemaChecked) return; @@ -386,10 +379,6 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) { } } -/** - * Initialize prepared statements (lazy initialization) - * Re-creates statements if the underlying DB connection changed (HMR, backup restore). - */ let _stmtDb: ApiKeysDbLike | null = null; function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { ensureApiKeysColumns(db); @@ -407,13 +396,13 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { _stmtGetAllKeys = db.prepare("SELECT * FROM api_keys ORDER BY created_at"); _stmtGetKeyById = db.prepare("SELECT * FROM api_keys WHERE id = ?"); _stmtValidateKey = db.prepare( - "SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?" + "SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?", ); _stmtGetKeyMetadata = db.prepare( - "SELECT id, name, machine_id, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?" + "SELECT id, name, machine_id, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?", ); _stmtInsertKey = db.prepare( - "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ); _stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?"); } @@ -466,7 +455,7 @@ export async function getApiKeys(limit?: number, offset?: number) { camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); camelRow.disableNonPublicModels = parseDisableNonPublicModels( - (camelRow as JsonRecord).disableNonPublicModels + (camelRow as JsonRecord).disableNonPublicModels, ); camelRow.allowUsageCommand = parseAllowUsageCommand((camelRow as JsonRecord).allowUsageCommand); camelRow.chaosModeEnabled = parseChaosModeEnabled((camelRow as JsonRecord).chaosModeEnabled); @@ -509,7 +498,7 @@ export function getApiKeysCount(): number { * inactive, or banned key, and it never widens a key's allowedModels. */ export async function pickApiKeyForInternalUse( - purpose: "combo-health-check" | "cloud-sync-verify" | "internal-probe" = "internal-probe" + purpose: "combo-health-check" | "cloud-sync-verify" | "internal-probe" = "internal-probe", ): Promise { try { const keys = (await getApiKeys()) as Array<{ @@ -527,13 +516,13 @@ export async function pickApiKeyForInternalUse( // 1. Management-scoped key (preferred for any internal probe). const manageKey = keys.find( - (k) => isUsable(k) && Array.isArray(k.scopes) && k.scopes.includes("manage") + (k) => isUsable(k) && Array.isArray(k.scopes) && k.scopes.includes("manage"), ); if (manageKey?.key) return manageKey.key; // 2. Allow-all key (empty allowedModels means no model restrictions). const allowAllKey = keys.find( - (k) => isUsable(k) && Array.isArray(k.allowedModels) && k.allowedModels.length === 0 + (k) => isUsable(k) && Array.isArray(k.allowedModels) && k.allowedModels.length === 0, ); if (allowAllKey?.key) return allowAllKey.key; @@ -576,7 +565,7 @@ export async function getApiKeyById(id: string) { camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); camelRow.disableNonPublicModels = parseDisableNonPublicModels( - (camelRow as JsonRecord).disableNonPublicModels + (camelRow as JsonRecord).disableNonPublicModels, ); camelRow.allowUsageCommand = parseAllowUsageCommand((camelRow as JsonRecord).allowUsageCommand); camelRow.chaosModeEnabled = parseChaosModeEnabled((camelRow as JsonRecord).chaosModeEnabled); @@ -633,7 +622,7 @@ export async function createApiKey(name: string, machineId: string, scopes: stri apiKey.createdAt, apiKey.key.slice(0, 12), await hashKey(apiKey.key), - JSON.stringify(scopes) + JSON.stringify(scopes), ); setNoLog(apiKey.id, false); @@ -655,7 +644,7 @@ export async function regenerateApiKey(id: string) { // Update in DB const updateStmt = db.prepare( - "UPDATE api_keys SET key = ?, key_hash = ?, key_prefix = ? WHERE id = ?" + "UPDATE api_keys SET key = ?, key_hash = ?, key_prefix = ? WHERE id = ?", ); updateStmt.run(newKey, newHash, newPrefix, id); @@ -707,7 +696,7 @@ export async function updateApiKeyPermissions( dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; chaosModeEnabled?: boolean; - } + }, ) { const db = getDbInstance() as ApiKeysDbLike; getPreparedStatements(db); @@ -1001,6 +990,16 @@ export async function updateApiKeyPermissions( if (changedRows === 0) return false; + const invalidatesModelCatalogCache = + normalized.allowedModels !== undefined || + normalized.blockedModels !== undefined || + allowedQuotasUpdate !== undefined || + normalized.disableNonPublicModels !== undefined; + + if (invalidatesModelCatalogCache) { + invalidateModelCatalogCache(); + } + const { logAuditEvent } = await import("@/lib/compliance"); if (normalized.isBanned !== undefined) { @@ -1094,7 +1093,7 @@ export async function revokeApiKey(id: string): Promise { const result = db .prepare( - "UPDATE api_keys SET revoked_at = COALESCE(revoked_at, @ts), is_active = 0 WHERE id = @id" + "UPDATE api_keys SET revoked_at = COALESCE(revoked_at, @ts), is_active = 0 WHERE id = @id", ) .run({ id, ts: new Date().toISOString() }); @@ -1223,7 +1222,7 @@ export async function validateApiKey(key: string | null | undefined) { revokedAt: row.revoked_at, }), "EX", - 3600 // 1 hour cache + 3600, // 1 hour cache ); } } catch { @@ -1240,7 +1239,7 @@ export async function validateApiKey(key: string | null | undefined) { * Get API key metadata with caching for performance */ export async function getApiKeyMetadata( - key: string | null | undefined + key: string | null | undefined, ): Promise { if (!key || typeof key !== "string") return null; @@ -1339,10 +1338,10 @@ export async function getApiKeyMetadata( blockedModels: parseAllowedModels(record.blocked_models ?? record.blockedModels), allowedCombos: parseAllowedCombos(record.allowed_combos ?? record.allowedCombos), allowedConnections: parseAllowedConnections( - record.allowed_connections ?? record.allowedConnections + record.allowed_connections ?? record.allowedConnections, ), allowedQuotas: parseAllowedQuotas( - (record as JsonRecord).allowed_quotas ?? (record as JsonRecord).allowedQuotas + (record as JsonRecord).allowed_quotas ?? (record as JsonRecord).allowedQuotas, ), noLog: parseNoLog(record.no_log ?? record.noLog), autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve), @@ -1364,20 +1363,20 @@ export async function getApiKeyMetadata( proxyId: typeof record.proxy_id === "string" && record.proxy_id.trim() !== "" ? record.proxy_id : null, allowedEndpoints: parseStringList( - (record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints + (record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints, ), streamDefaultMode: parseStreamDefaultMode( - (record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode + (record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode, ), disableNonPublicModels: parseDisableNonPublicModels( (record as JsonRecord).disable_non_public_models ?? - (record as JsonRecord).disableNonPublicModels + (record as JsonRecord).disableNonPublicModels, ), allowUsageCommand: parseAllowUsageCommand( - (record as JsonRecord).allow_usage_command ?? (record as JsonRecord).allowUsageCommand + (record as JsonRecord).allow_usage_command ?? (record as JsonRecord).allowUsageCommand, ), chaosModeEnabled: parseChaosModeEnabled( - (record as JsonRecord).chaos_mode_enabled ?? (record as JsonRecord).chaosModeEnabled + (record as JsonRecord).chaos_mode_enabled ?? (record as JsonRecord).chaosModeEnabled, ), ...parseApiKeyUsageLimitFields(record as JsonRecord), }; @@ -1403,7 +1402,7 @@ export async function getApiKeyMetadata( */ export async function isModelAllowedForKey( key: string | null | undefined, - modelId: string | null | undefined + modelId: string | null | undefined, ) { // If no key provided, allow (request may be using different auth method like JWT) // If no modelId provided, deny (invalid request) @@ -1413,12 +1412,13 @@ export async function isModelAllowedForKey( // Create cache key const cacheKey = `${key}:${modelId}`; const now = Date.now(); + const catalogGeneration = getModelCatalogCacheVersion(); const usesSettingDependentClaudeRouting = isPotentialUnprefixedClaudeCodeModel(modelId); // Check permission cache - const cached = _modelPermissionCache.get(cacheKey); - if (!usesSettingDependentClaudeRouting && cached && now - cached.timestamp < CACHE_TTL) { - return cached.allowed; + const cached = getCachedModelPermission(cacheKey, now, catalogGeneration); + if (!usesSettingDependentClaudeRouting && cached !== undefined) { + return cached; } const metadata = await getApiKeyMetadata(key); @@ -1479,8 +1479,8 @@ export async function isModelAllowedForKey( } // Cache the result if (!usesSettingDependentClaudeRouting) { - evictIfNeeded(_modelPermissionCache); - _modelPermissionCache.set(cacheKey, { allowed, timestamp: now }); + evictModelPermissionCache(); + setCachedModelPermission(cacheKey, allowed, now, catalogGeneration); } return allowed; @@ -1506,8 +1506,6 @@ function clearPreparedStatementCache() { */ export function clearApiKeyCaches() { invalidateCaches(); - _lastUsedUpdateCache.clear(); - _modelPermissionCache.clear(); } /** diff --git a/src/lib/db/apiKeys/modelPermissionCache.ts b/src/lib/db/apiKeys/modelPermissionCache.ts new file mode 100644 index 0000000000..d353f59b38 --- /dev/null +++ b/src/lib/db/apiKeys/modelPermissionCache.ts @@ -0,0 +1,62 @@ +const MODEL_PERMISSION_CACHE_TTL = 60 * 1000; + +interface ModelPermissionCacheValue { + allowed: boolean; + timestamp: number; + generation: number; +} + +const _modelPermissionCache = new Map(); + +function isFresh( + entry: ModelPermissionCacheValue, + now: number, + currentGeneration: number, +): boolean { + if (entry.generation !== currentGeneration) return false; + if (now - entry.timestamp >= MODEL_PERMISSION_CACHE_TTL) return false; + return true; +} + +export function getCachedModelPermission( + cacheKey: string, + now: number, + catalogGeneration: number, +): boolean | undefined { + const entry = _modelPermissionCache.get(cacheKey); + if (!entry) return undefined; + + if (!isFresh(entry, now, catalogGeneration)) { + _modelPermissionCache.delete(cacheKey); + return undefined; + } + + return entry.allowed; +} + +export function setCachedModelPermission( + cacheKey: string, + allowed: boolean, + now: number, + catalogGeneration: number, +): void { + _modelPermissionCache.set(cacheKey, { + allowed, + timestamp: now, + generation: catalogGeneration, + }); +} + +export function evictModelPermissionCache(): void { + if (_modelPermissionCache.size <= 1000) return; + const entriesToRemove = Math.floor(1000 * 0.2); + let i = 0; + for (const key of _modelPermissionCache.keys()) { + if (i++ >= entriesToRemove) break; + _modelPermissionCache.delete(key); + } +} + +export function clearModelPermissionCache(): void { + _modelPermissionCache.clear(); +} diff --git a/src/lib/db/ccDiscoveryAliases.ts b/src/lib/db/ccDiscoveryAliases.ts index 0eb46e2b34..2d16d2c673 100644 --- a/src/lib/db/ccDiscoveryAliases.ts +++ b/src/lib/db/ccDiscoveryAliases.ts @@ -22,6 +22,7 @@ import { getFeatureFlagOverride } from "./featureFlags"; import { getDbInstance } from "./core"; +import { finishModelCatalogWriteWithoutBackup } from "./models/modelCatalogWriteSignals"; const NAMESPACE = "ccDiscoveryAliases"; const FLAG_KEY = "EXPOSE_CC_DISCOVERY_ALIASES"; @@ -71,13 +72,15 @@ export function setCcAliasProviderSetting(providerId: string, v: CcAliasSetting) const key = providerKey(providerId); if (v === null) { db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(NAMESPACE, key); + finishModelCatalogWriteWithoutBackup(); return; } db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( NAMESPACE, key, - v + v, ); + finishModelCatalogWriteWithoutBackup(); } export function getCcAliasModelSetting(providerId: string, modelId: string): CcAliasSetting { @@ -91,19 +94,21 @@ export function getCcAliasModelSetting(providerId: string, modelId: string): CcA export function setCcAliasModelSetting( providerId: string, modelId: string, - v: CcAliasSetting + v: CcAliasSetting, ): void { const db = getDbInstance(); const key = modelKey(providerId, modelId); if (v === null) { db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(NAMESPACE, key); + finishModelCatalogWriteWithoutBackup(); return; } db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( NAMESPACE, key, - v + v, ); + finishModelCatalogWriteWithoutBackup(); } /** diff --git a/src/lib/db/featureFlags.ts b/src/lib/db/featureFlags.ts index 724543c354..ea28690498 100644 --- a/src/lib/db/featureFlags.ts +++ b/src/lib/db/featureFlags.ts @@ -8,9 +8,16 @@ import { FEATURE_FLAG_DEFINITIONS } from "@/shared/constants/featureFlagDefinitions"; import { getDbInstance } from "./core"; +import { finishModelCatalogWriteWithoutBackup } from "./models/modelCatalogWriteSignals"; const NAMESPACE = "feature_flags"; +const CATALOG_RELEVANT_FEATURE_FLAGS = new Set([ + "MODEL_CATALOG_INCLUDE_NAMES", + "MODELS_CATALOG_PREFIX_MODE", + "EXPOSE_CC_DISCOVERY_ALIASES", +]); + /** * Returns all feature flag overrides as a key→value map. */ @@ -53,15 +60,18 @@ export function setFeatureFlagOverride(key: string, value: string): void { !definition.enumValues.includes(value) ) { throw new Error( - `Invalid value "${value}" for enum flag ${key}. Allowed: ${definition.enumValues.join(", ")}` + `Invalid value "${value}" for enum flag ${key}. Allowed: ${definition.enumValues.join(", ")}`, ); } const db = getDbInstance(); db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( NAMESPACE, key, - value + value, ); + if (CATALOG_RELEVANT_FEATURE_FLAGS.has(key)) { + finishModelCatalogWriteWithoutBackup(); + } } /** @@ -71,6 +81,9 @@ export function setFeatureFlagOverride(key: string, value: string): void { export function removeFeatureFlagOverride(key: string): void { const db = getDbInstance(); db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(NAMESPACE, key); + if (CATALOG_RELEVANT_FEATURE_FLAGS.has(key)) { + finishModelCatalogWriteWithoutBackup(); + } } /** @@ -78,5 +91,13 @@ export function removeFeatureFlagOverride(key: string): void { */ export function clearAllFeatureFlagOverrides(): void { const db = getDbInstance(); + const hadRelevantOverride = Boolean( + db + .prepare("SELECT 1 FROM key_value WHERE namespace = ? AND key IN (?, ?, ?) LIMIT 1") + .get(NAMESPACE, ...Array.from(CATALOG_RELEVANT_FEATURE_FLAGS)), + ); db.prepare("DELETE FROM key_value WHERE namespace = ?").run(NAMESPACE); + if (hadRelevantOverride) { + finishModelCatalogWriteWithoutBackup(); + } } diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 0f6499d737..4ad886cef1 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -4,17 +4,14 @@ * models/; this file re-exports their public APIs for backward compatibility. */ -import { isRetiredGitHubCopilotModelId } from "@omniroute/open-sse/config/providers/registry/github/retiredModels.ts"; - import { getDbInstance } from "./core"; -import { backupDbFile } from "./backup"; import { getProviderConnectionsCount } from "./providers"; -import { type JsonRecord, getKeyValue } from "./models/shared"; +import { type JsonRecord, asRecord, toNonEmptyString, getKeyValue } from "./models/shared"; import { - normalizeSyncedAvailableModels, - type SyncedAvailableModel, - type SyncedAvailableModelInput, -} from "./models/synced"; + finishSyncedAvailableModelsWrite, + persistCanonicalSyncedAvailableModels, +} from "./models/syncedAvailableModelPersistence"; +import { finishModelCatalogWriteWithBackup } from "./models/modelCatalogWriteSignals"; import { readCompatList, writeCompatList, @@ -47,7 +44,6 @@ export { deleteModelAliasesForProvider, } from "./models/aliases"; export { getMitmAlias, setMitmAliasAll } from "./models/mitmAlias"; -export type { SyncedAvailableModel } from "./models/synced"; // ──────────────── Custom Models ──────────────── @@ -109,7 +105,7 @@ export async function addCustomModel( tokenLimits: { inputTokenLimit?: number; outputTokenLimit?: number } = {}, // #1904: optional manual vision-capability override for the "add custom model" // form — read back by getCustomVisionCapabilityFields() in the /v1/models catalog. - supportsVision?: boolean + supportsVision?: boolean, ) { const db = getDbInstance(); const row = db @@ -138,9 +134,9 @@ export async function addCustomModel( }; models.push(model); db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)" + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)", ).run(providerId, JSON.stringify(models)); - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); return model; } @@ -162,7 +158,7 @@ export async function replaceCustomModels( supportsThinking?: boolean; targetFormat?: string; }>, - { allowEmpty = false }: { allowEmpty?: boolean } = {} + { allowEmpty = false }: { allowEmpty?: boolean } = {}, ) { // Guard: skip destructive clear when the caller hasn't explicitly opted in. // This prevents callers from wiping manually added models when the @@ -235,15 +231,15 @@ export async function replaceCustomModels( if (merged.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run( - providerId + providerId, ); } else { db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)" + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)", ).run(providerId, JSON.stringify(merged)); } - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); return merged; } @@ -273,20 +269,20 @@ export async function deleteImportedCustomModels(providerId: string): Promise - typeof model.id === "string" && model.id ? [model.id] : [] + typeof model.id === "string" && model.id ? [model.id] : [], ); for (const modelId of removedIds) removeModelCompatOverride(providerId, modelId); - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); return removedIds; } @@ -307,17 +303,17 @@ export async function removeCustomModel(providerId: string, modelId: string) { if (filtered.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run( - providerId + providerId, ); } else { db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run( JSON.stringify(filtered), - providerId + providerId, ); } removeModelCompatOverride(providerId, modelId); - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); return true; } @@ -326,12 +322,110 @@ export async function removeCustomModel(providerId: string, modelId: string) { // Each connection stores its own model list. Reads union across all connections // for a provider. Deleting a connection removes only its models. +export interface SyncedAvailableModel { + id: string; + name: string; + source: "imported"; + apiFormat?: string; + targetFormat?: string; + upstreamProtocol?: string; + supportedEndpoints?: string[]; + supportedThinkingEfforts?: string[]; + defaultThinkingEffort?: string; + inputTokenLimit?: number; + outputTokenLimit?: number; + description?: string; + supportsThinking?: boolean; + alwaysThinking?: boolean; + supportsTools?: boolean; + supportsVideo?: boolean; + // #4264: image-input capability captured at sync time (e.g. OpenRouter + // `architecture.input_modalities`/`modality`) so the catalog can surface vision. + supportsVision?: boolean; +} + +type SyncedAvailableModelInput = Omit & { + source?: string; +}; + +function normalizeSyncedAvailableModel(model: unknown): SyncedAvailableModel | null { + const record = asRecord(model); + const id = + toNonEmptyString(record.id) || toNonEmptyString(record.name) || toNonEmptyString(record.model); + if (!id) return null; + + const name = + toNonEmptyString(record.name) || + toNonEmptyString(record.displayName) || + toNonEmptyString(record.model) || + id; + const supportedEndpoints = Array.isArray(record.supportedEndpoints) + ? Array.from( + new Set( + record.supportedEndpoints + .map((endpoint) => toNonEmptyString(endpoint)) + .filter((endpoint): endpoint is string => Boolean(endpoint)), + ), + ).sort() + : undefined; + + return { + id, + name, + source: "imported", + ...(toNonEmptyString(record.apiFormat) + ? { apiFormat: toNonEmptyString(record.apiFormat)! } + : {}), + ...(toNonEmptyString(record.targetFormat) + ? { targetFormat: toNonEmptyString(record.targetFormat)! } + : {}), + ...(toNonEmptyString(record.upstreamProtocol) + ? { upstreamProtocol: toNonEmptyString(record.upstreamProtocol)! } + : {}), + ...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}), + ...(Array.isArray(record.supportedThinkingEfforts) + ? { + supportedThinkingEfforts: record.supportedThinkingEfforts.filter( + (effort): effort is string => typeof effort === "string" && effort.length > 0, + ), + } + : {}), + ...(toNonEmptyString(record.defaultThinkingEffort) + ? { defaultThinkingEffort: toNonEmptyString(record.defaultThinkingEffort)! } + : {}), + ...(typeof record.inputTokenLimit === "number" + ? { inputTokenLimit: record.inputTokenLimit } + : {}), + ...(typeof record.outputTokenLimit === "number" + ? { outputTokenLimit: record.outputTokenLimit } + : {}), + ...(typeof record.description === "string" ? { description: record.description } : {}), + ...(typeof record.supportsThinking === "boolean" + ? { supportsThinking: record.supportsThinking } + : {}), + ...(record.alwaysThinking === true ? { alwaysThinking: true } : {}), + ...(typeof record.supportsTools === "boolean" ? { supportsTools: record.supportsTools } : {}), + ...(typeof record.supportsVideo === "boolean" ? { supportsVideo: record.supportsVideo } : {}), + ...(record.supportsVision === true ? { supportsVision: true } : {}), + }; +} + +function normalizeSyncedAvailableModels(models: unknown): SyncedAvailableModel[] { + if (!Array.isArray(models)) return []; + const deduped = new Map(); + for (const model of models) { + const normalized = normalizeSyncedAvailableModel(model); + if (normalized) deduped.set(normalized.id, normalized); + } + return Array.from(deduped.values()); +} + /** * Get synced available models for a specific provider connection. */ export async function getSyncedAvailableModelsForConnection( providerId: string, - connectionId: string + connectionId: string, ): Promise { const db = getDbInstance(); const key = `${providerId}:${connectionId}`; @@ -342,7 +436,7 @@ export async function getSyncedAvailableModelsForConnection( if (!value) return []; try { const models = JSON.parse(value); - return normalizeSyncedAvailableModels(models, providerId); + return normalizeSyncedAvailableModels(models); } catch { return []; } @@ -352,19 +446,19 @@ export async function getSyncedAvailableModelsForConnection( * Get all synced available models for a provider, unioned across all connections. */ export async function getSyncedAvailableModels( - providerId: string + providerId: string, ): Promise { const db = getDbInstance(); const rows = db .prepare( - "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?" + "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?", ) .all(`${providerId}:%`); const map = new Map(); for (const row of rows) { const { key, value } = getKeyValue(row); if (!key || value === null) continue; - const models = normalizeSyncedAvailableModels(JSON.parse(value), providerId); + const models = normalizeSyncedAvailableModels(JSON.parse(value)); for (const m of models) { if (m.id) map.set(m.id, m); } @@ -376,13 +470,13 @@ export async function getSyncedAvailableModels( * Get synced available models for a provider grouped by connection id. */ export async function getSyncedAvailableModelsByConnection( - providerId: string + providerId: string, ): Promise> { const db = getDbInstance(); const prefix = `${providerId}:`; const rows = db .prepare( - "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?" + "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?", ) .all(`${prefix}%`); const result: Record = {}; @@ -391,7 +485,7 @@ export async function getSyncedAvailableModelsByConnection( if (!key || value === null || !key.startsWith(prefix)) continue; try { const connectionId = key.slice(prefix.length); - result[connectionId] = normalizeSyncedAvailableModels(JSON.parse(value), providerId); + result[connectionId] = normalizeSyncedAvailableModels(JSON.parse(value)); } catch { // Ignore malformed legacy entries. } @@ -416,7 +510,7 @@ export async function getAllSyncedAvailableModels(): Promise< if (!key || value === null) continue; const providerId = key.split(":")[0]; if (!byProvider.has(providerId)) byProvider.set(providerId, new Map()); - const models = normalizeSyncedAvailableModels(JSON.parse(value), providerId); + const models = normalizeSyncedAvailableModels(JSON.parse(value)); const map = byProvider.get(providerId)!; for (const m of models) { if (m.id) map.set(m.id, m); @@ -453,14 +547,13 @@ export async function getActiveProvidersWithSyncedModel(modelId: string): Promis json_extract(synced_model.value, '$.id'), json_extract(synced_model.value, '$.name'), json_extract(synced_model.value, '$.model') - ) = ?` + ) = ?`, ) .all(modelId) as Array<{ provider?: unknown }>; return rows .map((row) => row.provider) - .filter((provider): provider is string => typeof provider === "string" && provider.length > 0) - .filter((provider) => !isRetiredGitHubCopilotModelId(provider, modelId)); + .filter((provider): provider is string => typeof provider === "string" && provider.length > 0); } /** @@ -470,9 +563,8 @@ export async function getActiveProvidersWithSyncedModel(modelId: string): Promis export async function replaceSyncedAvailableModelsForConnection( providerId: string, connectionId: string, - models: SyncedAvailableModelInput[] + models: SyncedAvailableModelInput[], ): Promise { - const db = getDbInstance(); const key = `${providerId}:${connectionId}`; // #3199: drop ids the operator DELETED (trash) so a re-fetch does not re-import // a model that was explicitly removed. @@ -481,19 +573,10 @@ export async function replaceSyncedAvailableModelsForConnection( // the synced store so they remain listed-but-hidden across re-syncs instead of // churning back on through the managed-alias path ("Auto Sync Enabling all // Models"). See getModelIsDeleted for the legacy-row caveat. - const normalizedModels = normalizeSyncedAvailableModels(models, providerId).filter( - (m) => !getModelIsDeleted(providerId, m.id) + const normalizedModels = normalizeSyncedAvailableModels(models).filter( + (m) => !getModelIsDeleted(providerId, m.id), ); - if (normalizedModels.length === 0) { - db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run( - key - ); - } else { - db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)" - ).run(key, JSON.stringify(normalizedModels)); - } - backupDbFile("pre-write"); + persistCanonicalSyncedAvailableModels(key, normalizedModels, normalizeSyncedAvailableModels); // Return the full unioned list for the provider return getSyncedAvailableModels(providerId); } @@ -504,13 +587,13 @@ export async function replaceSyncedAvailableModelsForConnection( */ export async function removeSyncedAvailableModel( providerId: string, - modelId: string + modelId: string, ): Promise { const db = getDbInstance(); const prefix = `${providerId}:`; const rows = db .prepare( - "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?" + "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?", ) .all(`${prefix}%`); @@ -528,26 +611,25 @@ export async function removeSyncedAvailableModel( continue; } - const models = normalizeSyncedAvailableModels(parsedModels, providerId); + const models = normalizeSyncedAvailableModels(parsedModels); const filtered = models.filter((m) => m.id !== modelId); if (filtered.length !== models.length) { removedAny = true; if (filtered.length === 0) { db.prepare( - "DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?" + "DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?", ).run(key); } else { db.prepare( - "UPDATE key_value SET value = ? WHERE namespace = 'syncedAvailableModels' AND key = ?" + "UPDATE key_value SET value = ? WHERE namespace = 'syncedAvailableModels' AND key = ?", ).run(JSON.stringify(filtered), key); } } } - - if (removedAny) backupDbFile("pre-write"); }); removeModel(); + if (removedAny) finishSyncedAvailableModelsWrite(); return removedAny; } @@ -557,14 +639,14 @@ export async function removeSyncedAvailableModel( */ export async function deleteSyncedAvailableModelsForConnection( providerId: string, - connectionId: string + connectionId: string, ): Promise { const db = getDbInstance(); const key = `${providerId}:${connectionId}`; - db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run( - key - ); - backupDbFile("pre-write"); + const result = db + .prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?") + .run(key); + if (result.changes > 0) finishSyncedAvailableModelsWrite(); return getSyncedAvailableModels(providerId); } @@ -574,7 +656,7 @@ export async function deleteSyncedAvailableModelsForConnection( */ export async function cleanupProviderModelsAfterConnectionDelete( providerId: string, - connectionId: string + connectionId: string, ): Promise<{ remainingConnections: number; removedImportedModelIds: string[]; @@ -582,7 +664,7 @@ export async function cleanupProviderModelsAfterConnectionDelete( }> { const remainingSyncedModels = await deleteSyncedAvailableModelsForConnection( providerId, - connectionId + connectionId, ); const remainingConnections = getProviderConnectionsCount({ provider: providerId }); const removedImportedModelIds = @@ -600,11 +682,12 @@ export async function deleteSyncedAvailableModelsForProvider(providerId: string) const keyPrefix = `${providerId}:`; const result = db .prepare( - "DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND substr(key, 1, ?) = ?" + "DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND substr(key, 1, ?) = ?", ) .run(keyPrefix.length, keyPrefix); - backupDbFile("pre-write"); - return Number(result.changes || 0); + const changes = Number(result.changes || 0); + if (changes > 0) finishSyncedAvailableModelsWrite(); + return changes; } /** @@ -613,7 +696,7 @@ export async function deleteSyncedAvailableModelsForProvider(providerId: string) */ export async function pruneStaleSyncedAvailableModelsForProvider( providerId: string, - allowedConnectionIds: string[] + allowedConnectionIds: string[], ): Promise { const db = getDbInstance(); if (allowedConnectionIds.length === 0) { @@ -624,11 +707,12 @@ export async function pruneStaleSyncedAvailableModelsForProvider( const allowedKeys = allowedConnectionIds.map((id) => `${providerId}:${id}`); const result = db .prepare( - `DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ? AND key NOT IN (${placeholders})` + `DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ? AND key NOT IN (${placeholders})`, ) .run(`${keyPrefix}%`, ...allowedKeys); - backupDbFile("pre-write"); - return Number(result.changes || 0); + const changes = Number(result.changes || 0); + if (changes > 0) finishSyncedAvailableModelsWrite(); + return changes; } /** @@ -640,7 +724,7 @@ export async function pruneStaleSyncedAvailableModelsForProvider( function applyTriStateBooleanOverride( next: JsonRecord, updates: Record, - field: string + field: string, ): void { if (!Object.prototype.hasOwnProperty.call(updates, field)) return; if (updates[field] === null) { @@ -653,7 +737,7 @@ function applyTriStateBooleanOverride( export async function updateCustomModel( providerId: string, modelId: string, - updates: Record = {} + updates: Record = {}, ) { const db = getDbInstance(); const row = db @@ -681,7 +765,7 @@ export async function updateCustomModel( currentCompat, updates.compatByProtocol as Partial< Record> - > + >, ); if (!compatByProtocolHasEntries(mergedCompat)) mergedCompat = undefined; } @@ -726,10 +810,10 @@ export async function updateCustomModel( db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run( JSON.stringify(models), - providerId + providerId, ); - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); return next; } @@ -758,7 +842,7 @@ function getCustomModelRow(providerId: string, modelId: string): JsonRecord | nu typeof x === "object" && !Array.isArray(x) && typeof (x as { id?: string }).id === "string" && - ((x as { id: string }).id as string).toLowerCase() === modelId.toLowerCase() + ((x as { id: string }).id as string).toLowerCase() === modelId.toLowerCase(), )) as JsonRecord | undefined; return m ?? null; } catch { @@ -775,7 +859,7 @@ function getCustomModelRow(providerId: string, modelId: string): JsonRecord | nu export function getModelNormalizeToolCallId( providerId: string, modelId: string, - sourceFormat?: string | null + sourceFormat?: string | null, ): boolean { const m = getCustomModelRow(providerId, modelId); const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null; @@ -808,7 +892,7 @@ export function getModelNormalizeToolCallId( export function getModelPreserveOpenAIDeveloperRole( providerId: string, modelId: string, - sourceFormat?: string | null + sourceFormat?: string | null, ): boolean | undefined { const m = getCustomModelRow(providerId, modelId); const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null; @@ -857,45 +941,35 @@ export function getModelIsHidden(providerId: string, modelId: string): boolean { */ export function getHiddenModelsByProvider(): Map> { const db = getDbInstance(); - const visibilityByProvider = new Map>(); + const result = new Map>(); + + // Query all rows from key_value for both namespaces const rows = db .prepare( - "SELECT namespace, key, value FROM key_value WHERE namespace IN ('modelCompatOverrides', 'customModels')" + "SELECT key, value FROM key_value WHERE namespace IN ('modelCompatOverrides', 'customModels')", ) - .all() as Array<{ namespace: string; key: string; value: string | null }>; + .all() as Array<{ key: string; value: string | null }>; - for (const namespace of ["modelCompatOverrides", "customModels"]) { - for (const row of rows) { - if (row.namespace !== namespace || !row.value) continue; - try { - const parsed = JSON.parse(row.value); - if (!Array.isArray(parsed)) continue; - for (const entry of parsed) { - if (!entry || typeof entry !== "object") continue; - const modelId = (entry as { id?: unknown }).id; - if (typeof modelId !== "string" || modelId.length === 0) continue; - if (!Object.prototype.hasOwnProperty.call(entry, "isHidden")) continue; - let visibility = visibilityByProvider.get(row.key); - if (!visibility) { - visibility = new Map(); - visibilityByProvider.set(row.key, visibility); + for (const row of rows) { + if (!row.value) continue; + try { + const parsed = JSON.parse(row.value); + if (!Array.isArray(parsed)) continue; + for (const entry of parsed) { + if (entry && typeof entry === "object" && entry.isHidden) { + const modelId = entry.id; + if (typeof modelId === "string" && modelId.length > 0) { + if (!result.has(row.key)) result.set(row.key, new Set()); + result.get(row.key)!.add(modelId); } - visibility.set(modelId, Boolean((entry as { isHidden?: unknown }).isHidden)); } - } catch { - // Skip malformed entries } + } catch { + // Skip malformed entries } } - return new Map( - [...visibilityByProvider].flatMap(([providerId, visibility]) => { - const hiddenModels = [...visibility].flatMap(([modelId, isHidden]) => - isHidden ? [modelId] : [] - ); - return hiddenModels.length > 0 ? [[providerId, new Set(hiddenModels)] as const] : []; - }) - ); + return result; } /** @@ -960,7 +1034,7 @@ export function setModelIsHidden(providerId: string, modelId: string, hidden: bo function readUpstreamFromJsonRecord( row: JsonRecord | null | undefined, - key: "upstreamHeaders" + key: "upstreamHeaders", ): Record | undefined { if (!row) return undefined; const raw = row[key]; @@ -982,7 +1056,7 @@ function readUpstreamFromJsonRecord( export function getModelUpstreamExtraHeaders( providerId: string, modelId: string, - sourceFormat?: string | null + sourceFormat?: string | null, ): Record { const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null; const m = getCustomModelRow(providerId, modelId); @@ -1009,8 +1083,8 @@ export function getModelUpstreamExtraHeaders( Object.assign( base, sanitizeUpstreamHeadersMap( - co.compatByProtocol[protocol]!.upstreamHeaders as Record - ) + co.compatByProtocol[protocol]!.upstreamHeaders as Record, + ), ); } return base; diff --git a/src/lib/db/models/aliases.ts b/src/lib/db/models/aliases.ts index b88e15d7c1..b28d95e31d 100644 --- a/src/lib/db/models/aliases.ts +++ b/src/lib/db/models/aliases.ts @@ -1,8 +1,8 @@ /** db/models/aliases.ts — model alias CRUD (modelAliases namespace). */ import { getDbInstance } from "../core"; -import { backupDbFile } from "../backup"; import { getKeyValue } from "./shared"; +import { finishModelCatalogWriteWithBackup } from "./modelCatalogWriteSignals"; export async function getModelAliases() { const db = getDbInstance(); @@ -21,15 +21,15 @@ export async function getModelAliases() { export async function setModelAlias(alias: string, model: unknown) { const db = getDbInstance(); db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('modelAliases', ?, ?)" + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('modelAliases', ?, ?)", ).run(alias, JSON.stringify(model)); - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); } export async function deleteModelAlias(alias: string) { const db = getDbInstance(); db.prepare("DELETE FROM key_value WHERE namespace = 'modelAliases' AND key = ?").run(alias); - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); } /** diff --git a/src/lib/db/models/compat.ts b/src/lib/db/models/compat.ts index 54b05ba3a6..eba83f8703 100644 --- a/src/lib/db/models/compat.ts +++ b/src/lib/db/models/compat.ts @@ -1,13 +1,13 @@ /** db/models/compat.ts — model-compat overrides (normalizeToolCallId, per-protocol flags, upstream headers). */ import { getDbInstance } from "../core"; -import { backupDbFile } from "../backup"; import { MODEL_COMPAT_PROTOCOL_KEYS, type ModelCompatProtocolKey, } from "@/shared/constants/modelCompat"; import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders"; import { getKeyValue } from "./shared"; +import { finishModelCatalogWriteWithBackup } from "./modelCatalogWriteSignals"; /** Built-in / alias models: tool-call + developer-role flags without a full custom row */ const MODEL_COMPAT_NAMESPACE = "modelCompatOverrides"; @@ -42,7 +42,7 @@ function isValidUpstreamHeaderName(k: string): boolean { /** Sanitize user-provided upstream header map (used when persisting and when reading for requests). */ export function sanitizeUpstreamHeadersMap( - raw: Record | null | undefined + raw: Record | null | undefined, ): Record { const out: Record = {}; if (!raw || typeof raw !== "object") return out; @@ -66,7 +66,7 @@ export function sanitizeUpstreamHeadersMap( export function deepMergeCompatByProtocol( prev: CompatByProtocolMap | undefined, - patch: Partial>> + patch: Partial>>, ): CompatByProtocolMap { const out: CompatByProtocolMap = { ...(prev || {}) }; for (const key of Object.keys(patch) as ModelCompatProtocolKey[]) { @@ -140,16 +140,16 @@ export function writeCompatList(providerId: string, list: ModelCompatOverride[]) if (list.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run( MODEL_COMPAT_NAMESPACE, - providerId + providerId, ); } else { db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( MODEL_COMPAT_NAMESPACE, providerId, - JSON.stringify(list) + JSON.stringify(list), ); } - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); } export function getModelCompatOverrides(providerId: string): ModelCompatOverride[] { @@ -178,7 +178,7 @@ export function compatByProtocolHasEntries(map: CompatByProtocolMap | undefined) export function mergeModelCompatOverride( providerId: string, modelId: string, - patch: ModelCompatPatch + patch: ModelCompatPatch, ) { const list = readCompatList(providerId); const idx = list.findIndex((e) => e.id === modelId); diff --git a/src/lib/db/models/modelCatalogWriteSignals.ts b/src/lib/db/models/modelCatalogWriteSignals.ts new file mode 100644 index 0000000000..0903839baa --- /dev/null +++ b/src/lib/db/models/modelCatalogWriteSignals.ts @@ -0,0 +1,11 @@ +import { backupDbFile } from "../backup"; +import { invalidateModelCatalogCache } from "../readCache"; + +export function finishModelCatalogWriteWithBackup(): void { + backupDbFile("pre-write"); + invalidateModelCatalogCache(); +} + +export function finishModelCatalogWriteWithoutBackup(): void { + invalidateModelCatalogCache(); +} diff --git a/src/lib/db/models/syncedAvailableModelPersistence.ts b/src/lib/db/models/syncedAvailableModelPersistence.ts new file mode 100644 index 0000000000..7e8d13a142 --- /dev/null +++ b/src/lib/db/models/syncedAvailableModelPersistence.ts @@ -0,0 +1,52 @@ +/** Canonical comparison and write helpers for connection-scoped synced model catalogs. */ + +import { backupDbFile } from "../backup"; +import { getDbInstance } from "../core"; +import { invalidateModelCatalogCache } from "../readCache"; +import { getKeyValue } from "./shared"; + +type ModelNormalizer = (models: unknown) => T[]; + +export function finishSyncedAvailableModelsWrite(): void { + backupDbFile("pre-write"); + invalidateModelCatalogCache(); +} + +export function persistCanonicalSyncedAvailableModels( + key: string, + normalizedModels: T[], + normalizeModels: ModelNormalizer +): boolean { + const db = getDbInstance(); + const existingRow = db + .prepare("SELECT value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?") + .get(key); + const existingValue = getKeyValue(existingRow).value; + let existingModels: T[] | null = null; + if (existingValue !== null) { + try { + existingModels = normalizeModels(JSON.parse(existingValue)); + } catch { + existingModels = null; + } + } + + const unchanged = + (normalizedModels.length > 0 && + existingModels !== null && + JSON.stringify(existingModels) === JSON.stringify(normalizedModels)) || + (normalizedModels.length === 0 && existingValue === null); + if (unchanged) return false; + + if (normalizedModels.length === 0) { + db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run( + key + ); + } else { + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)" + ).run(key, JSON.stringify(normalizedModels)); + } + finishSyncedAvailableModelsWrite(); + return true; +} diff --git a/src/lib/db/quotaGroups.ts b/src/lib/db/quotaGroups.ts index b944828394..f2f21966b9 100644 --- a/src/lib/db/quotaGroups.ts +++ b/src/lib/db/quotaGroups.ts @@ -9,6 +9,7 @@ */ import { getDbInstance } from "./core"; +import { invalidateModelCatalogCache } from "./readCache"; // --------------------------------------------------------------------------- // Types @@ -113,7 +114,11 @@ export function listGroups(): QuotaGroup[] { */ export function renameGroup(id: string, name: string): boolean { const result = getDb().prepare("UPDATE quota_groups SET name = ? WHERE id = ?").run(name, id); - return result.changes > 0; + if (result.changes > 0) { + invalidateModelCatalogCache(); + return true; + } + return false; } /** @@ -130,7 +135,7 @@ export function deleteGroup(id: string): boolean { // Protect the seed group. if (id === "group-demo") { throw new Error( - "Cannot delete the protected seed group 'group-demo'. Reassign its pools to another group first." + "Cannot delete the protected seed group 'group-demo'. Reassign its pools to another group first.", ); } diff --git a/src/lib/db/quotaPools.ts b/src/lib/db/quotaPools.ts index b054f15c75..a26a4bc6b0 100644 --- a/src/lib/db/quotaPools.ts +++ b/src/lib/db/quotaPools.ts @@ -9,6 +9,8 @@ */ import { getDbInstance } from "./core"; +import { clearApiKeyCaches } from "./apiKeys"; +import { invalidateModelCatalogCache } from "./readCache"; // Phase B2: auto-mint/prune quotaShared-* combos when pool allocations change. // Imported lazily (dynamic import in the hook) to avoid circular-dependency // risk between db/ and quota/ modules. The import is fire-and-forget; combo @@ -30,7 +32,7 @@ async function removeQuotaCombosGuarded(poolId: string): Promise { } catch (err) { console.warn( "[quota-pools] removeQuotaCombosForPool failed (non-fatal):", - (err as Error)?.message + (err as Error)?.message, ); } } @@ -125,7 +127,7 @@ function assertSingleProvider(connectionIds: string[]): void { const providers = rows.map((r) => r.provider).filter(Boolean); if (new Set(providers).size > 1) { throw new Error( - `A quota pool must use a single provider (got: ${[...new Set(providers)].join(", ")})` + `A quota pool must use a single provider (got: ${[...new Set(providers)].join(", ")})`, ); } } @@ -179,7 +181,7 @@ interface PoolConnectionRow { function getConnectionIds(poolId: string, fallbackConnectionId: string): string[] { const rows = getDb() .prepare( - "SELECT connection_id FROM quota_pool_connections WHERE pool_id = ? ORDER BY created_at ASC" + "SELECT connection_id FROM quota_pool_connections WHERE pool_id = ? ORDER BY created_at ASC", ) .all(poolId); if (rows.length > 0) { @@ -210,7 +212,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] { // Batch allocations: 1 query for all pools const allocRows = db .prepare( - `SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id IN (${ph})` + `SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id IN (${ph})`, ) .all(...poolIds); const allocsByPool = new Map(); @@ -226,7 +228,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] { // Batch connections: 1 query for all pools const connRows = db .prepare<{ pool_id: string; connection_id: string }>( - `SELECT pool_id, connection_id FROM quota_pool_connections WHERE pool_id IN (${ph}) ORDER BY created_at ASC` + `SELECT pool_id, connection_id FROM quota_pool_connections WHERE pool_id IN (${ph}) ORDER BY created_at ASC`, ) .all(...poolIds); const connsByPool = new Map(); @@ -253,7 +255,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] { function getAllocations(poolId: string): PoolAllocation[] { const rows = getDb() .prepare( - "SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id = ?" + "SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id = ?", ) .all(poolId); return rows.map(rowToAllocation); @@ -324,7 +326,7 @@ export function listPools(options?: { limit?: number; offset?: number }): { export function getPool(id: string): QuotaPool | null { const row = getDb() .prepare( - "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?" + "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?", ) .get(id); if (!row) return null; @@ -358,12 +360,12 @@ export function createPool(input: PoolCreate): QuotaPool { const doCreate = database.transaction(() => { database .prepare( - "INSERT INTO quota_pools (id, connection_id, name, group_id, created_at) VALUES (?, ?, ?, ?, ?)" + "INSERT INTO quota_pools (id, connection_id, name, group_id, created_at) VALUES (?, ?, ?, ?, ?)", ) .run(id, primaryConnectionId, input.name, groupId, now); const insertConn = database.prepare( - "INSERT OR IGNORE INTO quota_pool_connections (pool_id, connection_id) VALUES (?, ?)" + "INSERT OR IGNORE INTO quota_pool_connections (pool_id, connection_id) VALUES (?, ?)", ); for (const connId of members) { insertConn.run(id, connId); @@ -372,7 +374,7 @@ export function createPool(input: PoolCreate): QuotaPool { if (input.allocations && input.allocations.length > 0) { const insertAlloc = database.prepare( `INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy) - VALUES (?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?)`, ); for (const alloc of input.allocations) { insertAlloc.run( @@ -381,7 +383,7 @@ export function createPool(input: PoolCreate): QuotaPool { alloc.weight, alloc.capValue ?? null, alloc.capUnit ?? null, - alloc.policy + alloc.policy, ); } } @@ -396,12 +398,14 @@ export function createPool(input: PoolCreate): QuotaPool { group_id: groupId, created_at: now, }, - getAllocations(id) + getAllocations(id), ); // Phase B2: fire-and-forget combo sync; failures are logged but never thrown. void syncQuotaCombosGuarded(id); + invalidateModelCatalogCache(); + return result; } @@ -415,7 +419,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { const database = getDb(); const existing = database .prepare( - "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?" + "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?", ) .get(id); if (!existing) return null; @@ -441,7 +445,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { // Replace join rows. database.prepare("DELETE FROM quota_pool_connections WHERE pool_id = ?").run(id); const insertConn = database.prepare( - "INSERT OR IGNORE INTO quota_pool_connections (pool_id, connection_id) VALUES (?, ?)" + "INSERT OR IGNORE INTO quota_pool_connections (pool_id, connection_id) VALUES (?, ?)", ); for (const connId of input.connectionIds) { insertConn.run(id, connId); @@ -456,7 +460,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { database.prepare("DELETE FROM quota_allocations WHERE pool_id = ?").run(id); const insertAlloc = database.prepare( `INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy) - VALUES (?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?)`, ); for (const alloc of input.allocations) { insertAlloc.run( @@ -465,7 +469,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { alloc.weight, alloc.capValue ?? null, alloc.capUnit ?? null, - alloc.policy + alloc.policy, ); } } @@ -477,6 +481,8 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { // Phase B2: fire-and-forget combo sync; failures are logged but never thrown. void syncQuotaCombosGuarded(id); + invalidateModelCatalogCache(); + return result; } @@ -500,13 +506,20 @@ export function deletePool(id: string): boolean { (SELECT json_group_array(value) FROM json_each(api_keys.allowed_quotas) WHERE value != ?), '[]') WHERE allowed_quotas IS NOT NULL AND allowed_quotas != '[]' - AND EXISTS (SELECT 1 FROM json_each(api_keys.allowed_quotas) WHERE value = ?)` + AND EXISTS (SELECT 1 FROM json_each(api_keys.allowed_quotas) WHERE value = ?)`, ) .run(id, id); return database.prepare("DELETE FROM quota_pools WHERE id = ?").run(id); }); const result = doDelete(); - return result.changes > 0; + if (result.changes <= 0) return false; + + // Direct rewrite of key permission metadata happens above; clear API-key + // caches so any primed permission entries pick up the new allowed_quotas set. + clearApiKeyCaches(); + invalidateModelCatalogCache(); + + return true; } /** @@ -552,7 +565,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) // without requiring a manual re-save. Persists the normalized weights. const totalWeight = allocations.reduce( (s, a) => s + (Number.isFinite(a.weight) ? a.weight : 0), - 0 + 0, ); const normalizedAllocations = totalWeight === 0 && allocations.length > 0 @@ -563,7 +576,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) // Defensive: fall back to [poolId] (single-pool semantics) if pool not found. const targetPool = database .prepare( - "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?" + "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?", ) .get(poolId); @@ -582,7 +595,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) const doUpsert = database.transaction(() => { const insert = database.prepare( `INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy) - VALUES (?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?)`, ); for (const pid of poolIdsInGroup) { database.prepare("DELETE FROM quota_allocations WHERE pool_id = ?").run(pid); @@ -593,7 +606,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) alloc.weight, alloc.capValue ?? null, alloc.capUnit ?? null, - alloc.policy + alloc.policy, ); } } @@ -610,13 +623,13 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) * Returns pairs of { poolId, allocation }. */ export function listAllocationsForApiKey( - apiKeyId: string + apiKeyId: string, ): Array<{ poolId: string; allocation: PoolAllocation }> { const rows = getDb() .prepare( `SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations - WHERE api_key_id = ?` + WHERE api_key_id = ?`, ) .all(apiKeyId); return rows.map((row) => ({ poolId: row.pool_id, allocation: rowToAllocation(row) })); diff --git a/src/lib/db/readCache.ts b/src/lib/db/readCache.ts index 79fff8c2d2..13dcaaaf85 100644 --- a/src/lib/db/readCache.ts +++ b/src/lib/db/readCache.ts @@ -234,26 +234,29 @@ export function getCombosCacheVersion(): number { // ──────────────── Model Catalog Cache Invalidation Signal ──────────────── // -// #6408 added a request-shape-keyed (prefix/isCodex/apiKey) TTL cache around the -// unified /v1/models builder (src/app/api/v1/models/catalog.ts) to coalesce -// concurrent/bursty GETs. That cache key does not vary with the underlying DB -// state the builder reads (connections, settings, combos), so a write followed by -// a read within the ~1.5s TTL replayed the pre-write response. Same import-cycle +// #6408 added a request-shape-keyed (prefix/isCodex/apiKey/configuredOnly) TTL +// cache around the unified /v1/models builder (src/app/api/v1/models/catalog.ts) +// to coalesce concurrent/bursty GETs. That cache key does not vary with the +// underlying DB state the builder reads (connections, settings, combos), so +// writes need an explicit invalidation signal. Same import-cycle // constraint as combosCacheVersion above (a db module must not import the route -// module) — catalog.ts instead compares this version on every access and drops its -// whole cache the moment it moves, so any write that calls invalidateDbCache() makes -// the next read miss immediately instead of waiting out the TTL. +// module): catalogCache.ts compares this version on every access and builder +// completion, then hard-invalidates snapshots and old-generation work when it moves. let modelCatalogCacheVersion = 0; /** - * Current model-catalog-cache version. `getUnifiedModelsResponse()` folds this - * into its response cache key; a change means settings/connections/combos were - * written since the cache was populated and the cached body is stale. + * Current model-catalog-cache version. A change means catalog-backed state was + * written and the next read must synchronously build the new generation. */ export function getModelCatalogCacheVersion(): number { return modelCatalogCacheVersion; } +/** Invalidate only the unified model catalog response cache. */ +export function invalidateModelCatalogCache(): void { + modelCatalogCacheVersion++; +} + /** * Invalidate caches (call after writes to any of: settings, pricing, * connections, combos, nodes). diff --git a/tests/integration/v1-models-swr-response-flush-8728.test.ts b/tests/integration/v1-models-swr-response-flush-8728.test.ts new file mode 100644 index 0000000000..60aa72c894 --- /dev/null +++ b/tests/integration/v1-models-swr-response-flush-8728.test.ts @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const CACHE_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-swr-cache-8728-")); +process.env.DATA_DIR = CACHE_DATA_DIR; + +const catalogCache = await import("../../src/app/api/v1/models/catalogCache.ts"); +const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); +const BLOCK_MS = 300; + +function listen(server: http.Server, socketPath: string): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); +} + +function close(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +function fetchFromExternalClient( + socketPath: string +): Promise<{ body: string; receivedAt: number }> { + const script = [ + 'import http from "node:http";', + "const chunks = [];", + "const request = http.request({ socketPath: process.argv[1], path: '/v1/models' }, (response) => {", + " response.on('data', (chunk) => chunks.push(chunk));", + " response.on('end', () => {", + " const body = Buffer.concat(chunks).toString('utf8');", + " process.stdout.write(JSON.stringify({ body, receivedAt: Date.now() }));", + " });", + "});", + "request.on('error', (error) => { throw error; });", + "request.end();", + ].join("\n"); + const child = spawn(process.execPath, ["--input-type=module", "-e", script, socketPath], { + stdio: ["ignore", "pipe", "pipe"], + }); + + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.once("error", reject); + child.once("exit", (code) => { + if (code !== 0) { + reject(new Error(`external fetch exited ${code}: ${stderr}`)); + return; + } + resolve(JSON.parse(stdout)); + }); + }); +} + +function productionShapedSynchronousRefresh() { + const models = Array.from({ length: 4_000 }, (_, index) => ({ + id: `provider/model-${index}`, + object: "model", + owned_by: "provider", + display_name: `Model ${index}`, + })); + const startedAt = Date.now(); + do { + JSON.stringify({ object: "list", data: models }); + } while (Date.now() - startedAt < BLOCK_MS); +} + +test.after(() => { + fs.rmSync(CACHE_DATA_DIR, { recursive: true, force: true }); +}); + +test("the /v1/models route wires Next after() as its response-flush-safe scheduler", () => { + const routeSource = fs.readFileSync( + path.join(REPO_ROOT, "src/app/api/v1/models/route.ts"), + "utf8" + ); + const cacheSource = fs.readFileSync( + path.join(REPO_ROOT, "src/app/api/v1/models/catalogCache.ts"), + "utf8" + ); + assert.match(routeSource, /import\s+\{\s*after\s*\}\s+from\s+["']next\/server["']/); + assert.match(routeSource, /scheduleBackgroundRefresh:\s*\(task\)\s*=>\s*after\(task\)/); + assert.match(cacheSource, /import\s+\{\s*after\s*\}\s+from\s+["']next\/server["']/); + assert.match(cacheSource, /function defaultBackgroundRefreshScheduler[\s\S]*?after\(task\)/); +}); + +test("an external client receives the stale body before synchronous refresh finishes blocking", async (t) => { + catalogCache.__resetCatalogBuilderRunsForTest(); + const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-swr-http-8728-")); + const socketPath = path.join(socketDir, "catalog.sock"); + + let buildCount = 0; + let responseFinishedAt = 0; + let refreshStartedAt = 0; + let refreshFinishedAt = 0; + let scheduledCount = 0; + + const server = http.createServer(async (incoming, outgoing) => { + const url = `http://127.0.0.1${incoming.url || "/"}`; + const response = await catalogCache.resolveCachedCatalogResponse( + new Request(url), + { corsHeaders: {}, diagnosticHeaders: {} }, + async () => { + buildCount++; + if (buildCount > 1) { + refreshStartedAt = Date.now(); + productionShapedSynchronousRefresh(); + refreshFinishedAt = Date.now(); + } + return { + body: buildCount > 1 ? "new" : "old", + headers: { "content-type": "text/plain" }, + status: 200, + cacheTTL: 60_000, + }; + }, + { + getStaleWhileRevalidateMs: () => Number.POSITIVE_INFINITY, + scheduleBackgroundRefresh: (task) => { + scheduledCount++; + outgoing.once("finish", () => { + responseFinishedAt = Date.now(); + setImmediate(() => { + void task(); + }); + }); + }, + } + ); + + outgoing.writeHead(response.status, Object.fromEntries(response.headers.entries())); + outgoing.end(await response.text()); + }); + + try { + await listen(server, socketPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") { + t.skip("sandbox does not permit opening HTTP listener sockets"); + fs.rmSync(socketDir, { recursive: true, force: true }); + return; + } + throw error; + } + try { + assert.equal((await fetchFromExternalClient(socketPath)).body, "old"); + catalogCache.__expireCatalogCacheForTest(); + + const stale = await fetchFromExternalClient(socketPath); + await catalogCache.__flushCatalogBackgroundRefreshForTest(); + + assert.equal(stale.body, "old"); + assert.equal(scheduledCount, 1); + assert.ok(refreshStartedAt >= responseFinishedAt, "refresh must start after response finish"); + assert.ok( + stale.receivedAt < refreshFinishedAt, + `external client received stale body at ${stale.receivedAt}, after refresh finished at ${refreshFinishedAt}` + ); + assert.ok( + refreshFinishedAt - refreshStartedAt >= BLOCK_MS, + "refresh did not exercise the synchronous blocking window" + ); + } finally { + await close(server); + fs.rmSync(socketDir, { recursive: true, force: true }); + catalogCache.__resetCatalogBuilderRunsForTest(); + } +}); diff --git a/tests/unit/db-synced-model-catalog-invalidation-8728.test.ts b/tests/unit/db-synced-model-catalog-invalidation-8728.test.ts new file mode 100644 index 0000000000..f013d530ff --- /dev/null +++ b/tests/unit/db-synced-model-catalog-invalidation-8728.test.ts @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-model-cache-8728-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const models = await import("../../src/lib/db/models.ts"); +const readCache = await import("../../src/lib/db/readCache.ts"); + +function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function catalogVersion() { + return readCache.getModelCatalogCacheVersion(); +} + +function totalChanges() { + return Number( + ( + core.getDbInstance().prepare("SELECT total_changes() AS count").get() as { + count: number; + } + ).count + ); +} + +function seedSynced(providerId: string, connectionId: string, value: unknown) { + core + .getDbInstance() + .prepare("INSERT INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)") + .run(`${providerId}:${connectionId}`, JSON.stringify(value)); +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("replace invalidates only for canonical persisted changes", async () => { + const initialVersion = catalogVersion(); + + await models.replaceSyncedAvailableModelsForConnection("openai", "a", [ + { + id: "gpt-b", + name: " GPT B ", + source: "ignored", + supportedEndpoints: ["responses", "chat", "chat"], + }, + { id: "gpt-a", name: "GPT A" }, + ]); + assert.equal(catalogVersion(), initialVersion + 1); + + const beforeIdenticalChanges = totalChanges(); + const beforeIdenticalVersion = catalogVersion(); + await models.replaceSyncedAvailableModelsForConnection("openai", "a", [ + { + id: "gpt-b", + name: "GPT B", + source: "imported", + supportedEndpoints: ["chat", "responses"], + }, + { id: "gpt-a", name: "GPT A", source: "imported" }, + ]); + assert.equal(totalChanges(), beforeIdenticalChanges, "canonical equality must skip the DB write"); + assert.equal(catalogVersion(), beforeIdenticalVersion, "canonical equality must not invalidate"); + + await models.replaceSyncedAvailableModelsForConnection("openai", "a", [ + { id: "gpt-a", name: "GPT A" }, + { id: "gpt-b", name: "GPT B", supportedEndpoints: ["chat", "responses"] }, + ]); + assert.equal(catalogVersion(), beforeIdenticalVersion + 1, "ordering changes are persisted"); + + await models.replaceSyncedAvailableModelsForConnection("openai", "a", [ + { id: "gpt-a", name: "GPT A renamed" }, + { id: "gpt-b", name: "GPT B", supportedEndpoints: ["chat", "responses"] }, + ]); + assert.equal(catalogVersion(), beforeIdenticalVersion + 2, "content changes are persisted"); +}); + +test("replace handles empty and deleted-model normalization without phantom writes", async () => { + const startVersion = catalogVersion(); + const startChanges = totalChanges(); + + await models.replaceSyncedAvailableModelsForConnection("openai", "absent", []); + assert.equal(totalChanges(), startChanges, "absent-to-empty replacement must be a no-op"); + assert.equal(catalogVersion(), startVersion); + + models.mergeModelCompatOverride("openai", "trashed", { + isDeleted: true, + isHidden: true, + }); + + const beforeDeletedAbsent = totalChanges(); + const beforeDeletedVersion = catalogVersion(); + await models.replaceSyncedAvailableModelsForConnection("openai", "absent", [ + { id: "trashed", name: "Trashed" }, + ]); + assert.equal(totalChanges(), beforeDeletedAbsent, "filtered deleted models must stay absent"); + assert.equal(catalogVersion(), beforeDeletedVersion); + + seedSynced("openai", "present", [{ id: "existing", name: "Existing" }]); + const beforeDeleteVersion = catalogVersion(); + await models.replaceSyncedAvailableModelsForConnection("openai", "present", []); + assert.equal(catalogVersion(), beforeDeleteVersion + 1, "present-to-empty must invalidate"); + assert.deepEqual(await models.getSyncedAvailableModelsForConnection("openai", "present"), []); +}); + +test("remove and connection/provider deletes invalidate only when rows actually change", async () => { + let version = catalogVersion(); + assert.equal(await models.removeSyncedAvailableModel("openai", "missing"), false); + assert.equal(catalogVersion(), version); + + seedSynced("openai", "a", [ + { id: "gpt-a", name: "GPT A" }, + { id: "gpt-b", name: "GPT B" }, + ]); + assert.equal(await models.removeSyncedAvailableModel("openai", "gpt-a"), true); + assert.equal(catalogVersion(), ++version); + assert.equal(await models.removeSyncedAvailableModel("openai", "gpt-a"), false); + assert.equal(catalogVersion(), version); + + await models.deleteSyncedAvailableModelsForConnection("openai", "missing"); + assert.equal(catalogVersion(), version); + await models.deleteSyncedAvailableModelsForConnection("openai", "a"); + assert.equal(catalogVersion(), ++version); + + assert.equal(await models.deleteSyncedAvailableModelsForProvider("openai"), 0); + assert.equal(catalogVersion(), version); + seedSynced("openai", "b", [{ id: "gpt-b", name: "GPT B" }]); + seedSynced("openai", "c", [{ id: "gpt-c", name: "GPT C" }]); + assert.equal(await models.deleteSyncedAvailableModelsForProvider("openai"), 2); + assert.equal(catalogVersion(), ++version); +}); + +test("prune uses affected rows and the delegated provider delete invalidates exactly once", async () => { + seedSynced("openai", "keep", [{ id: "gpt-a", name: "GPT A" }]); + seedSynced("openai", "stale", [{ id: "gpt-b", name: "GPT B" }]); + let version = catalogVersion(); + + assert.equal( + await models.pruneStaleSyncedAvailableModelsForProvider("openai", ["keep", "stale"]), + 0 + ); + assert.equal(catalogVersion(), version); + + assert.equal(await models.pruneStaleSyncedAvailableModelsForProvider("openai", ["keep"]), 1); + assert.equal(catalogVersion(), ++version); + + assert.equal(await models.pruneStaleSyncedAvailableModelsForProvider("openai", []), 1); + assert.equal(catalogVersion(), ++version, "delegated provider delete must invalidate once"); + + assert.equal(await models.pruneStaleSyncedAvailableModelsForProvider("openai", []), 0); + assert.equal(catalogVersion(), version); +}); diff --git a/tests/unit/model-catalog-cache-swr-8728.test.ts b/tests/unit/model-catalog-cache-swr-8728.test.ts new file mode 100644 index 0000000000..ffef72a217 --- /dev/null +++ b/tests/unit/model-catalog-cache-swr-8728.test.ts @@ -0,0 +1,203 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-catalog-cache-8728-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const readCache = await import("../../src/lib/db/readCache.ts"); +const catalogCache = await import("../../src/app/api/v1/models/catalogCache.ts"); + +type RefreshTask = () => Promise; + +function request() { + return new Request("http://localhost/v1/models"); +} + +function payload(body: string, status = 200): catalogCache.CatalogPayload { + return { + body, + headers: { "content-type": "application/json" }, + status, + cacheTTL: 60_000, + }; +} + +function createPolicyQueue() { + const tasks: RefreshTask[] = []; + return { + policy: { + getStaleWhileRevalidateMs: () => Number.POSITIVE_INFINITY, + scheduleBackgroundRefresh: (task: RefreshTask) => { + tasks.push(task); + }, + }, + tasks, + }; +} + +async function resolve( + build: (request: Request) => Promise, + policy = createPolicyQueue().policy +) { + return catalogCache.resolveCachedCatalogResponse( + request(), + { corsHeaders: {}, diagnosticHeaders: {} }, + build, + policy + ); +} + +test.beforeEach(() => { + catalogCache.__resetCatalogBuilderRunsForTest(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("production SWR policy is unbounded and reset restores the default accessor", () => { + assert.equal(catalogCache.CATALOG_STALE_WHILE_REVALIDATE_MS, Number.POSITIVE_INFINITY); + assert.equal(catalogCache.getCatalogStaleWhileRevalidateMs(), Number.POSITIVE_INFINITY); + + catalogCache.__setCatalogStaleWhileRevalidateAccessorForTest(() => 0); + assert.equal(catalogCache.getCatalogStaleWhileRevalidateMs(), 0); + + catalogCache.__resetCatalogBuilderRunsForTest(); + assert.equal(catalogCache.getCatalogStaleWhileRevalidateMs(), Number.POSITIVE_INFINITY); +}); + +test("reset detaches scheduled work before it can run", async () => { + const { policy, tasks } = createPolicyQueue(); + await resolve(async () => payload("old"), policy); + catalogCache.__expireCatalogCacheForTest(); + await resolve(async () => payload("detached"), policy); + assert.equal(tasks.length, 1); + + catalogCache.__resetCatalogBuilderRunsForTest(); + await tasks[0](); + + assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 0); +}); + +test("ordinary TTL expiry serves the last success indefinitely and schedules one refresh per key", async () => { + const { policy, tasks } = createPolicyQueue(); + const initial = await resolve(async () => payload("old"), policy); + assert.equal(await initial.text(), "old"); + catalogCache.__expireCatalogCacheForTest(7 * 24 * 60 * 60 * 1000); + + const staleResponses = await Promise.all( + Array.from({ length: 5 }, () => resolve(async () => payload("new"), policy)) + ); + + assert.deepEqual( + await Promise.all(staleResponses.map((response) => response.text())), + Array(5).fill("old") + ); + assert.equal(tasks.length, 1, "concurrent stale reads must schedule exactly one refresh"); + assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 1); + + await tasks[0](); + + const refreshed = await resolve(async () => payload("unexpected"), policy); + assert.equal(await refreshed.text(), "new"); + assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 2); +}); + +test("unsuccessful cold payloads are returned but never cached", async () => { + const first = await resolve(async () => payload("temporary failure", 503)); + assert.equal(first.status, 503); + assert.equal(await first.text(), "temporary failure"); + + const second = await resolve(async () => payload("recovered")); + assert.equal(second.status, 200); + assert.equal(await second.text(), "recovered"); + assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 2); +}); + +test("failed background refresh retains the prior successful snapshot and permits retry", async (t) => { + t.mock.method(console, "error", () => {}); + const { policy, tasks } = createPolicyQueue(); + assert.equal(await (await resolve(async () => payload("old"), policy)).text(), "old"); + catalogCache.__expireCatalogCacheForTest(); + + assert.equal( + await ( + await resolve(async () => { + throw new Error("temporary failure"); + }, policy) + ).text(), + "old" + ); + await tasks.shift()!(); + + assert.equal( + await (await resolve(async () => payload("temporary failure", 503), policy)).text(), + "old" + ); + assert.equal(tasks.length, 1, "a failed refresh must release single-flight state for retry"); + await tasks.shift()!(); + + assert.equal(await (await resolve(async () => payload("new"), policy)).text(), "old"); + assert.equal(tasks.length, 1, "an unsuccessful payload must also permit another refresh"); + await tasks.shift()!(); + + assert.equal(await (await resolve(async () => payload("unused"), policy)).text(), "new"); +}); + +test("hard invalidation drops snapshots, detaches old work, and guards old-generation writeback", async () => { + let resolveOld!: (value: catalogCache.CatalogPayload) => void; + const oldPayload = new Promise((resolvePromise) => { + resolveOld = resolvePromise; + }); + let currentBuildStarted = false; + let resolveCurrent!: (value: catalogCache.CatalogPayload) => void; + const currentPayload = new Promise((resolvePromise) => { + resolveCurrent = resolvePromise; + }); + + const oldRequest = resolve(async () => oldPayload); + await Promise.resolve(); + + readCache.invalidateModelCatalogCache(); + const currentRequest = resolve(async () => { + currentBuildStarted = true; + return currentPayload; + }); + await Promise.resolve(); + + assert.equal(currentBuildStarted, true, "the first post-write read must start a current build"); + + resolveCurrent(payload("current")); + assert.equal(await (await currentRequest).text(), "current"); + + resolveOld(payload("old")); + assert.equal(await (await oldRequest).text(), "old"); + + const cached = await resolve(async () => payload("unexpected")); + assert.equal(await cached.text(), "current", "old completion must not overwrite current cache"); + assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 2); +}); + +test("hard invalidation clears a completed snapshot and makes the next read block", async () => { + assert.equal(await (await resolve(async () => payload("old"))).text(), "old"); + readCache.invalidateModelCatalogCache(); + + let resolveCurrent!: (value: catalogCache.CatalogPayload) => void; + const currentPayload = new Promise((resolvePromise) => { + resolveCurrent = resolvePromise; + }); + let settled = false; + const next = resolve(async () => currentPayload).then((response) => { + settled = true; + return response; + }); + + await Promise.resolve(); + assert.equal(settled, false, "post-write reads may block and must not serve the old snapshot"); + + resolveCurrent(payload("current")); + assert.equal(await (await next).text(), "current"); +}); diff --git a/tests/unit/model-catalog-policy-invalidation-8728.test.ts b/tests/unit/model-catalog-policy-invalidation-8728.test.ts new file mode 100644 index 0000000000..4c9c7e2744 --- /dev/null +++ b/tests/unit/model-catalog-policy-invalidation-8728.test.ts @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-model-catalog-policy-8728-"), +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "model-catalog-policy-invalidation-8728"; + +const core = await import("../../src/lib/db/core.ts"); +const readCache = await import("../../src/lib/db/readCache.ts"); +const apiKeys = await import("../../src/lib/db/apiKeys.ts"); +const apiKeyGroups = await import("../../src/lib/db/apiKeyGroups.ts"); +const models = await import("../../src/lib/db/models.ts"); +const quotaPools = await import("../../src/lib/db/quotaPools.ts"); +const quotaGroups = await import("../../src/lib/db/quotaGroups.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeys.resetApiKeyState(); + + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function catalogVersion() { + return readCache.getModelCatalogCacheVersion(); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + apiKeys.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("updateApiKeyPermissions increments only on catalog-affecting fields", async () => { + const created = await apiKeys.createApiKey("Cache Policy Key", "machine-cpi-1"); + let version = catalogVersion(); + + assert.equal(await apiKeys.updateApiKeyPermissions(created.id, { isActive: false }), true); + assert.equal(catalogVersion(), version, "isActive does not invalidate model-catalog cache"); + + assert.equal(await apiKeys.updateApiKeyPermissions(created.id, {}), false); + assert.equal(catalogVersion(), version, "no-op update does not invalidate model-catalog cache"); + + await apiKeys.updateApiKeyPermissions(created.id, { allowedModels: ["openai/*"] }); + assert.equal(catalogVersion(), ++version); + + await apiKeys.updateApiKeyPermissions(created.id, { blockedModels: ["openai/sandbox/*"] }); + assert.equal(catalogVersion(), ++version); + + await apiKeys.updateApiKeyPermissions(created.id, { allowedQuotas: ["q-1", "q-2"] }); + assert.equal(catalogVersion(), ++version); + + await apiKeys.updateApiKeyPermissions(created.id, { disableNonPublicModels: true }); + assert.equal(catalogVersion(), ++version); +}); + +test("isModelAllowedForKey cache recomputes when group permissions change", async () => { + const key = await apiKeys.createApiKey("Group Visibility Key", "machine-cpi-2"); + const modelId = "openai/gpt-4o-mini"; + await apiKeys.updateApiKeyPermissions(key.id, { allowedModels: ["openai/*"] }); + + assert.equal(await apiKeys.isModelAllowedForKey(key.key, modelId), true); + + const group = apiKeyGroups.createKeyGroup("Model deny", "denies openai"); + apiKeyGroups.addGroupPermission(group.id, "openai/*", "deny"); + assert.equal(apiKeyGroups.addKeyToGroup(key.id, group.id), true); + + assert.equal(await apiKeys.isModelAllowedForKey(key.key, modelId), false); + assert.ok(catalogVersion() > 0, "group visibility change increments model catalog generation"); +}); + +test("isModelAllowedForKey cache recomputes after custom model visibility changes", async () => { + const key = await apiKeys.createApiKey("Custom Visibility Key", "machine-cpi-3"); + await apiKeys.updateApiKeyPermissions(key.id, { + allowedModels: ["openai/*"], + disableNonPublicModels: true, + }); + + const modelId = "openai/catalog-cache-repro"; + await models.addCustomModel( + "openai", + "catalog-cache-repro", + "Catalog cache repro", + "manual", + "chat-completions", + ["chat"], + ); + + assert.equal(await apiKeys.isModelAllowedForKey(key.key, modelId), true); + + models.setModelIsHidden("openai", "catalog-cache-repro", true); + assert.equal(await apiKeys.isModelAllowedForKey(key.key, modelId), false); +}); + +test("API-key group membership only invalidates model catalog on real membership mutations", async () => { + const key = await apiKeys.createApiKey("Group Membership Key", "machine-cpi-5"); + const group = apiKeyGroups.createKeyGroup("Model deny", "denies openai"); + let version = catalogVersion(); + + assert.equal(apiKeyGroups.addKeyToGroup(key.id, group.id), true); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + assert.equal(apiKeyGroups.addKeyToGroup(key.id, group.id), true); + assert.equal(catalogVersion(), version); + + assert.equal(apiKeyGroups.removeKeyFromGroup(key.id, group.id), true); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + assert.equal(apiKeyGroups.removeKeyFromGroup(key.id, group.id), false); + assert.equal(catalogVersion(), version); +}); + +test("quota pools and quota-group renames signal model-catalog invalidation as expected", async () => { + let version = catalogVersion(); + const pool = quotaPools.createPool({ + connectionId: "conn-quota", + name: "Quota Pool", + groupId: "group-demo", + }); + assert.equal(catalogVersion(), version + 1); + + const group = quotaGroups.createGroup("Quota Group"); + assert.equal(catalogVersion(), version + 1, "creating quota groups does not invalidate catalog"); + + version = catalogVersion(); + const renamed = quotaGroups.renameGroup(group.id, "Renamed Quota Group"); + assert.equal(renamed, true); + assert.equal(catalogVersion(), version + 1, "quota-group rename invalidates catalog"); + + version = catalogVersion(); + assert.notEqual(quotaPools.updatePool(pool.id, { name: "Quota Pool Updated" }), null); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + assert.equal(quotaPools.deletePool(pool.id), true); + assert.equal(catalogVersion(), version + 1); +}); + +test("deletePool clears primed key metadata after allowed_quotas rewrite", async () => { + const pool = quotaPools.createPool({ connectionId: "conn-meta", name: "Meta Pool" }); + const key = await apiKeys.createApiKey("Pool Metadata Key", "machine-cpi-4"); + + await apiKeys.updateApiKeyPermissions(key.id, { allowedQuotas: [pool.id] }); + const before = await apiKeys.getApiKeyMetadata(key.key); + assert.deepEqual(before?.allowedQuotas, [pool.id]); + + assert.equal(quotaPools.deletePool(pool.id), true); + + const after = await apiKeys.getApiKeyMetadata(key.key); + assert.deepEqual(after?.allowedQuotas, [], "allowed_quotas cache must reflect direct rewrite"); +}); diff --git a/tests/unit/model-catalog-source-invalidation-8728.test.ts b/tests/unit/model-catalog-source-invalidation-8728.test.ts new file mode 100644 index 0000000000..50dfa86eff --- /dev/null +++ b/tests/unit/model-catalog-source-invalidation-8728.test.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-model-catalog-sources-8728-"), +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const models = await import("../../src/lib/db/models.ts"); +const aliases = await import("../../src/lib/db/models/aliases.ts"); +const compat = await import("../../src/lib/db/models/compat.ts"); +const ccAliases = await import("../../src/lib/db/ccDiscoveryAliases.ts"); +const featureFlags = await import("../../src/lib/db/featureFlags.ts"); +const readCache = await import("../../src/lib/db/readCache.ts"); +const openRouterCatalog = await import("../../src/lib/catalog/openrouterCatalog.ts"); + +function resetStorage() { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function catalogVersion() { + return readCache.getModelCatalogCacheVersion(); +} + +const REAL_FETCH = globalThis.fetch; + +function installMockOpenRouterFetch(payload: { data: Array> }): void { + globalThis.fetch = (async () => + new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; +} + +function installFailingOpenRouterFetch(): void { + globalThis.fetch = (async () => { + throw new Error("simulated openrouter failure"); + }) as typeof fetch; +} + +function restoreRealFetch(): void { + globalThis.fetch = REAL_FETCH; +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + restoreRealFetch(); +}); + +test("custom-model source writes invalidate model-catalog cache version; no-op writes do not", async () => { + let version = catalogVersion(); + + await models.addCustomModel("openai", "manual-one"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + const duplicate = await models.addCustomModel("openai", "manual-one"); + assert.equal(duplicate.id, "manual-one"); + assert.equal(catalogVersion(), version); + + await models.replaceCustomModels("openai", [ + { id: "manual-one", source: "manual", apiFormat: "chat-completions" }, + { id: "imported-one", source: "auto-sync", apiFormat: "chat-completions" }, + ]); + version = version + 1; + assert.equal(catalogVersion(), version); + + const removedIds = await models.deleteImportedCustomModels("openai"); + assert.deepEqual(removedIds, ["imported-one"]); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + const removedExisting = await models.removeCustomModel("openai", "manual-one"); + assert.equal(removedExisting, true); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + const removedMissing = await models.removeCustomModel("openai", "missing"); + assert.equal(removedMissing, false); + assert.equal(catalogVersion(), version); + + const updatedMissing = await models.updateCustomModel("openai", "missing", { + modelName: "Missing", + }); + assert.equal(updatedMissing, null); + assert.equal(catalogVersion(), version); + + const noImported = await models.deleteImportedCustomModels("openai"); + assert.deepEqual(noImported, []); + assert.equal(catalogVersion(), version); + + await models.addCustomModel("openai", "manual-two", "manual two"); + version = catalogVersion(); + const updateResult = await models.updateCustomModel("openai", "manual-two", { + modelName: "Manual Two", + }); + assert.notEqual(updateResult, null); + assert.equal(catalogVersion(), version + 1); + + const noTarget = await models.updateCustomModel("openai", "missing", { modelName: "Missing" }); + assert.equal(noTarget, null); + assert.equal(catalogVersion(), version + 1); +}); + +test("model alias and compat writes invalidate model-catalog cache version", async () => { + let version = catalogVersion(); + + await aliases.setModelAlias("alias-alpha", "openai/manual-one"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + await aliases.deleteModelAlias("alias-alpha"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + compat.writeCompatList("openai", [ + { + id: "manual-two", + normalizeToolCallId: true, + }, + ]); + assert.equal(catalogVersion(), version + 1); +}); + +test("Claude Code discovery alias setters invalidate on both write and inherit/delete", () => { + let version = catalogVersion(); + + ccAliases.setCcAliasProviderSetting("openai", "on"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + ccAliases.setCcAliasProviderSetting("openai", null); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + ccAliases.setCcAliasModelSetting("openai", "gpt-4", "on"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + ccAliases.setCcAliasModelSetting("openai", "gpt-4", null); + assert.equal(catalogVersion(), version + 1); +}); + +test("feature flag writes invalidate catalog version only for catalog-relevant overrides", async () => { + const unrelatedBefore = catalogVersion(); + featureFlags.setFeatureFlagOverride("ARENA_ELO_SYNC_ENABLED", "true"); + assert.equal(catalogVersion(), unrelatedBefore); + + let version = catalogVersion(); + featureFlags.setFeatureFlagOverride("MODEL_CATALOG_INCLUDE_NAMES", "false"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + featureFlags.setFeatureFlagOverride("MODELS_CATALOG_PREFIX_MODE", "alias"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + featureFlags.setFeatureFlagOverride("EXPOSE_CC_DISCOVERY_ALIASES", "true"); + assert.equal(catalogVersion(), version + 1); + + const relevantKeyBeforeRemove = catalogVersion(); + featureFlags.removeFeatureFlagOverride("MODEL_CATALOG_INCLUDE_NAMES"); + assert.equal(catalogVersion(), relevantKeyBeforeRemove + 1); + + featureFlags.setFeatureFlagOverride("MODELS_CATALOG_PREFIX_MODE", "dual"); + const irrelevantClearVersion = catalogVersion(); + featureFlags.setFeatureFlagOverride("ARENA_ELO_SYNC_ENABLED", "true"); + featureFlags.clearAllFeatureFlagOverrides(); + assert.equal(catalogVersion(), irrelevantClearVersion + 1); + + const clearNoRelevantBefore = catalogVersion(); + featureFlags.clearAllFeatureFlagOverrides(); + assert.equal(catalogVersion(), clearNoRelevantBefore); +}); + +test("refreshOpenRouterCatalog invalidates only on success", async () => { + installMockOpenRouterFetch({ data: [{ id: "openrouter/fake", source: "test" }] }); + try { + const beforeGet = catalogVersion(); + await openRouterCatalog.getOpenRouterCatalog(); + assert.equal( + catalogVersion(), + beforeGet, + "ordinary get should not invalidate the model-catalog cache", + ); + + const beforeRefreshSuccess = catalogVersion(); + const success = await openRouterCatalog.refreshOpenRouterCatalog(); + assert.equal(success.ok, true); + assert.equal(catalogVersion(), beforeRefreshSuccess + 1); + + installFailingOpenRouterFetch(); + const beforeRefreshFailure = catalogVersion(); + const failed = await openRouterCatalog.refreshOpenRouterCatalog(); + assert.equal(failed.ok, false); + assert.equal(catalogVersion(), beforeRefreshFailure, "failed refresh should not invalidate"); + } finally { + restoreRealFetch(); + } +}); diff --git a/tests/unit/quota-exclusive-catalog-short-circuit.test.ts b/tests/unit/quota-exclusive-catalog-short-circuit.test.ts index c3710a1cff..f6b786d643 100644 --- a/tests/unit/quota-exclusive-catalog-short-circuit.test.ts +++ b/tests/unit/quota-exclusive-catalog-short-circuit.test.ts @@ -134,9 +134,8 @@ await test("chave quota-exclusive não constrói o catálogo completo", async (t process.env.EXPOSE_CC_DISCOVERY_ALIASES = "1"; // A chave do cache é `prefix|isCodex|apiKey|configuredOnly` — um query param // qualquer NÃO a invalida, então a resposta do subteste anterior seria servida. - v1ModelsCatalog.__expireCatalogCacheForTest( - v1ModelsCatalog.CATALOG_STALE_WHILE_REVALIDATE_MS + 1000 - ); + v1ModelsCatalog.__setCatalogStaleWhileRevalidateMsForTest(0); + v1ModelsCatalog.__expireCatalogCacheForTest(1); try { const res = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models", { @@ -152,6 +151,9 @@ await test("chave quota-exclusive não constrói o catálogo completo", async (t `ids=${JSON.stringify(body.data.map((m) => m.id).slice(0, 6))}` ); } finally { + v1ModelsCatalog.__setCatalogStaleWhileRevalidateMsForTest( + v1ModelsCatalog.CATALOG_STALE_WHILE_REVALIDATE_MS + ); if (prev === undefined) delete process.env.EXPOSE_CC_DISCOVERY_ALIASES; else process.env.EXPOSE_CC_DISCOVERY_ALIASES = prev; } diff --git a/tests/unit/v1-models-discovery-conformance.test.ts b/tests/unit/v1-models-discovery-conformance.test.ts index e1ab778003..9941d18ca5 100644 --- a/tests/unit/v1-models-discovery-conformance.test.ts +++ b/tests/unit/v1-models-discovery-conformance.test.ts @@ -108,27 +108,29 @@ test("3. stale-first: an expired 200 entry within the staleness window is served ); }); -test("4. beyond the staleness window, the response waits for a fresh build again", async () => { +test("4. an ordinary TTL expiry remains stale-first regardless of snapshot age", async () => { const makeRequest = () => new Request("http://localhost/v1/models"); const res1 = await v1ModelsCatalog.getUnifiedModelsResponse(makeRequest()); assert.equal(res1.status, 200); + const body1 = await res1.text(); const runsAfterFirst = v1ModelsCatalog.__getCatalogBuilderRunsForTest(); assert.equal(runsAfterFirst, 1); - // Push the entry's age past CATALOG_STALE_WHILE_REVALIDATE_MS. - v1ModelsCatalog.__expireCatalogCacheForTest( - v1ModelsCatalog.CATALOG_STALE_WHILE_REVALIDATE_MS + 5_000 - ); + // Age well past the historical 30-second bound. Ordinary expiry must not turn a + // refresh failure into a client-visible cold-build wait. + v1ModelsCatalog.__expireCatalogCacheForTest(24 * 60 * 60 * 1000); const res2 = await v1ModelsCatalog.getUnifiedModelsResponse(makeRequest()); assert.equal(res2.status, 200); + assert.equal(await res2.text(), body1); assert.equal( v1ModelsCatalog.__getCatalogBuilderRunsForTest(), - runsAfterFirst + 1, - "past the staleness window, the builder must run again BEFORE the response is returned " + - "(a refresh that keeps failing must not pin a stale catalog forever)" + runsAfterFirst, + "ordinary TTL expiry must return the last successful snapshot before refreshing" ); + await v1ModelsCatalog.__flushCatalogBackgroundRefreshForTest(); + assert.equal(v1ModelsCatalog.__getCatalogBuilderRunsForTest(), runsAfterFirst + 1); }); test("5. a cached non-200 entry is never served as stale", async () => {