mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 13:14:56 +03:00
Compare commits
4 Commits
refactor/e
...
green/8728
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
657d7a30c7 | ||
|
|
3dd8f52c00 | ||
|
|
69d6e59ef9 | ||
|
|
aca6e6adf7 |
1
changelog.d/fixes/8728-model-catalog-swr.md
Normal file
1
changelog.d/fixes/8728-model-catalog-swr.md
Normal file
@@ -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.
|
||||||
@@ -109,18 +109,25 @@ export { getCustomVisionCapabilityFields };
|
|||||||
// lives in ./catalogCache. Re-exported here because the existing tests import the
|
// 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
|
// hooks from this module, and CATALOG_STALE_WHILE_REVALIDATE_MS is part of the
|
||||||
// documented behavior of this endpoint.
|
// 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 {
|
export {
|
||||||
CATALOG_STALE_WHILE_REVALIDATE_MS,
|
CATALOG_STALE_WHILE_REVALIDATE_MS,
|
||||||
|
getCatalogStaleWhileRevalidateMs,
|
||||||
__resetCatalogBuilderRunsForTest,
|
__resetCatalogBuilderRunsForTest,
|
||||||
__getCatalogBuilderRunsForTest,
|
__getCatalogBuilderRunsForTest,
|
||||||
__expireCatalogCacheForTest,
|
__expireCatalogCacheForTest,
|
||||||
__setCatalogCacheEntryForTest,
|
__setCatalogCacheEntryForTest,
|
||||||
__flushCatalogBackgroundRefreshForTest,
|
__flushCatalogBackgroundRefreshForTest,
|
||||||
__forceCatalogInFlightRejectionForTest,
|
__forceCatalogInFlightRejectionForTest,
|
||||||
|
__setCatalogStaleWhileRevalidateAccessorForTest,
|
||||||
|
__setCatalogStaleWhileRevalidateMsForTest,
|
||||||
} from "./catalogCache";
|
} from "./catalogCache";
|
||||||
export type { CachedCatalog } from "./catalogCache";
|
export type { CachedCatalog, CatalogCachePolicy } from "./catalogCache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build unified OpenAI-compatible model catalog response.
|
* Build unified OpenAI-compatible model catalog response.
|
||||||
@@ -128,7 +135,8 @@ export type { CachedCatalog } from "./catalogCache";
|
|||||||
*/
|
*/
|
||||||
export async function getUnifiedModelsResponse(
|
export async function getUnifiedModelsResponse(
|
||||||
request: Request,
|
request: Request,
|
||||||
corsHeaders: Record<string, string> = {}
|
corsHeaders: Record<string, string> = {},
|
||||||
|
cachePolicy: CatalogCachePolicy = {}
|
||||||
) {
|
) {
|
||||||
const diagnosticHeaders = getCatalogDiagnosticsHeaders({ request });
|
const diagnosticHeaders = getCatalogDiagnosticsHeaders({ request });
|
||||||
|
|
||||||
@@ -160,7 +168,8 @@ export async function getUnifiedModelsResponse(
|
|||||||
return await resolveCachedCatalogResponse(
|
return await resolveCachedCatalogResponse(
|
||||||
request,
|
request,
|
||||||
{ corsHeaders, diagnosticHeaders },
|
{ corsHeaders, diagnosticHeaders },
|
||||||
buildCatalogPayload
|
buildCatalogPayload,
|
||||||
|
cachePolicy
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Hard rule #12: never put a raw err.message/err.stack in a response body.
|
// Hard rule #12: never put a raw err.message/err.stack in a response body.
|
||||||
|
|||||||
@@ -5,15 +5,16 @@
|
|||||||
* builder walks 8 registries and hits SQLite for connections, combos, custom
|
* builder walks 8 registries and hits SQLite for connections, combos, custom
|
||||||
* models and aliases; under Next.js's single-threaded App Router request
|
* 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
|
* handling, N concurrent calls execute back-to-back and the Nth completes at
|
||||||
* N × single-request latency. So identical concurrent requests are coalesced
|
* N × single-request latency. Identical concurrent requests are therefore
|
||||||
* onto one in-flight promise and the serialized body is memoized for a short
|
* coalesced onto one in-flight promise and successful serialized bodies are
|
||||||
* window.
|
* memoized for a short fresh window.
|
||||||
*
|
*
|
||||||
* Auth rejection is NOT handled here and must stay in the caller: it depends on
|
* 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.
|
* live per-request state (dashboard cookie, API key) and must never be cached.
|
||||||
*/
|
*/
|
||||||
import { getModelCatalogCacheVersion } from "@/lib/db/readCache";
|
import { getModelCatalogCacheVersion } from "@/lib/db/readCache";
|
||||||
import { extractApiKey } from "@/sse/services/auth";
|
import { extractApiKey } from "@/sse/services/auth";
|
||||||
|
import { after } from "next/server";
|
||||||
|
|
||||||
import { isCodexModelCatalogClient } from "./catalogRequest";
|
import { isCodexModelCatalogClient } from "./catalogRequest";
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export type CachedCatalog = {
|
|||||||
expiresAt: number;
|
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 = {
|
export type CatalogPayload = {
|
||||||
body: string;
|
body: string;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
@@ -32,54 +33,65 @@ export type CatalogPayload = {
|
|||||||
cacheTTL: number;
|
cacheTTL: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CatalogRefreshTask = () => Promise<void>;
|
||||||
|
export type CatalogRefreshScheduler = (task: CatalogRefreshTask) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A client with a short discovery timeout (Claude Code allows 3 s) must never
|
* Per-call cache policy. Request-context routes inject Next.js `after()` as the
|
||||||
* wait on a full rebuild. Once a cached 200 expires it is still served
|
* scheduler; unit tests and direct non-framework callers can inject a deterministic
|
||||||
* immediately for up to this long while a background refresh repopulates it.
|
* scheduler without making the cache branch on runner-specific environment variables.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
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`.
|
* Fallback memoization window; overridden by `settings.cache.modelCatalogCacheTtlMs`.
|
||||||
*
|
*
|
||||||
* This does NOT govern post-write freshness — `invalidateDbCache()` bumps
|
* This is only the fresh window. Ordinary expiry serves the last successful snapshot
|
||||||
* `modelCatalogCacheVersion` on every settings/connections/combos/pricing write and
|
* while refreshing. `modelCatalogCacheVersion` changes bypass stale serving entirely.
|
||||||
* `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.
|
|
||||||
*/
|
*/
|
||||||
export const CATALOG_CACHE_TTL_MS_DEFAULT = 60_000;
|
export const CATALOG_CACHE_TTL_MS_DEFAULT = 60_000;
|
||||||
|
|
||||||
|
type CatalogInFlight = {
|
||||||
|
generation: number;
|
||||||
|
promise: Promise<CatalogPayload>;
|
||||||
|
};
|
||||||
|
|
||||||
const catalogCache = new Map<string, CachedCatalog>();
|
const catalogCache = new Map<string, CachedCatalog>();
|
||||||
|
const catalogInFlight = new Map<string, CatalogInFlight>();
|
||||||
|
|
||||||
/**
|
let catalogGeneration = 0;
|
||||||
* An in-flight build is bound to the catalog-state generation it started from
|
let lastSeenCatalogCacheVersion = getModelCatalogCacheVersion();
|
||||||
* (`getModelCatalogCacheVersion()` at launch). After a write invalidates the
|
let staleWhileRevalidateMsAccessor = () => CATALOG_STALE_WHILE_REVALIDATE_MS;
|
||||||
* 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<CachedCatalog> };
|
|
||||||
const catalogInFlight = new Map<string, InFlightBuild>();
|
|
||||||
|
|
||||||
let _catalogBuilderRuns = 0;
|
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 {
|
function buildCatalogCacheKey(request: Request): string {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const prefix = url.searchParams.get("prefix") || "";
|
const prefix = url.searchParams.get("prefix") || "";
|
||||||
@@ -89,32 +101,28 @@ function buildCatalogCacheKey(request: Request): string {
|
|||||||
return `${prefix}|${isCodex}|${apiKey}|${configuredOnly}`;
|
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/
|
* Observe the DB-side invalidation signal.
|
||||||
// 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
|
* Every observed version transition is hard invalidation: snapshots are cleared,
|
||||||
// would leak one Map entry per version forever instead of ever pruning old ones).
|
* the local generation advances, and old work is detached. Completion guards also
|
||||||
let lastSeenCatalogCacheVersion = getModelCatalogCacheVersion();
|
* call this function, so a version change that occurs while a builder is running
|
||||||
function dropCatalogCacheIfStateChanged(): void {
|
* prevents that builder from writing even before another request arrives.
|
||||||
|
*/
|
||||||
|
function synchronizeCatalogGeneration(): void {
|
||||||
const currentVersion = getModelCatalogCacheVersion();
|
const currentVersion = getModelCatalogCacheVersion();
|
||||||
if (currentVersion === lastSeenCatalogCacheVersion) return;
|
if (currentVersion === lastSeenCatalogCacheVersion) return;
|
||||||
|
|
||||||
lastSeenCatalogCacheVersion = currentVersion;
|
lastSeenCatalogCacheVersion = currentVersion;
|
||||||
|
catalogGeneration++;
|
||||||
catalogCache.clear();
|
catalogCache.clear();
|
||||||
// Deliberately NOT clearing catalogInFlight: an in-flight build bound to the
|
catalogInFlight.clear();
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header sources mix Title-Case keys (diagnostic/cors headers built by app code) with
|
// 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
|
// lower-case ones (payload headers captured via the Fetch `Headers` iterator). Merge
|
||||||
// object spread keeps both casings as distinct keys, and the `Response` constructor
|
// through a real Headers so the caller's per-request diagnostics overwrite cached values
|
||||||
// then *appends* rather than overwrites them, producing comma-joined duplicates (e.g.
|
// case-insensitively.
|
||||||
// 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.
|
|
||||||
export function mergeCatalogHeaders(
|
export function mergeCatalogHeaders(
|
||||||
...sources: Array<Record<string, string> | undefined>
|
...sources: Array<Record<string, string> | undefined>
|
||||||
): Headers {
|
): Headers {
|
||||||
@@ -128,80 +136,25 @@ export function mergeCatalogHeaders(
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function isSuccessfulPayload(payload: CatalogPayload): boolean {
|
||||||
* Persist a freshly built payload — but only when the build still belongs to the
|
return payload.status >= 200 && payload.status < 300;
|
||||||
* 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
|
function storeSuccessfulPayload(
|
||||||
* payload reflects pre-write state and caching it would serve stale data.
|
|
||||||
*/
|
|
||||||
function storePayload(
|
|
||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
payload: CatalogPayload,
|
payload: CatalogPayload,
|
||||||
buildGeneration: number
|
inFlight: CatalogInFlight
|
||||||
): CachedCatalog {
|
): void {
|
||||||
const entry: CachedCatalog = {
|
synchronizeCatalogGeneration();
|
||||||
|
if (!isSuccessfulPayload(payload)) return;
|
||||||
|
if (inFlight.generation !== catalogGeneration) return;
|
||||||
|
if (catalogInFlight.get(cacheKey) !== inFlight) return;
|
||||||
|
|
||||||
|
catalogCache.set(cacheKey, {
|
||||||
body: payload.body,
|
body: payload.body,
|
||||||
headers: payload.headers,
|
headers: payload.headers,
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
expiresAt: Date.now() + payload.cacheTTL,
|
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<CatalogPayload>
|
|
||||||
): void {
|
|
||||||
if (catalogInFlight.has(cacheKey)) return; // a refresh for this key is already running
|
|
||||||
|
|
||||||
const generation = getModelCatalogCacheVersion();
|
|
||||||
const refreshPromise: Promise<CachedCatalog> = 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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,23 +163,104 @@ function runBuilder(
|
|||||||
request: Request
|
request: Request
|
||||||
): Promise<CatalogPayload> {
|
): Promise<CatalogPayload> {
|
||||||
_catalogBuilderRuns++;
|
_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<CatalogPayload>
|
||||||
|
): 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<CatalogPayload>,
|
||||||
|
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<CatalogPayload>((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
|
* Resolve the cached catalog response for `request`, building it through the shared
|
||||||
* `buildPayload` when there is nothing fresh to serve.
|
* `buildPayload` primitive when there is no current snapshot.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export async function resolveCachedCatalogResponse(
|
export async function resolveCachedCatalogResponse(
|
||||||
request: Request,
|
request: Request,
|
||||||
headerSources: { corsHeaders: Record<string, string>; diagnosticHeaders: Record<string, string> },
|
headerSources: { corsHeaders: Record<string, string>; diagnosticHeaders: Record<string, string> },
|
||||||
buildPayload: (request: Request) => Promise<CatalogPayload>
|
buildPayload: (request: Request) => Promise<CatalogPayload>,
|
||||||
|
policy: CatalogCachePolicy = {}
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { corsHeaders, diagnosticHeaders } = headerSources;
|
const { corsHeaders, diagnosticHeaders } = headerSources;
|
||||||
dropCatalogCacheIfStateChanged();
|
synchronizeCatalogGeneration();
|
||||||
|
|
||||||
const cacheKey = buildCatalogCacheKey(request);
|
const cacheKey = buildCatalogCacheKey(request);
|
||||||
const now = Date.now();
|
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
|
const staleWhileRevalidateMs =
|
||||||
// (a) it was a successful build — a cached error replayed as "stale" would mask an
|
policy.getStaleWhileRevalidateMs?.() ?? getCatalogStaleWhileRevalidateMs();
|
||||||
// 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.
|
|
||||||
if (
|
if (
|
||||||
cached &&
|
cached &&
|
||||||
cached.status === 200 &&
|
cached.status >= 200 &&
|
||||||
now - cached.expiresAt <= CATALOG_STALE_WHILE_REVALIDATE_MS
|
cached.status < 300 &&
|
||||||
|
now - cached.expiresAt <= staleWhileRevalidateMs
|
||||||
) {
|
) {
|
||||||
scheduleBackgroundRefresh(cacheKey, request, buildPayload);
|
scheduleBackgroundRefresh(
|
||||||
|
cacheKey,
|
||||||
|
request,
|
||||||
|
buildPayload,
|
||||||
|
policy.scheduleBackgroundRefresh ?? defaultBackgroundRefreshScheduler
|
||||||
|
);
|
||||||
return new Response(cached.body, {
|
return new Response(cached.body, {
|
||||||
status: cached.status,
|
status: cached.status,
|
||||||
headers: mergeCatalogHeaders(corsHeaders, cached.headers, diagnosticHeaders),
|
headers: mergeCatalogHeaders(corsHeaders, cached.headers, diagnosticHeaders),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentGeneration = getModelCatalogCacheVersion();
|
let inFlight = catalogInFlight.get(cacheKey);
|
||||||
let inflight = catalogInFlight.get(cacheKey);
|
if (!inFlight) {
|
||||||
// Only join an in-flight build from the CURRENT generation. A build bound to an
|
inFlight = startSynchronousBuild(cacheKey, request, buildPayload);
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await inflight.promise;
|
const payload = await inFlight.promise;
|
||||||
return new Response(payload.body, {
|
return new Response(payload.body, {
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
headers: mergeCatalogHeaders(corsHeaders, payload.headers, diagnosticHeaders),
|
headers: mergeCatalogHeaders(corsHeaders, payload.headers, diagnosticHeaders),
|
||||||
@@ -281,14 +306,26 @@ export async function resolveCachedCatalogResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Test hooks ───────────────────────────────────────────────────────────────
|
// ── 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 {
|
export function __resetCatalogBuilderRunsForTest(): void {
|
||||||
_catalogBuilderRuns = 0;
|
_catalogBuilderRuns = 0;
|
||||||
|
catalogGeneration++;
|
||||||
catalogCache.clear();
|
catalogCache.clear();
|
||||||
catalogInFlight.clear();
|
catalogInFlight.clear();
|
||||||
lastSeenCatalogCacheVersion = getModelCatalogCacheVersion();
|
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). */
|
/** Counts full builder executions — proves concurrent requests share one run (#6408). */
|
||||||
@@ -296,11 +333,7 @@ export function __getCatalogBuilderRunsForTest(): number {
|
|||||||
return _catalogBuilderRuns;
|
return _catalogBuilderRuns;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Marks every successful snapshot expired without sleeping out the real TTL. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export function __expireCatalogCacheForTest(msAgo = 1): void {
|
export function __expireCatalogCacheForTest(msAgo = 1): void {
|
||||||
const expiresAt = Date.now() - msAgo;
|
const expiresAt = Date.now() - msAgo;
|
||||||
for (const [key, entry] of catalogCache.entries()) {
|
for (const [key, entry] of catalogCache.entries()) {
|
||||||
@@ -308,38 +341,22 @@ export function __expireCatalogCacheForTest(msAgo = 1): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Seeds a request-keyed snapshot for status/staleness compatibility tests. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export function __setCatalogCacheEntryForTest(request: Request, entry: CachedCatalog): void {
|
export function __setCatalogCacheEntryForTest(request: Request, entry: CachedCatalog): void {
|
||||||
catalogCache.set(buildCatalogCacheKey(request), entry);
|
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<void> {
|
export async function __flushCatalogBackgroundRefreshForTest(): Promise<void> {
|
||||||
await Promise.all([...catalogInFlight.values()].map((entry) => entry.promise.catch(() => {})));
|
await Promise.all([...catalogInFlight.values()].map(({ promise }) => promise.catch(() => {})));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Injects a handled in-flight rejection for the catalog error-shape regression test. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export function __forceCatalogInFlightRejectionForTest(request: Request, error: unknown): void {
|
export function __forceCatalogInFlightRejectionForTest(request: Request, error: unknown): void {
|
||||||
const rejected: Promise<CachedCatalog> = Promise.reject(error);
|
const promise: Promise<CatalogPayload> = Promise.reject(error);
|
||||||
rejected.catch(() => {}); // mark as handled — avoids an unhandledRejection warning
|
void promise.catch(() => {});
|
||||||
// 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).
|
|
||||||
catalogInFlight.set(buildCatalogCacheKey(request), {
|
catalogInFlight.set(buildCatalogCacheKey(request), {
|
||||||
generation: getModelCatalogCacheVersion(),
|
generation: catalogGeneration,
|
||||||
promise: rejected,
|
promise,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { after } from "next/server";
|
||||||
|
|
||||||
import { getUnifiedModelsResponse } from "./catalog";
|
import { getUnifiedModelsResponse } from "./catalog";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,5 +33,11 @@ export async function HEAD() {
|
|||||||
* GET /v1/models - OpenAI compatible models list
|
* GET /v1/models - OpenAI compatible models list
|
||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
return getUnifiedModelsResponse(request);
|
return getUnifiedModelsResponse(
|
||||||
|
request,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
scheduleBackgroundRefresh: (task) => after(task),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { invalidateModelCatalogCache } from "@/lib/db/readCache";
|
||||||
|
|
||||||
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models";
|
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models";
|
||||||
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
@@ -169,6 +170,7 @@ export async function refreshOpenRouterCatalog(): Promise<{
|
|||||||
try {
|
try {
|
||||||
const data = await fetchFromAPI();
|
const data = await fetchFromAPI();
|
||||||
writeCache(data);
|
writeCache(data);
|
||||||
|
invalidateModelCatalogCache();
|
||||||
return { data, ok: true };
|
return { data, ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
import { getDbInstance } from "@/lib/db/core";
|
import { getDbInstance } from "@/lib/db/core";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
import { invalidateModelCatalogCache } from "./readCache";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ export function createKeyGroup(name: string, description = ""): KeyGroup {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
db.prepare(
|
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);
|
).run(id, name, description, now, now);
|
||||||
|
|
||||||
return getKeyGroup(id)!;
|
return getKeyGroup(id)!;
|
||||||
@@ -79,7 +80,7 @@ export function createKeyGroup(name: string, description = ""): KeyGroup {
|
|||||||
|
|
||||||
export function updateKeyGroup(
|
export function updateKeyGroup(
|
||||||
id: string,
|
id: string,
|
||||||
updates: { name?: string; description?: string; isActive?: boolean }
|
updates: { name?: string; description?: string; isActive?: boolean },
|
||||||
): KeyGroup | undefined {
|
): KeyGroup | undefined {
|
||||||
const existing = getKeyGroup(id);
|
const existing = getKeyGroup(id);
|
||||||
if (!existing) return undefined;
|
if (!existing) return undefined;
|
||||||
@@ -102,17 +103,27 @@ export function updateKeyGroup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sets.length === 0) return existing;
|
if (sets.length === 0) return existing;
|
||||||
|
const catalogInvalidationNeeded =
|
||||||
|
updates.isActive !== undefined && updates.isActive !== existing.isActive;
|
||||||
|
|
||||||
sets.push("updated_at = datetime('now')");
|
sets.push("updated_at = datetime('now')");
|
||||||
|
|
||||||
db.prepare(`UPDATE key_groups SET ${sets.join(", ")} WHERE id = @id`).run(params);
|
const result = db.prepare(`UPDATE key_groups SET ${sets.join(", ")} WHERE id = @id`).run(params);
|
||||||
return getKeyGroup(id);
|
if (catalogInvalidationNeeded && result.changes > 0) {
|
||||||
|
invalidateModelCatalogCache();
|
||||||
|
}
|
||||||
|
return result.changes > 0 ? getKeyGroup(id) : existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteKeyGroup(id: string): boolean {
|
export function deleteKeyGroup(id: string): boolean {
|
||||||
const db = getDbInstance() as any;
|
const db = getDbInstance() as any;
|
||||||
// CASCADE deletes permissions and members
|
// CASCADE deletes permissions and members
|
||||||
const result = db.prepare("DELETE FROM key_groups WHERE id = ?").run(id);
|
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 ────────────────────────────────────────────────────
|
// ── Group Permissions ────────────────────────────────────────────────────
|
||||||
@@ -121,7 +132,7 @@ export function getGroupPermissions(groupId: string): GroupModelPermission[] {
|
|||||||
const db = getDbInstance() as any;
|
const db = getDbInstance() as any;
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.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[];
|
.all(groupId) as any[];
|
||||||
return rows.map(rowToPermission);
|
return rows.map(rowToPermission);
|
||||||
@@ -131,15 +142,21 @@ export function addGroupPermission(
|
|||||||
groupId: string,
|
groupId: string,
|
||||||
modelPattern: string,
|
modelPattern: string,
|
||||||
accessType: "allow" | "deny",
|
accessType: "allow" | "deny",
|
||||||
provider?: string
|
provider?: string,
|
||||||
): GroupModelPermission {
|
): GroupModelPermission {
|
||||||
const db = getDbInstance() as any;
|
const db = getDbInstance() as any;
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
db.prepare(
|
const result = db
|
||||||
"INSERT INTO group_model_permissions (id, group_id, model_pattern, provider, access_type, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
.prepare(
|
||||||
).run(id, groupId, modelPattern, provider || null, accessType, now);
|
"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)!;
|
return getGroupPermissions(groupId).find((p) => p.id === id)!;
|
||||||
}
|
}
|
||||||
@@ -147,12 +164,18 @@ export function addGroupPermission(
|
|||||||
export function removeGroupPermission(permissionId: string): boolean {
|
export function removeGroupPermission(permissionId: string): boolean {
|
||||||
const db = getDbInstance() as any;
|
const db = getDbInstance() as any;
|
||||||
const result = db.prepare("DELETE FROM group_model_permissions WHERE id = ?").run(permissionId);
|
const result = db.prepare("DELETE FROM group_model_permissions WHERE id = ?").run(permissionId);
|
||||||
|
if (result.changes > 0) {
|
||||||
|
invalidateModelCatalogCache();
|
||||||
|
}
|
||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearGroupPermissions(groupId: string): void {
|
export function clearGroupPermissions(groupId: string): void {
|
||||||
const db = getDbInstance() as any;
|
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 ────────────────────────────────────────────────────
|
// ── 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
|
INNER JOIN key_group_members m ON g.id = m.group_id
|
||||||
WHERE m.key_id = ? AND g.is_active = 1
|
WHERE m.key_id = ? AND g.is_active = 1
|
||||||
ORDER BY g.name ASC
|
ORDER BY g.name ASC
|
||||||
`
|
`,
|
||||||
)
|
)
|
||||||
.all(keyId) as any[];
|
.all(keyId) as any[];
|
||||||
return rows.map(rowToGroup);
|
return rows.map(rowToGroup);
|
||||||
@@ -183,10 +206,12 @@ export function getKeyGroupsForApiKey(keyId: string): KeyGroup[] {
|
|||||||
export function addKeyToGroup(keyId: string, groupId: string): boolean {
|
export function addKeyToGroup(keyId: string, groupId: string): boolean {
|
||||||
const db = getDbInstance() as any;
|
const db = getDbInstance() as any;
|
||||||
try {
|
try {
|
||||||
db.prepare("INSERT OR IGNORE INTO key_group_members (key_id, group_id) VALUES (?, ?)").run(
|
const result = db
|
||||||
keyId,
|
.prepare("INSERT OR IGNORE INTO key_group_members (key_id, group_id) VALUES (?, ?)")
|
||||||
groupId
|
.run(keyId, groupId);
|
||||||
);
|
if (result.changes > 0) {
|
||||||
|
invalidateModelCatalogCache();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -198,6 +223,9 @@ export function removeKeyFromGroup(keyId: string, groupId: string): boolean {
|
|||||||
const result = db
|
const result = db
|
||||||
.prepare("DELETE FROM key_group_members WHERE key_id = ? AND group_id = ?")
|
.prepare("DELETE FROM key_group_members WHERE key_id = ? AND group_id = ?")
|
||||||
.run(keyId, groupId);
|
.run(keyId, groupId);
|
||||||
|
if (result.changes > 0) {
|
||||||
|
invalidateModelCatalogCache();
|
||||||
|
}
|
||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +252,7 @@ export interface ModelAccessCheck {
|
|||||||
export function checkKeyModelAccess(
|
export function checkKeyModelAccess(
|
||||||
keyId: string,
|
keyId: string,
|
||||||
model: string,
|
model: string,
|
||||||
provider?: string
|
provider?: string,
|
||||||
): ModelAccessCheck {
|
): ModelAccessCheck {
|
||||||
const groups = getKeyGroupsForApiKey(keyId);
|
const groups = getKeyGroupsForApiKey(keyId);
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
@@ -242,7 +270,7 @@ export function checkKeyModelAccess(
|
|||||||
SELECT * FROM group_model_permissions
|
SELECT * FROM group_model_permissions
|
||||||
WHERE group_id IN (${placeholders})
|
WHERE group_id IN (${placeholders})
|
||||||
ORDER BY access_type ASC
|
ORDER BY access_type ASC
|
||||||
`
|
`,
|
||||||
)
|
)
|
||||||
.all(...groupIds) as any[];
|
.all(...groupIds) as any[];
|
||||||
|
|
||||||
@@ -253,7 +281,7 @@ export function checkKeyModelAccess(
|
|||||||
(p) =>
|
(p) =>
|
||||||
p.accessType === "deny" &&
|
p.accessType === "deny" &&
|
||||||
matchesModelPattern(p.modelPattern, model) &&
|
matchesModelPattern(p.modelPattern, model) &&
|
||||||
(!p.provider || p.provider === provider)
|
(!p.provider || p.provider === provider),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (denyRules.length > 0) {
|
if (denyRules.length > 0) {
|
||||||
@@ -265,7 +293,7 @@ export function checkKeyModelAccess(
|
|||||||
(p) =>
|
(p) =>
|
||||||
p.accessType === "allow" &&
|
p.accessType === "allow" &&
|
||||||
matchesModelPattern(p.modelPattern, model) &&
|
matchesModelPattern(p.modelPattern, model) &&
|
||||||
(!p.provider || p.provider === provider)
|
(!p.provider || p.provider === provider),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allowRules.length > 0) {
|
if (allowRules.length > 0) {
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ import {
|
|||||||
parseStreamDefaultMode,
|
parseStreamDefaultMode,
|
||||||
parseChaosModeEnabled,
|
parseChaosModeEnabled,
|
||||||
} from "./apiKeys/rowParsers";
|
} from "./apiKeys/rowParsers";
|
||||||
|
import {
|
||||||
|
clearModelPermissionCache,
|
||||||
|
getCachedModelPermission,
|
||||||
|
setCachedModelPermission,
|
||||||
|
evictModelPermissionCache,
|
||||||
|
} from "./apiKeys/modelPermissionCache";
|
||||||
|
import { getModelCatalogCacheVersion, invalidateModelCatalogCache } from "./readCache";
|
||||||
import type { AccessSchedule, RateLimitRule } from "./apiKeys/types";
|
import type { AccessSchedule, RateLimitRule } from "./apiKeys/types";
|
||||||
|
|
||||||
// ──────────────── Performance Optimizations ────────────────
|
// ──────────────── Performance Optimizations ────────────────
|
||||||
@@ -62,7 +69,6 @@ interface CacheEntry<TValue> {
|
|||||||
value: TValue;
|
value: TValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-exported for the historical public surface (moved to ./apiKeys/types).
|
|
||||||
export type { AccessSchedule, RateLimitRule } from "./apiKeys/types";
|
export type { AccessSchedule, RateLimitRule } from "./apiKeys/types";
|
||||||
|
|
||||||
interface ApiKeyMetadata {
|
interface ApiKeyMetadata {
|
||||||
@@ -82,9 +88,7 @@ interface ApiKeyMetadata {
|
|||||||
maxRequestsPerMinute: number | null;
|
maxRequestsPerMinute: number | null;
|
||||||
throttleDelayMs: number | null;
|
throttleDelayMs: number | null;
|
||||||
rateLimits: RateLimitRule[] | null;
|
rateLimits: RateLimitRule[] | null;
|
||||||
// T08: Per-key max concurrent sticky sessions (0 = unlimited)
|
|
||||||
maxSessions: number;
|
maxSessions: number;
|
||||||
// Phase 3 lifecycle/policy fields
|
|
||||||
revokedAt: string | null;
|
revokedAt: string | null;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
ipAllowlist: string[];
|
ipAllowlist: string[];
|
||||||
@@ -198,12 +202,6 @@ const CACHE_TTL = 60 * 1000; // 1 minute TTL
|
|||||||
const LAST_USED_UPDATE_TTL = 5 * 60 * 1000;
|
const LAST_USED_UPDATE_TTL = 5 * 60 * 1000;
|
||||||
const MAX_CACHE_SIZE = 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<string, { allowed: boolean; timestamp: number }>();
|
|
||||||
|
|
||||||
// Prepared statements cache
|
// Prepared statements cache
|
||||||
let _stmtGetAllKeys: ApiKeysStatements["getAllKeys"] | null = null;
|
let _stmtGetAllKeys: ApiKeysStatements["getAllKeys"] | null = null;
|
||||||
let _stmtGetKeyById: ApiKeysStatements["getKeyById"] | null = null;
|
let _stmtGetKeyById: ApiKeysStatements["getKeyById"] | null = null;
|
||||||
@@ -218,7 +216,7 @@ let _stmtDeleteKey: ApiKeysStatements["deleteKey"] | null = null;
|
|||||||
function invalidateCaches() {
|
function invalidateCaches() {
|
||||||
_keyValidationCache.clear();
|
_keyValidationCache.clear();
|
||||||
_keyMetadataCache.clear();
|
_keyMetadataCache.clear();
|
||||||
_modelPermissionCache.clear();
|
clearModelPermissionCache();
|
||||||
_lastUsedUpdateCache.clear();
|
_lastUsedUpdateCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,12 +276,8 @@ function markApiKeyUsed(db: ApiKeysDbLike, id: unknown, now: number): void {
|
|||||||
_lastUsedUpdateCache.set(id, now);
|
_lastUsedUpdateCache.set(id, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* LRU eviction for cache
|
|
||||||
*/
|
|
||||||
function evictIfNeeded<TKey, TValue>(cache: Map<TKey, TValue>) {
|
function evictIfNeeded<TKey, TValue>(cache: Map<TKey, TValue>) {
|
||||||
if (cache.size > MAX_CACHE_SIZE) {
|
if (cache.size > MAX_CACHE_SIZE) {
|
||||||
// Remove oldest 20% of entries
|
|
||||||
const entriesToRemove = Math.floor(MAX_CACHE_SIZE * 0.2);
|
const entriesToRemove = Math.floor(MAX_CACHE_SIZE * 0.2);
|
||||||
let i = 0;
|
let i = 0;
|
||||||
for (const key of cache.keys()) {
|
for (const key of cache.keys()) {
|
||||||
@@ -315,7 +309,7 @@ async function getModelPermissionCandidates(modelId: string): Promise<string[]>
|
|||||||
providerOrAlias,
|
providerOrAlias,
|
||||||
providerScopedModel,
|
providerScopedModel,
|
||||||
resolveProviderId,
|
resolveProviderId,
|
||||||
getProviderAlias
|
getProviderAlias,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Array.from(candidates);
|
return Array.from(candidates);
|
||||||
@@ -333,7 +327,7 @@ async function getModelPermissionCandidates(modelId: string): Promise<string[]>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getPublishedModelLookupTarget(
|
async function getPublishedModelLookupTarget(
|
||||||
modelId: string
|
modelId: string,
|
||||||
): Promise<{ providerId: string; modelId: string } | null> {
|
): Promise<{ providerId: string; modelId: string } | null> {
|
||||||
const cleanModelId = stripExtendedContextSuffix(modelId.trim());
|
const cleanModelId = stripExtendedContextSuffix(modelId.trim());
|
||||||
if (!cleanModelId) return null;
|
if (!cleanModelId) return null;
|
||||||
@@ -362,14 +356,13 @@ async function getPublishedModelLookupTarget(
|
|||||||
function ensureApiKeyColumn(
|
function ensureApiKeyColumn(
|
||||||
db: ApiKeysDbLike,
|
db: ApiKeysDbLike,
|
||||||
columnNames: Set<string>,
|
columnNames: Set<string>,
|
||||||
column: (typeof API_KEY_COLUMN_FALLBACKS)[number]
|
column: (typeof API_KEY_COLUMN_FALLBACKS)[number],
|
||||||
): void {
|
): void {
|
||||||
if (columnNames.has(column.name)) return;
|
if (columnNames.has(column.name)) return;
|
||||||
db.exec(`ALTER TABLE api_keys ADD COLUMN ${column.definition}`);
|
db.exec(`ALTER TABLE api_keys ADD COLUMN ${column.definition}`);
|
||||||
console.log(`[DB] Added api_keys.${column.name} column`);
|
console.log(`[DB] Added api_keys.${column.name} column`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure api_keys extension columns exist (memoized)
|
|
||||||
function ensureApiKeysColumns(db: ApiKeysDbLike) {
|
function ensureApiKeysColumns(db: ApiKeysDbLike) {
|
||||||
if (_schemaChecked) return;
|
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;
|
let _stmtDb: ApiKeysDbLike | null = null;
|
||||||
function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
|
function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
|
||||||
ensureApiKeysColumns(db);
|
ensureApiKeysColumns(db);
|
||||||
@@ -407,13 +396,13 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
|
|||||||
_stmtGetAllKeys = db.prepare<ApiKeyRow>("SELECT * FROM api_keys ORDER BY created_at");
|
_stmtGetAllKeys = db.prepare<ApiKeyRow>("SELECT * FROM api_keys ORDER BY created_at");
|
||||||
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
|
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
|
||||||
_stmtValidateKey = db.prepare<JsonRecord>(
|
_stmtValidateKey = db.prepare<JsonRecord>(
|
||||||
"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<ApiKeyRow>(
|
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
|
||||||
"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(
|
_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 = ?");
|
_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.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints);
|
||||||
camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode);
|
camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode);
|
||||||
camelRow.disableNonPublicModels = parseDisableNonPublicModels(
|
camelRow.disableNonPublicModels = parseDisableNonPublicModels(
|
||||||
(camelRow as JsonRecord).disableNonPublicModels
|
(camelRow as JsonRecord).disableNonPublicModels,
|
||||||
);
|
);
|
||||||
camelRow.allowUsageCommand = parseAllowUsageCommand((camelRow as JsonRecord).allowUsageCommand);
|
camelRow.allowUsageCommand = parseAllowUsageCommand((camelRow as JsonRecord).allowUsageCommand);
|
||||||
camelRow.chaosModeEnabled = parseChaosModeEnabled((camelRow as JsonRecord).chaosModeEnabled);
|
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.
|
* inactive, or banned key, and it never widens a key's allowedModels.
|
||||||
*/
|
*/
|
||||||
export async function pickApiKeyForInternalUse(
|
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<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const keys = (await getApiKeys()) as Array<{
|
const keys = (await getApiKeys()) as Array<{
|
||||||
@@ -527,13 +516,13 @@ export async function pickApiKeyForInternalUse(
|
|||||||
|
|
||||||
// 1. Management-scoped key (preferred for any internal probe).
|
// 1. Management-scoped key (preferred for any internal probe).
|
||||||
const manageKey = keys.find(
|
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;
|
if (manageKey?.key) return manageKey.key;
|
||||||
|
|
||||||
// 2. Allow-all key (empty allowedModels means no model restrictions).
|
// 2. Allow-all key (empty allowedModels means no model restrictions).
|
||||||
const allowAllKey = keys.find(
|
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;
|
if (allowAllKey?.key) return allowAllKey.key;
|
||||||
|
|
||||||
@@ -576,7 +565,7 @@ export async function getApiKeyById(id: string) {
|
|||||||
camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints);
|
camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints);
|
||||||
camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode);
|
camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode);
|
||||||
camelRow.disableNonPublicModels = parseDisableNonPublicModels(
|
camelRow.disableNonPublicModels = parseDisableNonPublicModels(
|
||||||
(camelRow as JsonRecord).disableNonPublicModels
|
(camelRow as JsonRecord).disableNonPublicModels,
|
||||||
);
|
);
|
||||||
camelRow.allowUsageCommand = parseAllowUsageCommand((camelRow as JsonRecord).allowUsageCommand);
|
camelRow.allowUsageCommand = parseAllowUsageCommand((camelRow as JsonRecord).allowUsageCommand);
|
||||||
camelRow.chaosModeEnabled = parseChaosModeEnabled((camelRow as JsonRecord).chaosModeEnabled);
|
camelRow.chaosModeEnabled = parseChaosModeEnabled((camelRow as JsonRecord).chaosModeEnabled);
|
||||||
@@ -633,7 +622,7 @@ export async function createApiKey(name: string, machineId: string, scopes: stri
|
|||||||
apiKey.createdAt,
|
apiKey.createdAt,
|
||||||
apiKey.key.slice(0, 12),
|
apiKey.key.slice(0, 12),
|
||||||
await hashKey(apiKey.key),
|
await hashKey(apiKey.key),
|
||||||
JSON.stringify(scopes)
|
JSON.stringify(scopes),
|
||||||
);
|
);
|
||||||
setNoLog(apiKey.id, false);
|
setNoLog(apiKey.id, false);
|
||||||
|
|
||||||
@@ -655,7 +644,7 @@ export async function regenerateApiKey(id: string) {
|
|||||||
|
|
||||||
// Update in DB
|
// Update in DB
|
||||||
const updateStmt = db.prepare(
|
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);
|
updateStmt.run(newKey, newHash, newPrefix, id);
|
||||||
|
|
||||||
@@ -707,7 +696,7 @@ export async function updateApiKeyPermissions(
|
|||||||
dailyUsageLimitUsd?: number | null;
|
dailyUsageLimitUsd?: number | null;
|
||||||
weeklyUsageLimitUsd?: number | null;
|
weeklyUsageLimitUsd?: number | null;
|
||||||
chaosModeEnabled?: boolean;
|
chaosModeEnabled?: boolean;
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
const db = getDbInstance() as ApiKeysDbLike;
|
const db = getDbInstance() as ApiKeysDbLike;
|
||||||
getPreparedStatements(db);
|
getPreparedStatements(db);
|
||||||
@@ -1001,6 +990,16 @@ export async function updateApiKeyPermissions(
|
|||||||
|
|
||||||
if (changedRows === 0) return false;
|
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");
|
const { logAuditEvent } = await import("@/lib/compliance");
|
||||||
|
|
||||||
if (normalized.isBanned !== undefined) {
|
if (normalized.isBanned !== undefined) {
|
||||||
@@ -1094,7 +1093,7 @@ export async function revokeApiKey(id: string): Promise<boolean> {
|
|||||||
|
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.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() });
|
.run({ id, ts: new Date().toISOString() });
|
||||||
|
|
||||||
@@ -1223,7 +1222,7 @@ export async function validateApiKey(key: string | null | undefined) {
|
|||||||
revokedAt: row.revoked_at,
|
revokedAt: row.revoked_at,
|
||||||
}),
|
}),
|
||||||
"EX",
|
"EX",
|
||||||
3600 // 1 hour cache
|
3600, // 1 hour cache
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1240,7 +1239,7 @@ export async function validateApiKey(key: string | null | undefined) {
|
|||||||
* Get API key metadata with caching for performance
|
* Get API key metadata with caching for performance
|
||||||
*/
|
*/
|
||||||
export async function getApiKeyMetadata(
|
export async function getApiKeyMetadata(
|
||||||
key: string | null | undefined
|
key: string | null | undefined,
|
||||||
): Promise<ApiKeyMetadata | null> {
|
): Promise<ApiKeyMetadata | null> {
|
||||||
if (!key || typeof key !== "string") return null;
|
if (!key || typeof key !== "string") return null;
|
||||||
|
|
||||||
@@ -1339,10 +1338,10 @@ export async function getApiKeyMetadata(
|
|||||||
blockedModels: parseAllowedModels(record.blocked_models ?? record.blockedModels),
|
blockedModels: parseAllowedModels(record.blocked_models ?? record.blockedModels),
|
||||||
allowedCombos: parseAllowedCombos(record.allowed_combos ?? record.allowedCombos),
|
allowedCombos: parseAllowedCombos(record.allowed_combos ?? record.allowedCombos),
|
||||||
allowedConnections: parseAllowedConnections(
|
allowedConnections: parseAllowedConnections(
|
||||||
record.allowed_connections ?? record.allowedConnections
|
record.allowed_connections ?? record.allowedConnections,
|
||||||
),
|
),
|
||||||
allowedQuotas: parseAllowedQuotas(
|
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),
|
noLog: parseNoLog(record.no_log ?? record.noLog),
|
||||||
autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve),
|
autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve),
|
||||||
@@ -1364,20 +1363,20 @@ export async function getApiKeyMetadata(
|
|||||||
proxyId:
|
proxyId:
|
||||||
typeof record.proxy_id === "string" && record.proxy_id.trim() !== "" ? record.proxy_id : null,
|
typeof record.proxy_id === "string" && record.proxy_id.trim() !== "" ? record.proxy_id : null,
|
||||||
allowedEndpoints: parseStringList(
|
allowedEndpoints: parseStringList(
|
||||||
(record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints
|
(record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints,
|
||||||
),
|
),
|
||||||
streamDefaultMode: parseStreamDefaultMode(
|
streamDefaultMode: parseStreamDefaultMode(
|
||||||
(record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode
|
(record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode,
|
||||||
),
|
),
|
||||||
disableNonPublicModels: parseDisableNonPublicModels(
|
disableNonPublicModels: parseDisableNonPublicModels(
|
||||||
(record as JsonRecord).disable_non_public_models ??
|
(record as JsonRecord).disable_non_public_models ??
|
||||||
(record as JsonRecord).disableNonPublicModels
|
(record as JsonRecord).disableNonPublicModels,
|
||||||
),
|
),
|
||||||
allowUsageCommand: parseAllowUsageCommand(
|
allowUsageCommand: parseAllowUsageCommand(
|
||||||
(record as JsonRecord).allow_usage_command ?? (record as JsonRecord).allowUsageCommand
|
(record as JsonRecord).allow_usage_command ?? (record as JsonRecord).allowUsageCommand,
|
||||||
),
|
),
|
||||||
chaosModeEnabled: parseChaosModeEnabled(
|
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),
|
...parseApiKeyUsageLimitFields(record as JsonRecord),
|
||||||
};
|
};
|
||||||
@@ -1403,7 +1402,7 @@ export async function getApiKeyMetadata(
|
|||||||
*/
|
*/
|
||||||
export async function isModelAllowedForKey(
|
export async function isModelAllowedForKey(
|
||||||
key: string | null | undefined,
|
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 key provided, allow (request may be using different auth method like JWT)
|
||||||
// If no modelId provided, deny (invalid request)
|
// If no modelId provided, deny (invalid request)
|
||||||
@@ -1413,12 +1412,13 @@ export async function isModelAllowedForKey(
|
|||||||
// Create cache key
|
// Create cache key
|
||||||
const cacheKey = `${key}:${modelId}`;
|
const cacheKey = `${key}:${modelId}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const catalogGeneration = getModelCatalogCacheVersion();
|
||||||
const usesSettingDependentClaudeRouting = isPotentialUnprefixedClaudeCodeModel(modelId);
|
const usesSettingDependentClaudeRouting = isPotentialUnprefixedClaudeCodeModel(modelId);
|
||||||
|
|
||||||
// Check permission cache
|
// Check permission cache
|
||||||
const cached = _modelPermissionCache.get(cacheKey);
|
const cached = getCachedModelPermission(cacheKey, now, catalogGeneration);
|
||||||
if (!usesSettingDependentClaudeRouting && cached && now - cached.timestamp < CACHE_TTL) {
|
if (!usesSettingDependentClaudeRouting && cached !== undefined) {
|
||||||
return cached.allowed;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = await getApiKeyMetadata(key);
|
const metadata = await getApiKeyMetadata(key);
|
||||||
@@ -1479,8 +1479,8 @@ export async function isModelAllowedForKey(
|
|||||||
}
|
}
|
||||||
// Cache the result
|
// Cache the result
|
||||||
if (!usesSettingDependentClaudeRouting) {
|
if (!usesSettingDependentClaudeRouting) {
|
||||||
evictIfNeeded(_modelPermissionCache);
|
evictModelPermissionCache();
|
||||||
_modelPermissionCache.set(cacheKey, { allowed, timestamp: now });
|
setCachedModelPermission(cacheKey, allowed, now, catalogGeneration);
|
||||||
}
|
}
|
||||||
|
|
||||||
return allowed;
|
return allowed;
|
||||||
@@ -1506,8 +1506,6 @@ function clearPreparedStatementCache() {
|
|||||||
*/
|
*/
|
||||||
export function clearApiKeyCaches() {
|
export function clearApiKeyCaches() {
|
||||||
invalidateCaches();
|
invalidateCaches();
|
||||||
_lastUsedUpdateCache.clear();
|
|
||||||
_modelPermissionCache.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
62
src/lib/db/apiKeys/modelPermissionCache.ts
Normal file
62
src/lib/db/apiKeys/modelPermissionCache.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const MODEL_PERMISSION_CACHE_TTL = 60 * 1000;
|
||||||
|
|
||||||
|
interface ModelPermissionCacheValue {
|
||||||
|
allowed: boolean;
|
||||||
|
timestamp: number;
|
||||||
|
generation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _modelPermissionCache = new Map<string, ModelPermissionCacheValue>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
import { getFeatureFlagOverride } from "./featureFlags";
|
import { getFeatureFlagOverride } from "./featureFlags";
|
||||||
import { getDbInstance } from "./core";
|
import { getDbInstance } from "./core";
|
||||||
|
import { finishModelCatalogWriteWithoutBackup } from "./models/modelCatalogWriteSignals";
|
||||||
|
|
||||||
const NAMESPACE = "ccDiscoveryAliases";
|
const NAMESPACE = "ccDiscoveryAliases";
|
||||||
const FLAG_KEY = "EXPOSE_CC_DISCOVERY_ALIASES";
|
const FLAG_KEY = "EXPOSE_CC_DISCOVERY_ALIASES";
|
||||||
@@ -71,13 +72,15 @@ export function setCcAliasProviderSetting(providerId: string, v: CcAliasSetting)
|
|||||||
const key = providerKey(providerId);
|
const key = providerKey(providerId);
|
||||||
if (v === null) {
|
if (v === null) {
|
||||||
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(NAMESPACE, key);
|
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(NAMESPACE, key);
|
||||||
|
finishModelCatalogWriteWithoutBackup();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||||
NAMESPACE,
|
NAMESPACE,
|
||||||
key,
|
key,
|
||||||
v
|
v,
|
||||||
);
|
);
|
||||||
|
finishModelCatalogWriteWithoutBackup();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCcAliasModelSetting(providerId: string, modelId: string): CcAliasSetting {
|
export function getCcAliasModelSetting(providerId: string, modelId: string): CcAliasSetting {
|
||||||
@@ -91,19 +94,21 @@ export function getCcAliasModelSetting(providerId: string, modelId: string): CcA
|
|||||||
export function setCcAliasModelSetting(
|
export function setCcAliasModelSetting(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
v: CcAliasSetting
|
v: CcAliasSetting,
|
||||||
): void {
|
): void {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
const key = modelKey(providerId, modelId);
|
const key = modelKey(providerId, modelId);
|
||||||
if (v === null) {
|
if (v === null) {
|
||||||
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(NAMESPACE, key);
|
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(NAMESPACE, key);
|
||||||
|
finishModelCatalogWriteWithoutBackup();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||||
NAMESPACE,
|
NAMESPACE,
|
||||||
key,
|
key,
|
||||||
v
|
v,
|
||||||
);
|
);
|
||||||
|
finishModelCatalogWriteWithoutBackup();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,9 +8,16 @@
|
|||||||
|
|
||||||
import { FEATURE_FLAG_DEFINITIONS } from "@/shared/constants/featureFlagDefinitions";
|
import { FEATURE_FLAG_DEFINITIONS } from "@/shared/constants/featureFlagDefinitions";
|
||||||
import { getDbInstance } from "./core";
|
import { getDbInstance } from "./core";
|
||||||
|
import { finishModelCatalogWriteWithoutBackup } from "./models/modelCatalogWriteSignals";
|
||||||
|
|
||||||
const NAMESPACE = "feature_flags";
|
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.
|
* 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)
|
!definition.enumValues.includes(value)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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();
|
const db = getDbInstance();
|
||||||
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||||
NAMESPACE,
|
NAMESPACE,
|
||||||
key,
|
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 {
|
export function removeFeatureFlagOverride(key: string): void {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(NAMESPACE, key);
|
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 {
|
export function clearAllFeatureFlagOverrides(): void {
|
||||||
const db = getDbInstance();
|
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);
|
db.prepare("DELETE FROM key_value WHERE namespace = ?").run(NAMESPACE);
|
||||||
|
if (hadRelevantOverride) {
|
||||||
|
finishModelCatalogWriteWithoutBackup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,14 @@
|
|||||||
* models/; this file re-exports their public APIs for backward compatibility.
|
* 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 { getDbInstance } from "./core";
|
||||||
import { backupDbFile } from "./backup";
|
|
||||||
import { getProviderConnectionsCount } from "./providers";
|
import { getProviderConnectionsCount } from "./providers";
|
||||||
import { type JsonRecord, getKeyValue } from "./models/shared";
|
import { type JsonRecord, asRecord, toNonEmptyString, getKeyValue } from "./models/shared";
|
||||||
import {
|
import {
|
||||||
normalizeSyncedAvailableModels,
|
finishSyncedAvailableModelsWrite,
|
||||||
type SyncedAvailableModel,
|
persistCanonicalSyncedAvailableModels,
|
||||||
type SyncedAvailableModelInput,
|
} from "./models/syncedAvailableModelPersistence";
|
||||||
} from "./models/synced";
|
import { finishModelCatalogWriteWithBackup } from "./models/modelCatalogWriteSignals";
|
||||||
import {
|
import {
|
||||||
readCompatList,
|
readCompatList,
|
||||||
writeCompatList,
|
writeCompatList,
|
||||||
@@ -47,7 +44,6 @@ export {
|
|||||||
deleteModelAliasesForProvider,
|
deleteModelAliasesForProvider,
|
||||||
} from "./models/aliases";
|
} from "./models/aliases";
|
||||||
export { getMitmAlias, setMitmAliasAll } from "./models/mitmAlias";
|
export { getMitmAlias, setMitmAliasAll } from "./models/mitmAlias";
|
||||||
export type { SyncedAvailableModel } from "./models/synced";
|
|
||||||
|
|
||||||
// ──────────────── Custom Models ────────────────
|
// ──────────────── Custom Models ────────────────
|
||||||
|
|
||||||
@@ -109,7 +105,7 @@ export async function addCustomModel(
|
|||||||
tokenLimits: { inputTokenLimit?: number; outputTokenLimit?: number } = {},
|
tokenLimits: { inputTokenLimit?: number; outputTokenLimit?: number } = {},
|
||||||
// #1904: optional manual vision-capability override for the "add custom model"
|
// #1904: optional manual vision-capability override for the "add custom model"
|
||||||
// form — read back by getCustomVisionCapabilityFields() in the /v1/models catalog.
|
// form — read back by getCustomVisionCapabilityFields() in the /v1/models catalog.
|
||||||
supportsVision?: boolean
|
supportsVision?: boolean,
|
||||||
) {
|
) {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
const row = db
|
const row = db
|
||||||
@@ -138,9 +134,9 @@ export async function addCustomModel(
|
|||||||
};
|
};
|
||||||
models.push(model);
|
models.push(model);
|
||||||
db.prepare(
|
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));
|
).run(providerId, JSON.stringify(models));
|
||||||
backupDbFile("pre-write");
|
finishModelCatalogWriteWithBackup();
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +158,7 @@ export async function replaceCustomModels(
|
|||||||
supportsThinking?: boolean;
|
supportsThinking?: boolean;
|
||||||
targetFormat?: string;
|
targetFormat?: string;
|
||||||
}>,
|
}>,
|
||||||
{ allowEmpty = false }: { allowEmpty?: boolean } = {}
|
{ allowEmpty = false }: { allowEmpty?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
// Guard: skip destructive clear when the caller hasn't explicitly opted in.
|
// Guard: skip destructive clear when the caller hasn't explicitly opted in.
|
||||||
// This prevents callers from wiping manually added models when the
|
// This prevents callers from wiping manually added models when the
|
||||||
@@ -235,15 +231,15 @@ export async function replaceCustomModels(
|
|||||||
|
|
||||||
if (merged.length === 0) {
|
if (merged.length === 0) {
|
||||||
db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run(
|
db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run(
|
||||||
providerId
|
providerId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
db.prepare(
|
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));
|
).run(providerId, JSON.stringify(merged));
|
||||||
}
|
}
|
||||||
|
|
||||||
backupDbFile("pre-write");
|
finishModelCatalogWriteWithBackup();
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,20 +269,20 @@ export async function deleteImportedCustomModels(providerId: string): Promise<st
|
|||||||
|
|
||||||
if (retained.length === 0) {
|
if (retained.length === 0) {
|
||||||
db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run(
|
db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run(
|
||||||
providerId
|
providerId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run(
|
db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run(
|
||||||
JSON.stringify(retained),
|
JSON.stringify(retained),
|
||||||
providerId
|
providerId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const removedIds = removed.flatMap((model) =>
|
const removedIds = removed.flatMap((model) =>
|
||||||
typeof model.id === "string" && model.id ? [model.id] : []
|
typeof model.id === "string" && model.id ? [model.id] : [],
|
||||||
);
|
);
|
||||||
for (const modelId of removedIds) removeModelCompatOverride(providerId, modelId);
|
for (const modelId of removedIds) removeModelCompatOverride(providerId, modelId);
|
||||||
backupDbFile("pre-write");
|
finishModelCatalogWriteWithBackup();
|
||||||
return removedIds;
|
return removedIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,17 +303,17 @@ export async function removeCustomModel(providerId: string, modelId: string) {
|
|||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run(
|
db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run(
|
||||||
providerId
|
providerId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run(
|
db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run(
|
||||||
JSON.stringify(filtered),
|
JSON.stringify(filtered),
|
||||||
providerId
|
providerId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeModelCompatOverride(providerId, modelId);
|
removeModelCompatOverride(providerId, modelId);
|
||||||
backupDbFile("pre-write");
|
finishModelCatalogWriteWithBackup();
|
||||||
return true;
|
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
|
// Each connection stores its own model list. Reads union across all connections
|
||||||
// for a provider. Deleting a connection removes only its models.
|
// 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<SyncedAvailableModel, "source"> & {
|
||||||
|
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<string, SyncedAvailableModel>();
|
||||||
|
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.
|
* Get synced available models for a specific provider connection.
|
||||||
*/
|
*/
|
||||||
export async function getSyncedAvailableModelsForConnection(
|
export async function getSyncedAvailableModelsForConnection(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
connectionId: string
|
connectionId: string,
|
||||||
): Promise<SyncedAvailableModel[]> {
|
): Promise<SyncedAvailableModel[]> {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
const key = `${providerId}:${connectionId}`;
|
const key = `${providerId}:${connectionId}`;
|
||||||
@@ -342,7 +436,7 @@ export async function getSyncedAvailableModelsForConnection(
|
|||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
try {
|
try {
|
||||||
const models = JSON.parse(value);
|
const models = JSON.parse(value);
|
||||||
return normalizeSyncedAvailableModels(models, providerId);
|
return normalizeSyncedAvailableModels(models);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -352,19 +446,19 @@ export async function getSyncedAvailableModelsForConnection(
|
|||||||
* Get all synced available models for a provider, unioned across all connections.
|
* Get all synced available models for a provider, unioned across all connections.
|
||||||
*/
|
*/
|
||||||
export async function getSyncedAvailableModels(
|
export async function getSyncedAvailableModels(
|
||||||
providerId: string
|
providerId: string,
|
||||||
): Promise<SyncedAvailableModel[]> {
|
): Promise<SyncedAvailableModel[]> {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.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}:%`);
|
.all(`${providerId}:%`);
|
||||||
const map = new Map<string, SyncedAvailableModel>();
|
const map = new Map<string, SyncedAvailableModel>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const { key, value } = getKeyValue(row);
|
const { key, value } = getKeyValue(row);
|
||||||
if (!key || value === null) continue;
|
if (!key || value === null) continue;
|
||||||
const models = normalizeSyncedAvailableModels(JSON.parse(value), providerId);
|
const models = normalizeSyncedAvailableModels(JSON.parse(value));
|
||||||
for (const m of models) {
|
for (const m of models) {
|
||||||
if (m.id) map.set(m.id, m);
|
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.
|
* Get synced available models for a provider grouped by connection id.
|
||||||
*/
|
*/
|
||||||
export async function getSyncedAvailableModelsByConnection(
|
export async function getSyncedAvailableModelsByConnection(
|
||||||
providerId: string
|
providerId: string,
|
||||||
): Promise<Record<string, SyncedAvailableModel[]>> {
|
): Promise<Record<string, SyncedAvailableModel[]>> {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
const prefix = `${providerId}:`;
|
const prefix = `${providerId}:`;
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.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}%`);
|
.all(`${prefix}%`);
|
||||||
const result: Record<string, SyncedAvailableModel[]> = {};
|
const result: Record<string, SyncedAvailableModel[]> = {};
|
||||||
@@ -391,7 +485,7 @@ export async function getSyncedAvailableModelsByConnection(
|
|||||||
if (!key || value === null || !key.startsWith(prefix)) continue;
|
if (!key || value === null || !key.startsWith(prefix)) continue;
|
||||||
try {
|
try {
|
||||||
const connectionId = key.slice(prefix.length);
|
const connectionId = key.slice(prefix.length);
|
||||||
result[connectionId] = normalizeSyncedAvailableModels(JSON.parse(value), providerId);
|
result[connectionId] = normalizeSyncedAvailableModels(JSON.parse(value));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed legacy entries.
|
// Ignore malformed legacy entries.
|
||||||
}
|
}
|
||||||
@@ -416,7 +510,7 @@ export async function getAllSyncedAvailableModels(): Promise<
|
|||||||
if (!key || value === null) continue;
|
if (!key || value === null) continue;
|
||||||
const providerId = key.split(":")[0];
|
const providerId = key.split(":")[0];
|
||||||
if (!byProvider.has(providerId)) byProvider.set(providerId, new Map());
|
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)!;
|
const map = byProvider.get(providerId)!;
|
||||||
for (const m of models) {
|
for (const m of models) {
|
||||||
if (m.id) map.set(m.id, m);
|
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, '$.id'),
|
||||||
json_extract(synced_model.value, '$.name'),
|
json_extract(synced_model.value, '$.name'),
|
||||||
json_extract(synced_model.value, '$.model')
|
json_extract(synced_model.value, '$.model')
|
||||||
) = ?`
|
) = ?`,
|
||||||
)
|
)
|
||||||
.all(modelId) as Array<{ provider?: unknown }>;
|
.all(modelId) as Array<{ provider?: unknown }>;
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
.map((row) => row.provider)
|
.map((row) => row.provider)
|
||||||
.filter((provider): provider is string => typeof provider === "string" && provider.length > 0)
|
.filter((provider): provider is string => typeof provider === "string" && provider.length > 0);
|
||||||
.filter((provider) => !isRetiredGitHubCopilotModelId(provider, modelId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -470,9 +563,8 @@ export async function getActiveProvidersWithSyncedModel(modelId: string): Promis
|
|||||||
export async function replaceSyncedAvailableModelsForConnection(
|
export async function replaceSyncedAvailableModelsForConnection(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
models: SyncedAvailableModelInput[]
|
models: SyncedAvailableModelInput[],
|
||||||
): Promise<SyncedAvailableModel[]> {
|
): Promise<SyncedAvailableModel[]> {
|
||||||
const db = getDbInstance();
|
|
||||||
const key = `${providerId}:${connectionId}`;
|
const key = `${providerId}:${connectionId}`;
|
||||||
// #3199: drop ids the operator DELETED (trash) so a re-fetch does not re-import
|
// #3199: drop ids the operator DELETED (trash) so a re-fetch does not re-import
|
||||||
// a model that was explicitly removed.
|
// 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
|
// 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
|
// churning back on through the managed-alias path ("Auto Sync Enabling all
|
||||||
// Models"). See getModelIsDeleted for the legacy-row caveat.
|
// Models"). See getModelIsDeleted for the legacy-row caveat.
|
||||||
const normalizedModels = normalizeSyncedAvailableModels(models, providerId).filter(
|
const normalizedModels = normalizeSyncedAvailableModels(models).filter(
|
||||||
(m) => !getModelIsDeleted(providerId, m.id)
|
(m) => !getModelIsDeleted(providerId, m.id),
|
||||||
);
|
);
|
||||||
if (normalizedModels.length === 0) {
|
persistCanonicalSyncedAvailableModels(key, normalizedModels, normalizeSyncedAvailableModels);
|
||||||
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");
|
|
||||||
// Return the full unioned list for the provider
|
// Return the full unioned list for the provider
|
||||||
return getSyncedAvailableModels(providerId);
|
return getSyncedAvailableModels(providerId);
|
||||||
}
|
}
|
||||||
@@ -504,13 +587,13 @@ export async function replaceSyncedAvailableModelsForConnection(
|
|||||||
*/
|
*/
|
||||||
export async function removeSyncedAvailableModel(
|
export async function removeSyncedAvailableModel(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
modelId: string
|
modelId: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
const prefix = `${providerId}:`;
|
const prefix = `${providerId}:`;
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.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}%`);
|
.all(`${prefix}%`);
|
||||||
|
|
||||||
@@ -528,26 +611,25 @@ export async function removeSyncedAvailableModel(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = normalizeSyncedAvailableModels(parsedModels, providerId);
|
const models = normalizeSyncedAvailableModels(parsedModels);
|
||||||
const filtered = models.filter((m) => m.id !== modelId);
|
const filtered = models.filter((m) => m.id !== modelId);
|
||||||
if (filtered.length !== models.length) {
|
if (filtered.length !== models.length) {
|
||||||
removedAny = true;
|
removedAny = true;
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?"
|
"DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?",
|
||||||
).run(key);
|
).run(key);
|
||||||
} else {
|
} else {
|
||||||
db.prepare(
|
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);
|
).run(JSON.stringify(filtered), key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (removedAny) backupDbFile("pre-write");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
removeModel();
|
removeModel();
|
||||||
|
if (removedAny) finishSyncedAvailableModelsWrite();
|
||||||
return removedAny;
|
return removedAny;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,14 +639,14 @@ export async function removeSyncedAvailableModel(
|
|||||||
*/
|
*/
|
||||||
export async function deleteSyncedAvailableModelsForConnection(
|
export async function deleteSyncedAvailableModelsForConnection(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
connectionId: string
|
connectionId: string,
|
||||||
): Promise<SyncedAvailableModel[]> {
|
): Promise<SyncedAvailableModel[]> {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
const key = `${providerId}:${connectionId}`;
|
const key = `${providerId}:${connectionId}`;
|
||||||
db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run(
|
const result = db
|
||||||
key
|
.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?")
|
||||||
);
|
.run(key);
|
||||||
backupDbFile("pre-write");
|
if (result.changes > 0) finishSyncedAvailableModelsWrite();
|
||||||
return getSyncedAvailableModels(providerId);
|
return getSyncedAvailableModels(providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +656,7 @@ export async function deleteSyncedAvailableModelsForConnection(
|
|||||||
*/
|
*/
|
||||||
export async function cleanupProviderModelsAfterConnectionDelete(
|
export async function cleanupProviderModelsAfterConnectionDelete(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
connectionId: string
|
connectionId: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
remainingConnections: number;
|
remainingConnections: number;
|
||||||
removedImportedModelIds: string[];
|
removedImportedModelIds: string[];
|
||||||
@@ -582,7 +664,7 @@ export async function cleanupProviderModelsAfterConnectionDelete(
|
|||||||
}> {
|
}> {
|
||||||
const remainingSyncedModels = await deleteSyncedAvailableModelsForConnection(
|
const remainingSyncedModels = await deleteSyncedAvailableModelsForConnection(
|
||||||
providerId,
|
providerId,
|
||||||
connectionId
|
connectionId,
|
||||||
);
|
);
|
||||||
const remainingConnections = getProviderConnectionsCount({ provider: providerId });
|
const remainingConnections = getProviderConnectionsCount({ provider: providerId });
|
||||||
const removedImportedModelIds =
|
const removedImportedModelIds =
|
||||||
@@ -600,11 +682,12 @@ export async function deleteSyncedAvailableModelsForProvider(providerId: string)
|
|||||||
const keyPrefix = `${providerId}:`;
|
const keyPrefix = `${providerId}:`;
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.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);
|
.run(keyPrefix.length, keyPrefix);
|
||||||
backupDbFile("pre-write");
|
const changes = Number(result.changes || 0);
|
||||||
return Number(result.changes || 0);
|
if (changes > 0) finishSyncedAvailableModelsWrite();
|
||||||
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -613,7 +696,7 @@ export async function deleteSyncedAvailableModelsForProvider(providerId: string)
|
|||||||
*/
|
*/
|
||||||
export async function pruneStaleSyncedAvailableModelsForProvider(
|
export async function pruneStaleSyncedAvailableModelsForProvider(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
allowedConnectionIds: string[]
|
allowedConnectionIds: string[],
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
if (allowedConnectionIds.length === 0) {
|
if (allowedConnectionIds.length === 0) {
|
||||||
@@ -624,11 +707,12 @@ export async function pruneStaleSyncedAvailableModelsForProvider(
|
|||||||
const allowedKeys = allowedConnectionIds.map((id) => `${providerId}:${id}`);
|
const allowedKeys = allowedConnectionIds.map((id) => `${providerId}:${id}`);
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.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);
|
.run(`${keyPrefix}%`, ...allowedKeys);
|
||||||
backupDbFile("pre-write");
|
const changes = Number(result.changes || 0);
|
||||||
return Number(result.changes || 0);
|
if (changes > 0) finishSyncedAvailableModelsWrite();
|
||||||
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -640,7 +724,7 @@ export async function pruneStaleSyncedAvailableModelsForProvider(
|
|||||||
function applyTriStateBooleanOverride(
|
function applyTriStateBooleanOverride(
|
||||||
next: JsonRecord,
|
next: JsonRecord,
|
||||||
updates: Record<string, unknown>,
|
updates: Record<string, unknown>,
|
||||||
field: string
|
field: string,
|
||||||
): void {
|
): void {
|
||||||
if (!Object.prototype.hasOwnProperty.call(updates, field)) return;
|
if (!Object.prototype.hasOwnProperty.call(updates, field)) return;
|
||||||
if (updates[field] === null) {
|
if (updates[field] === null) {
|
||||||
@@ -653,7 +737,7 @@ function applyTriStateBooleanOverride(
|
|||||||
export async function updateCustomModel(
|
export async function updateCustomModel(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
updates: Record<string, unknown> = {}
|
updates: Record<string, unknown> = {},
|
||||||
) {
|
) {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
const row = db
|
const row = db
|
||||||
@@ -681,7 +765,7 @@ export async function updateCustomModel(
|
|||||||
currentCompat,
|
currentCompat,
|
||||||
updates.compatByProtocol as Partial<
|
updates.compatByProtocol as Partial<
|
||||||
Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>
|
Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>
|
||||||
>
|
>,
|
||||||
);
|
);
|
||||||
if (!compatByProtocolHasEntries(mergedCompat)) mergedCompat = undefined;
|
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(
|
db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run(
|
||||||
JSON.stringify(models),
|
JSON.stringify(models),
|
||||||
providerId
|
providerId,
|
||||||
);
|
);
|
||||||
|
|
||||||
backupDbFile("pre-write");
|
finishModelCatalogWriteWithBackup();
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,7 +842,7 @@ function getCustomModelRow(providerId: string, modelId: string): JsonRecord | nu
|
|||||||
typeof x === "object" &&
|
typeof x === "object" &&
|
||||||
!Array.isArray(x) &&
|
!Array.isArray(x) &&
|
||||||
typeof (x as { id?: string }).id === "string" &&
|
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;
|
)) as JsonRecord | undefined;
|
||||||
return m ?? null;
|
return m ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -775,7 +859,7 @@ function getCustomModelRow(providerId: string, modelId: string): JsonRecord | nu
|
|||||||
export function getModelNormalizeToolCallId(
|
export function getModelNormalizeToolCallId(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
sourceFormat?: string | null
|
sourceFormat?: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
const m = getCustomModelRow(providerId, modelId);
|
const m = getCustomModelRow(providerId, modelId);
|
||||||
const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null;
|
const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null;
|
||||||
@@ -808,7 +892,7 @@ export function getModelNormalizeToolCallId(
|
|||||||
export function getModelPreserveOpenAIDeveloperRole(
|
export function getModelPreserveOpenAIDeveloperRole(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
sourceFormat?: string | null
|
sourceFormat?: string | null,
|
||||||
): boolean | undefined {
|
): boolean | undefined {
|
||||||
const m = getCustomModelRow(providerId, modelId);
|
const m = getCustomModelRow(providerId, modelId);
|
||||||
const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null;
|
const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null;
|
||||||
@@ -857,45 +941,35 @@ export function getModelIsHidden(providerId: string, modelId: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export function getHiddenModelsByProvider(): Map<string, Set<string>> {
|
export function getHiddenModelsByProvider(): Map<string, Set<string>> {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
const visibilityByProvider = new Map<string, Map<string, boolean>>();
|
const result = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
// Query all rows from key_value for both namespaces
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.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) {
|
for (const row of rows) {
|
||||||
if (row.namespace !== namespace || !row.value) continue;
|
if (!row.value) continue;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(row.value);
|
const parsed = JSON.parse(row.value);
|
||||||
if (!Array.isArray(parsed)) continue;
|
if (!Array.isArray(parsed)) continue;
|
||||||
for (const entry of parsed) {
|
for (const entry of parsed) {
|
||||||
if (!entry || typeof entry !== "object") continue;
|
if (entry && typeof entry === "object" && entry.isHidden) {
|
||||||
const modelId = (entry as { id?: unknown }).id;
|
const modelId = entry.id;
|
||||||
if (typeof modelId !== "string" || modelId.length === 0) continue;
|
if (typeof modelId === "string" && modelId.length > 0) {
|
||||||
if (!Object.prototype.hasOwnProperty.call(entry, "isHidden")) continue;
|
if (!result.has(row.key)) result.set(row.key, new Set());
|
||||||
let visibility = visibilityByProvider.get(row.key);
|
result.get(row.key)!.add(modelId);
|
||||||
if (!visibility) {
|
}
|
||||||
visibility = new Map<string, boolean>();
|
|
||||||
visibilityByProvider.set(row.key, visibility);
|
|
||||||
}
|
}
|
||||||
visibility.set(modelId, Boolean((entry as { isHidden?: unknown }).isHidden));
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Skip malformed entries
|
// Skip malformed entries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return new Map(
|
return result;
|
||||||
[...visibilityByProvider].flatMap(([providerId, visibility]) => {
|
|
||||||
const hiddenModels = [...visibility].flatMap(([modelId, isHidden]) =>
|
|
||||||
isHidden ? [modelId] : []
|
|
||||||
);
|
|
||||||
return hiddenModels.length > 0 ? [[providerId, new Set(hiddenModels)] as const] : [];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -960,7 +1034,7 @@ export function setModelIsHidden(providerId: string, modelId: string, hidden: bo
|
|||||||
|
|
||||||
function readUpstreamFromJsonRecord(
|
function readUpstreamFromJsonRecord(
|
||||||
row: JsonRecord | null | undefined,
|
row: JsonRecord | null | undefined,
|
||||||
key: "upstreamHeaders"
|
key: "upstreamHeaders",
|
||||||
): Record<string, string> | undefined {
|
): Record<string, string> | undefined {
|
||||||
if (!row) return undefined;
|
if (!row) return undefined;
|
||||||
const raw = row[key];
|
const raw = row[key];
|
||||||
@@ -982,7 +1056,7 @@ function readUpstreamFromJsonRecord(
|
|||||||
export function getModelUpstreamExtraHeaders(
|
export function getModelUpstreamExtraHeaders(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
sourceFormat?: string | null
|
sourceFormat?: string | null,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null;
|
const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null;
|
||||||
const m = getCustomModelRow(providerId, modelId);
|
const m = getCustomModelRow(providerId, modelId);
|
||||||
@@ -1009,8 +1083,8 @@ export function getModelUpstreamExtraHeaders(
|
|||||||
Object.assign(
|
Object.assign(
|
||||||
base,
|
base,
|
||||||
sanitizeUpstreamHeadersMap(
|
sanitizeUpstreamHeadersMap(
|
||||||
co.compatByProtocol[protocol]!.upstreamHeaders as Record<string, unknown>
|
co.compatByProtocol[protocol]!.upstreamHeaders as Record<string, unknown>,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/** db/models/aliases.ts — model alias CRUD (modelAliases namespace). */
|
/** db/models/aliases.ts — model alias CRUD (modelAliases namespace). */
|
||||||
|
|
||||||
import { getDbInstance } from "../core";
|
import { getDbInstance } from "../core";
|
||||||
import { backupDbFile } from "../backup";
|
|
||||||
import { getKeyValue } from "./shared";
|
import { getKeyValue } from "./shared";
|
||||||
|
import { finishModelCatalogWriteWithBackup } from "./modelCatalogWriteSignals";
|
||||||
|
|
||||||
export async function getModelAliases() {
|
export async function getModelAliases() {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
@@ -21,15 +21,15 @@ export async function getModelAliases() {
|
|||||||
export async function setModelAlias(alias: string, model: unknown) {
|
export async function setModelAlias(alias: string, model: unknown) {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
db.prepare(
|
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));
|
).run(alias, JSON.stringify(model));
|
||||||
backupDbFile("pre-write");
|
finishModelCatalogWriteWithBackup();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteModelAlias(alias: string) {
|
export async function deleteModelAlias(alias: string) {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
db.prepare("DELETE FROM key_value WHERE namespace = 'modelAliases' AND key = ?").run(alias);
|
db.prepare("DELETE FROM key_value WHERE namespace = 'modelAliases' AND key = ?").run(alias);
|
||||||
backupDbFile("pre-write");
|
finishModelCatalogWriteWithBackup();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
/** db/models/compat.ts — model-compat overrides (normalizeToolCallId, per-protocol flags, upstream headers). */
|
/** db/models/compat.ts — model-compat overrides (normalizeToolCallId, per-protocol flags, upstream headers). */
|
||||||
|
|
||||||
import { getDbInstance } from "../core";
|
import { getDbInstance } from "../core";
|
||||||
import { backupDbFile } from "../backup";
|
|
||||||
import {
|
import {
|
||||||
MODEL_COMPAT_PROTOCOL_KEYS,
|
MODEL_COMPAT_PROTOCOL_KEYS,
|
||||||
type ModelCompatProtocolKey,
|
type ModelCompatProtocolKey,
|
||||||
} from "@/shared/constants/modelCompat";
|
} from "@/shared/constants/modelCompat";
|
||||||
import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders";
|
import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders";
|
||||||
import { getKeyValue } from "./shared";
|
import { getKeyValue } from "./shared";
|
||||||
|
import { finishModelCatalogWriteWithBackup } from "./modelCatalogWriteSignals";
|
||||||
|
|
||||||
/** Built-in / alias models: tool-call + developer-role flags without a full custom row */
|
/** Built-in / alias models: tool-call + developer-role flags without a full custom row */
|
||||||
const MODEL_COMPAT_NAMESPACE = "modelCompatOverrides";
|
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). */
|
/** Sanitize user-provided upstream header map (used when persisting and when reading for requests). */
|
||||||
export function sanitizeUpstreamHeadersMap(
|
export function sanitizeUpstreamHeadersMap(
|
||||||
raw: Record<string, unknown> | null | undefined
|
raw: Record<string, unknown> | null | undefined,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
if (!raw || typeof raw !== "object") return out;
|
if (!raw || typeof raw !== "object") return out;
|
||||||
@@ -66,7 +66,7 @@ export function sanitizeUpstreamHeadersMap(
|
|||||||
|
|
||||||
export function deepMergeCompatByProtocol(
|
export function deepMergeCompatByProtocol(
|
||||||
prev: CompatByProtocolMap | undefined,
|
prev: CompatByProtocolMap | undefined,
|
||||||
patch: Partial<Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>>
|
patch: Partial<Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>>,
|
||||||
): CompatByProtocolMap {
|
): CompatByProtocolMap {
|
||||||
const out: CompatByProtocolMap = { ...(prev || {}) };
|
const out: CompatByProtocolMap = { ...(prev || {}) };
|
||||||
for (const key of Object.keys(patch) as ModelCompatProtocolKey[]) {
|
for (const key of Object.keys(patch) as ModelCompatProtocolKey[]) {
|
||||||
@@ -140,16 +140,16 @@ export function writeCompatList(providerId: string, list: ModelCompatOverride[])
|
|||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(
|
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(
|
||||||
MODEL_COMPAT_NAMESPACE,
|
MODEL_COMPAT_NAMESPACE,
|
||||||
providerId
|
providerId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||||
MODEL_COMPAT_NAMESPACE,
|
MODEL_COMPAT_NAMESPACE,
|
||||||
providerId,
|
providerId,
|
||||||
JSON.stringify(list)
|
JSON.stringify(list),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
backupDbFile("pre-write");
|
finishModelCatalogWriteWithBackup();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getModelCompatOverrides(providerId: string): ModelCompatOverride[] {
|
export function getModelCompatOverrides(providerId: string): ModelCompatOverride[] {
|
||||||
@@ -178,7 +178,7 @@ export function compatByProtocolHasEntries(map: CompatByProtocolMap | undefined)
|
|||||||
export function mergeModelCompatOverride(
|
export function mergeModelCompatOverride(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
patch: ModelCompatPatch
|
patch: ModelCompatPatch,
|
||||||
) {
|
) {
|
||||||
const list = readCompatList(providerId);
|
const list = readCompatList(providerId);
|
||||||
const idx = list.findIndex((e) => e.id === modelId);
|
const idx = list.findIndex((e) => e.id === modelId);
|
||||||
|
|||||||
11
src/lib/db/models/modelCatalogWriteSignals.ts
Normal file
11
src/lib/db/models/modelCatalogWriteSignals.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
52
src/lib/db/models/syncedAvailableModelPersistence.ts
Normal file
52
src/lib/db/models/syncedAvailableModelPersistence.ts
Normal file
@@ -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<T> = (models: unknown) => T[];
|
||||||
|
|
||||||
|
export function finishSyncedAvailableModelsWrite(): void {
|
||||||
|
backupDbFile("pre-write");
|
||||||
|
invalidateModelCatalogCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistCanonicalSyncedAvailableModels<T>(
|
||||||
|
key: string,
|
||||||
|
normalizedModels: T[],
|
||||||
|
normalizeModels: ModelNormalizer<T>
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getDbInstance } from "./core";
|
import { getDbInstance } from "./core";
|
||||||
|
import { invalidateModelCatalogCache } from "./readCache";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -113,7 +114,11 @@ export function listGroups(): QuotaGroup[] {
|
|||||||
*/
|
*/
|
||||||
export function renameGroup(id: string, name: string): boolean {
|
export function renameGroup(id: string, name: string): boolean {
|
||||||
const result = getDb().prepare("UPDATE quota_groups SET name = ? WHERE id = ?").run(name, id);
|
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.
|
// Protect the seed group.
|
||||||
if (id === "group-demo") {
|
if (id === "group-demo") {
|
||||||
throw new Error(
|
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.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getDbInstance } from "./core";
|
import { getDbInstance } from "./core";
|
||||||
|
import { clearApiKeyCaches } from "./apiKeys";
|
||||||
|
import { invalidateModelCatalogCache } from "./readCache";
|
||||||
// Phase B2: auto-mint/prune quotaShared-* combos when pool allocations change.
|
// Phase B2: auto-mint/prune quotaShared-* combos when pool allocations change.
|
||||||
// Imported lazily (dynamic import in the hook) to avoid circular-dependency
|
// Imported lazily (dynamic import in the hook) to avoid circular-dependency
|
||||||
// risk between db/ and quota/ modules. The import is fire-and-forget; combo
|
// risk between db/ and quota/ modules. The import is fire-and-forget; combo
|
||||||
@@ -30,7 +32,7 @@ async function removeQuotaCombosGuarded(poolId: string): Promise<void> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[quota-pools] removeQuotaCombosForPool failed (non-fatal):",
|
"[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);
|
const providers = rows.map((r) => r.provider).filter(Boolean);
|
||||||
if (new Set(providers).size > 1) {
|
if (new Set(providers).size > 1) {
|
||||||
throw new Error(
|
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[] {
|
function getConnectionIds(poolId: string, fallbackConnectionId: string): string[] {
|
||||||
const rows = getDb()
|
const rows = getDb()
|
||||||
.prepare<PoolConnectionRow>(
|
.prepare<PoolConnectionRow>(
|
||||||
"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);
|
.all(poolId);
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
@@ -210,7 +212,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] {
|
|||||||
// Batch allocations: 1 query for all pools
|
// Batch allocations: 1 query for all pools
|
||||||
const allocRows = db
|
const allocRows = db
|
||||||
.prepare<AllocationRow>(
|
.prepare<AllocationRow>(
|
||||||
`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);
|
.all(...poolIds);
|
||||||
const allocsByPool = new Map<string, AllocationRow[]>();
|
const allocsByPool = new Map<string, AllocationRow[]>();
|
||||||
@@ -226,7 +228,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] {
|
|||||||
// Batch connections: 1 query for all pools
|
// Batch connections: 1 query for all pools
|
||||||
const connRows = db
|
const connRows = db
|
||||||
.prepare<{ pool_id: string; connection_id: string }>(
|
.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);
|
.all(...poolIds);
|
||||||
const connsByPool = new Map<string, string[]>();
|
const connsByPool = new Map<string, string[]>();
|
||||||
@@ -253,7 +255,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] {
|
|||||||
function getAllocations(poolId: string): PoolAllocation[] {
|
function getAllocations(poolId: string): PoolAllocation[] {
|
||||||
const rows = getDb()
|
const rows = getDb()
|
||||||
.prepare<AllocationRow>(
|
.prepare<AllocationRow>(
|
||||||
"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);
|
.all(poolId);
|
||||||
return rows.map(rowToAllocation);
|
return rows.map(rowToAllocation);
|
||||||
@@ -324,7 +326,7 @@ export function listPools(options?: { limit?: number; offset?: number }): {
|
|||||||
export function getPool(id: string): QuotaPool | null {
|
export function getPool(id: string): QuotaPool | null {
|
||||||
const row = getDb()
|
const row = getDb()
|
||||||
.prepare<PoolRow>(
|
.prepare<PoolRow>(
|
||||||
"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);
|
.get(id);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
@@ -358,12 +360,12 @@ export function createPool(input: PoolCreate): QuotaPool {
|
|||||||
const doCreate = database.transaction(() => {
|
const doCreate = database.transaction(() => {
|
||||||
database
|
database
|
||||||
.prepare(
|
.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);
|
.run(id, primaryConnectionId, input.name, groupId, now);
|
||||||
|
|
||||||
const insertConn = database.prepare(
|
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) {
|
for (const connId of members) {
|
||||||
insertConn.run(id, connId);
|
insertConn.run(id, connId);
|
||||||
@@ -372,7 +374,7 @@ export function createPool(input: PoolCreate): QuotaPool {
|
|||||||
if (input.allocations && input.allocations.length > 0) {
|
if (input.allocations && input.allocations.length > 0) {
|
||||||
const insertAlloc = database.prepare(
|
const insertAlloc = database.prepare(
|
||||||
`INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy)
|
`INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
);
|
);
|
||||||
for (const alloc of input.allocations) {
|
for (const alloc of input.allocations) {
|
||||||
insertAlloc.run(
|
insertAlloc.run(
|
||||||
@@ -381,7 +383,7 @@ export function createPool(input: PoolCreate): QuotaPool {
|
|||||||
alloc.weight,
|
alloc.weight,
|
||||||
alloc.capValue ?? null,
|
alloc.capValue ?? null,
|
||||||
alloc.capUnit ?? null,
|
alloc.capUnit ?? null,
|
||||||
alloc.policy
|
alloc.policy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,12 +398,14 @@ export function createPool(input: PoolCreate): QuotaPool {
|
|||||||
group_id: groupId,
|
group_id: groupId,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
},
|
},
|
||||||
getAllocations(id)
|
getAllocations(id),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Phase B2: fire-and-forget combo sync; failures are logged but never thrown.
|
// Phase B2: fire-and-forget combo sync; failures are logged but never thrown.
|
||||||
void syncQuotaCombosGuarded(id);
|
void syncQuotaCombosGuarded(id);
|
||||||
|
|
||||||
|
invalidateModelCatalogCache();
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +419,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null {
|
|||||||
const database = getDb();
|
const database = getDb();
|
||||||
const existing = database
|
const existing = database
|
||||||
.prepare<PoolRow>(
|
.prepare<PoolRow>(
|
||||||
"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);
|
.get(id);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
@@ -441,7 +445,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null {
|
|||||||
// Replace join rows.
|
// Replace join rows.
|
||||||
database.prepare("DELETE FROM quota_pool_connections WHERE pool_id = ?").run(id);
|
database.prepare("DELETE FROM quota_pool_connections WHERE pool_id = ?").run(id);
|
||||||
const insertConn = database.prepare(
|
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) {
|
for (const connId of input.connectionIds) {
|
||||||
insertConn.run(id, connId);
|
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);
|
database.prepare("DELETE FROM quota_allocations WHERE pool_id = ?").run(id);
|
||||||
const insertAlloc = database.prepare(
|
const insertAlloc = database.prepare(
|
||||||
`INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy)
|
`INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
);
|
);
|
||||||
for (const alloc of input.allocations) {
|
for (const alloc of input.allocations) {
|
||||||
insertAlloc.run(
|
insertAlloc.run(
|
||||||
@@ -465,7 +469,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null {
|
|||||||
alloc.weight,
|
alloc.weight,
|
||||||
alloc.capValue ?? null,
|
alloc.capValue ?? null,
|
||||||
alloc.capUnit ?? 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.
|
// Phase B2: fire-and-forget combo sync; failures are logged but never thrown.
|
||||||
void syncQuotaCombosGuarded(id);
|
void syncQuotaCombosGuarded(id);
|
||||||
|
|
||||||
|
invalidateModelCatalogCache();
|
||||||
|
|
||||||
return result;
|
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 != ?),
|
(SELECT json_group_array(value) FROM json_each(api_keys.allowed_quotas) WHERE value != ?),
|
||||||
'[]')
|
'[]')
|
||||||
WHERE allowed_quotas IS NOT NULL AND allowed_quotas != '[]'
|
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);
|
.run(id, id);
|
||||||
return database.prepare("DELETE FROM quota_pools WHERE id = ?").run(id);
|
return database.prepare("DELETE FROM quota_pools WHERE id = ?").run(id);
|
||||||
});
|
});
|
||||||
const result = doDelete();
|
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.
|
// without requiring a manual re-save. Persists the normalized weights.
|
||||||
const totalWeight = allocations.reduce(
|
const totalWeight = allocations.reduce(
|
||||||
(s, a) => s + (Number.isFinite(a.weight) ? a.weight : 0),
|
(s, a) => s + (Number.isFinite(a.weight) ? a.weight : 0),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
const normalizedAllocations =
|
const normalizedAllocations =
|
||||||
totalWeight === 0 && allocations.length > 0
|
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.
|
// Defensive: fall back to [poolId] (single-pool semantics) if pool not found.
|
||||||
const targetPool = database
|
const targetPool = database
|
||||||
.prepare<PoolRow>(
|
.prepare<PoolRow>(
|
||||||
"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);
|
.get(poolId);
|
||||||
|
|
||||||
@@ -582,7 +595,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[])
|
|||||||
const doUpsert = database.transaction(() => {
|
const doUpsert = database.transaction(() => {
|
||||||
const insert = database.prepare(
|
const insert = database.prepare(
|
||||||
`INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy)
|
`INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
);
|
);
|
||||||
for (const pid of poolIdsInGroup) {
|
for (const pid of poolIdsInGroup) {
|
||||||
database.prepare("DELETE FROM quota_allocations WHERE pool_id = ?").run(pid);
|
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.weight,
|
||||||
alloc.capValue ?? null,
|
alloc.capValue ?? null,
|
||||||
alloc.capUnit ?? null,
|
alloc.capUnit ?? null,
|
||||||
alloc.policy
|
alloc.policy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -610,13 +623,13 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[])
|
|||||||
* Returns pairs of { poolId, allocation }.
|
* Returns pairs of { poolId, allocation }.
|
||||||
*/
|
*/
|
||||||
export function listAllocationsForApiKey(
|
export function listAllocationsForApiKey(
|
||||||
apiKeyId: string
|
apiKeyId: string,
|
||||||
): Array<{ poolId: string; allocation: PoolAllocation }> {
|
): Array<{ poolId: string; allocation: PoolAllocation }> {
|
||||||
const rows = getDb()
|
const rows = getDb()
|
||||||
.prepare<AllocationRow>(
|
.prepare<AllocationRow>(
|
||||||
`SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy
|
`SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy
|
||||||
FROM quota_allocations
|
FROM quota_allocations
|
||||||
WHERE api_key_id = ?`
|
WHERE api_key_id = ?`,
|
||||||
)
|
)
|
||||||
.all(apiKeyId);
|
.all(apiKeyId);
|
||||||
return rows.map((row) => ({ poolId: row.pool_id, allocation: rowToAllocation(row) }));
|
return rows.map((row) => ({ poolId: row.pool_id, allocation: rowToAllocation(row) }));
|
||||||
|
|||||||
@@ -234,26 +234,29 @@ export function getCombosCacheVersion(): number {
|
|||||||
|
|
||||||
// ──────────────── Model Catalog Cache Invalidation Signal ────────────────
|
// ──────────────── Model Catalog Cache Invalidation Signal ────────────────
|
||||||
//
|
//
|
||||||
// #6408 added a request-shape-keyed (prefix/isCodex/apiKey) TTL cache around the
|
// #6408 added a request-shape-keyed (prefix/isCodex/apiKey/configuredOnly) TTL
|
||||||
// unified /v1/models builder (src/app/api/v1/models/catalog.ts) to coalesce
|
// cache around the unified /v1/models builder (src/app/api/v1/models/catalog.ts)
|
||||||
// concurrent/bursty GETs. That cache key does not vary with the underlying DB
|
// to coalesce concurrent/bursty GETs. That cache key does not vary with the
|
||||||
// state the builder reads (connections, settings, combos), so a write followed by
|
// underlying DB state the builder reads (connections, settings, combos), so
|
||||||
// a read within the ~1.5s TTL replayed the pre-write response. Same import-cycle
|
// writes need an explicit invalidation signal. Same import-cycle
|
||||||
// constraint as combosCacheVersion above (a db module must not import the route
|
// 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
|
// module): catalogCache.ts compares this version on every access and builder
|
||||||
// whole cache the moment it moves, so any write that calls invalidateDbCache() makes
|
// completion, then hard-invalidates snapshots and old-generation work when it moves.
|
||||||
// the next read miss immediately instead of waiting out the TTL.
|
|
||||||
let modelCatalogCacheVersion = 0;
|
let modelCatalogCacheVersion = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current model-catalog-cache version. `getUnifiedModelsResponse()` folds this
|
* Current model-catalog-cache version. A change means catalog-backed state was
|
||||||
* into its response cache key; a change means settings/connections/combos were
|
* written and the next read must synchronously build the new generation.
|
||||||
* written since the cache was populated and the cached body is stale.
|
|
||||||
*/
|
*/
|
||||||
export function getModelCatalogCacheVersion(): number {
|
export function getModelCatalogCacheVersion(): number {
|
||||||
return modelCatalogCacheVersion;
|
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,
|
* Invalidate caches (call after writes to any of: settings, pricing,
|
||||||
* connections, combos, nodes).
|
* connections, combos, nodes).
|
||||||
|
|||||||
183
tests/integration/v1-models-swr-response-flush-8728.test.ts
Normal file
183
tests/integration/v1-models-swr-response-flush-8728.test.ts
Normal file
@@ -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<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(socketPath, resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(server: http.Server): Promise<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
166
tests/unit/db-synced-model-catalog-invalidation-8728.test.ts
Normal file
166
tests/unit/db-synced-model-catalog-invalidation-8728.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
203
tests/unit/model-catalog-cache-swr-8728.test.ts
Normal file
203
tests/unit/model-catalog-cache-swr-8728.test.ts
Normal file
@@ -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<void>;
|
||||||
|
|
||||||
|
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<catalogCache.CatalogPayload>,
|
||||||
|
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<catalogCache.CatalogPayload>((resolvePromise) => {
|
||||||
|
resolveOld = resolvePromise;
|
||||||
|
});
|
||||||
|
let currentBuildStarted = false;
|
||||||
|
let resolveCurrent!: (value: catalogCache.CatalogPayload) => void;
|
||||||
|
const currentPayload = new Promise<catalogCache.CatalogPayload>((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<catalogCache.CatalogPayload>((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");
|
||||||
|
});
|
||||||
178
tests/unit/model-catalog-policy-invalidation-8728.test.ts
Normal file
178
tests/unit/model-catalog-policy-invalidation-8728.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
214
tests/unit/model-catalog-source-invalidation-8728.test.ts
Normal file
214
tests/unit/model-catalog-source-invalidation-8728.test.ts
Normal file
@@ -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<Record<string, unknown>> }): 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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";
|
process.env.EXPOSE_CC_DISCOVERY_ALIASES = "1";
|
||||||
// A chave do cache é `prefix|isCodex|apiKey|configuredOnly` — um query param
|
// 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.
|
// qualquer NÃO a invalida, então a resposta do subteste anterior seria servida.
|
||||||
v1ModelsCatalog.__expireCatalogCacheForTest(
|
v1ModelsCatalog.__setCatalogStaleWhileRevalidateMsForTest(0);
|
||||||
v1ModelsCatalog.CATALOG_STALE_WHILE_REVALIDATE_MS + 1000
|
v1ModelsCatalog.__expireCatalogCacheForTest(1);
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const res = await v1ModelsCatalog.getUnifiedModelsResponse(
|
const res = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||||
new Request("http://localhost/api/v1/models", {
|
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))}`
|
`ids=${JSON.stringify(body.data.map((m) => m.id).slice(0, 6))}`
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
v1ModelsCatalog.__setCatalogStaleWhileRevalidateMsForTest(
|
||||||
|
v1ModelsCatalog.CATALOG_STALE_WHILE_REVALIDATE_MS
|
||||||
|
);
|
||||||
if (prev === undefined) delete process.env.EXPOSE_CC_DISCOVERY_ALIASES;
|
if (prev === undefined) delete process.env.EXPOSE_CC_DISCOVERY_ALIASES;
|
||||||
else process.env.EXPOSE_CC_DISCOVERY_ALIASES = prev;
|
else process.env.EXPOSE_CC_DISCOVERY_ALIASES = prev;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 makeRequest = () => new Request("http://localhost/v1/models");
|
||||||
|
|
||||||
const res1 = await v1ModelsCatalog.getUnifiedModelsResponse(makeRequest());
|
const res1 = await v1ModelsCatalog.getUnifiedModelsResponse(makeRequest());
|
||||||
assert.equal(res1.status, 200);
|
assert.equal(res1.status, 200);
|
||||||
|
const body1 = await res1.text();
|
||||||
const runsAfterFirst = v1ModelsCatalog.__getCatalogBuilderRunsForTest();
|
const runsAfterFirst = v1ModelsCatalog.__getCatalogBuilderRunsForTest();
|
||||||
assert.equal(runsAfterFirst, 1);
|
assert.equal(runsAfterFirst, 1);
|
||||||
|
|
||||||
// Push the entry's age past CATALOG_STALE_WHILE_REVALIDATE_MS.
|
// Age well past the historical 30-second bound. Ordinary expiry must not turn a
|
||||||
v1ModelsCatalog.__expireCatalogCacheForTest(
|
// refresh failure into a client-visible cold-build wait.
|
||||||
v1ModelsCatalog.CATALOG_STALE_WHILE_REVALIDATE_MS + 5_000
|
v1ModelsCatalog.__expireCatalogCacheForTest(24 * 60 * 60 * 1000);
|
||||||
);
|
|
||||||
|
|
||||||
const res2 = await v1ModelsCatalog.getUnifiedModelsResponse(makeRequest());
|
const res2 = await v1ModelsCatalog.getUnifiedModelsResponse(makeRequest());
|
||||||
assert.equal(res2.status, 200);
|
assert.equal(res2.status, 200);
|
||||||
|
assert.equal(await res2.text(), body1);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
v1ModelsCatalog.__getCatalogBuilderRunsForTest(),
|
v1ModelsCatalog.__getCatalogBuilderRunsForTest(),
|
||||||
runsAfterFirst + 1,
|
runsAfterFirst,
|
||||||
"past the staleness window, the builder must run again BEFORE the response is returned " +
|
"ordinary TTL expiry must return the last successful snapshot before refreshing"
|
||||||
"(a refresh that keeps failing must not pin a stale catalog forever)"
|
|
||||||
);
|
);
|
||||||
|
await v1ModelsCatalog.__flushCatalogBackgroundRefreshForTest();
|
||||||
|
assert.equal(v1ModelsCatalog.__getCatalogBuilderRunsForTest(), runsAfterFirst + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("5. a cached non-200 entry is never served as stale", async () => {
|
test("5. a cached non-200 entry is never served as stale", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user