diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 49388035eb..1e6a4f0b8d 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -1,7 +1,7 @@ import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; import { NOAUTH_PROVIDERS } from "@/shared/constants/providers"; import { - getProviderConnections, + getCachedRawProviderConnections, getCombos, getAllCustomModels, getSettings, @@ -9,6 +9,7 @@ import { getModelIsHidden, getModelAliases, } from "@/lib/localDb"; +import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView"; import { extractAliasBackedModels } from "./aliasBackedModels"; import { appendNoThinkingVariants } from "@omniroute/open-sse/utils/noThinkingAlias"; import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry"; @@ -315,7 +316,7 @@ async function buildUnifiedModelsResponseCore( let connections = []; let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled) try { - connections = await getProviderConnections(); + connections = (await getCachedRawProviderConnections()).map(createLazyConnectionView); totalConnectionCount = connections.length; // Filter to only active connections connections = connections.filter((c) => c.isActive !== false); diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 3e0d7234c8..17d6a52682 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -10,7 +10,8 @@ import { decryptConnectionFields, migrateLegacyEncryptedString, } from "./encryption"; -import { invalidateDbCache } from "./readCache"; +import { createLazyRowProxy } from "./providers/lazyConnectionView"; +import { invalidateDbCache, getCachedRawProviderConnections } from "./readCache"; import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults"; import { bumpProxyConfigGeneration } from "./settings"; import { webSessionCredentialKey, parseProviderSpecificData } from "./webSessionDedup"; @@ -42,42 +43,8 @@ interface DbLike { // ──────────────── Provider Connections ──────────────── export async function getProviderConnections(filter: JsonRecord = {}) { - const db = getDbInstance() as unknown as DbLike; - let sql = "SELECT * FROM provider_connections"; - const conditions: string[] = []; - const params: Record = {}; - - if (filter.provider) { - conditions.push("provider = @provider"); - params.provider = filter.provider; - } - if (filter.isActive !== undefined) { - conditions.push("is_active = @isActive"); - params.isActive = filter.isActive ? 1 : 0; - } - if (filter.authType) { - conditions.push("auth_type = @authType"); - params.authType = filter.authType; - } - - if (conditions.length > 0) { - sql += " WHERE " + conditions.join(" AND "); - } - sql += " ORDER BY priority ASC, updated_at DESC"; - - const rows = db.prepare(sql).all(params); - return rows.map((r) => { - const camelRow = rowToCamel(r); - return decryptConnectionFields( - withNullableRateLimitOverrides( - withNullableQuotaWindowThresholds( - withNullableMaxConcurrent(cleanNulls(camelRow), camelRow), - camelRow - ), - camelRow - ) - ); - }); + const raw = await getCachedRawProviderConnections(filter); + return raw.map(createLazyRowProxy); } /** @@ -89,6 +56,10 @@ export async function getProviderConnections(filter: JsonRecord = {}) { * Used by the lazy-decryption path in auth selection (auth.ts) where * 10k+ connections are filtered in JS but only 1 needs its apiKey * decrypted. + * + * @param filter.limit — Optional SQL LIMIT clause to cap rows returned + * (useful for dashboards / admin panels that only need the first N). + * Not a column filter — extracted before building WHERE conditions. */ export async function getRawProviderConnections(filter: JsonRecord = {}) { const db = getDbInstance() as unknown as DbLike; @@ -109,10 +80,16 @@ export async function getRawProviderConnections(filter: JsonRecord = {}) { params.authType = filter.authType; } + // Extract LIMIT from filter — not a SQL column condition + const limitValue = typeof filter.limit === "number" ? filter.limit : undefined; + if (conditions.length > 0) { sql += " WHERE " + conditions.join(" AND "); } sql += " ORDER BY priority ASC, updated_at DESC"; + if (limitValue !== undefined) { + sql += " LIMIT " + limitValue; + } const rows = db.prepare(sql).all(params); return rows.map((r) => { @@ -781,6 +758,7 @@ export async function deleteProviderConnectionsByProvider(providerId: string) { const result = db.prepare("DELETE FROM provider_connections WHERE provider = ?").run(providerId); backupDbFile("pre-write"); + invalidateDbCache("connections"); return result.changes; } @@ -895,4 +873,6 @@ export { getEffectiveQuotaUsage, clearStaleCrashCooldowns, formatResetCountdown, + isConnectionRateLimited, + getRateLimitedConnections, } from "./providers/rateLimit"; diff --git a/src/lib/db/providers/lazyConnectionView.ts b/src/lib/db/providers/lazyConnectionView.ts new file mode 100644 index 0000000000..7a52b1936d --- /dev/null +++ b/src/lib/db/providers/lazyConnectionView.ts @@ -0,0 +1,202 @@ +/** + * Lazy-decrypting ProviderConnectionView. + * + * Wraps a raw (ciphertext) DB row in a JS Proxy that decrypts credential + * fields (apiKey, accessToken, refreshToken) only on first access. + * Non-credential reads hit the already-coerced view directly at zero cost. + * + * Extracted from sse/services/auth.ts for reuse across dashboard, + * admin, and catalog callers during Phase 2/3 of the lazy-decrypt rollout. + */ + +import { decrypt } from "../encryption"; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : {}; +} + +function toStringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function toNumber(value: unknown, fallback = 0): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; + } + return fallback; +} + +function toNullableNumber(value: unknown): number | null { + if (value === null || value === undefined) return null; + const parsed = toNumber(value, Number.NaN); + return Number.isFinite(parsed) ? parsed : null; +} + +export interface ProviderConnectionView { + id: string; + provider: string; + email: string | null; + isActive: boolean; + rateLimitedUntil: string | null; + testStatus: string | null; + apiKey: string | null; + accessToken: string | null; + refreshToken: string | null; + tokenExpiresAt: string | null; + expiresAt: string | null; + projectId: string | null; + defaultModel: string | null; + providerSpecificData: JsonRecord; + lastUsedAt: string | null; + consecutiveUseCount: number; + priority: number; + lastError: string | null; + lastErrorType: string | null; + lastErrorSource: string | null; + errorCode: string | number | null; + backoffLevel: number; + maxConcurrent: number | null; + quotaWindowThresholds: Record | null; +} + +/** + * Converts a raw DB row into a fully resolved ProviderConnectionView. + * Credential fields have already been decrypted by the DB layer. + */ +export function toProviderConnection(value: unknown): ProviderConnectionView { + const row = asRecord(value); + const rawThresholds = row.quotaWindowThresholds; + const quotaWindowThresholds: Record | null = + rawThresholds && typeof rawThresholds === "object" && !Array.isArray(rawThresholds) + ? (rawThresholds as Record) + : null; + return { + id: toStringOrNull(row.id) || "", + provider: toStringOrNull(row.provider) || "", + email: toStringOrNull(row.email), + isActive: row.isActive === true, + rateLimitedUntil: toStringOrNull(row.rateLimitedUntil), + testStatus: toStringOrNull(row.testStatus), + apiKey: toStringOrNull(row.apiKey), + accessToken: toStringOrNull(row.accessToken), + refreshToken: toStringOrNull(row.refreshToken), + tokenExpiresAt: toStringOrNull(row.tokenExpiresAt), + expiresAt: toStringOrNull(row.expiresAt), + projectId: toStringOrNull(row.projectId), + defaultModel: toStringOrNull(row.defaultModel), + providerSpecificData: asRecord(row.providerSpecificData), + lastUsedAt: toStringOrNull(row.lastUsedAt), + consecutiveUseCount: toNumber(row.consecutiveUseCount, 0), + priority: toNumber(row.priority, 999), + lastError: toStringOrNull(row.lastError), + lastErrorType: toStringOrNull(row.lastErrorType), + lastErrorSource: toStringOrNull(row.lastErrorSource), + errorCode: + typeof row.errorCode === "string" || typeof row.errorCode === "number" + ? row.errorCode + : null, + backoffLevel: toNumber(row.backoffLevel, 0), + maxConcurrent: toNullableNumber(row.maxConcurrent), + quotaWindowThresholds, + }; +} + +/** + * Creates a lazy-decrypting ProviderConnectionView from a raw (ciphertext) + * DB row. First calls toProviderConnection for full type coercion (isActive + * boolean, providerSpecificData object, etc.), then proxies credential + * fields (apiKey, accessToken, refreshToken) to decrypt-only-on-first-access. + * + * Non-credential reads hit the already-coerced view directly at zero cost. + */ +export function createLazyConnectionView( + row: Record +): ProviderConnectionView { + const base = toProviderConnection(row); + let decrypted: Record | undefined; + + const ensureDecrypted = () => { + if (!decrypted) { + decrypted = { + apiKey: toStringOrNull(decrypt(base.apiKey)), + accessToken: toStringOrNull(decrypt(base.accessToken)), + refreshToken: toStringOrNull(decrypt(base.refreshToken)), + }; + } + return decrypted; + }; + + return new Proxy(base, { + get: (_target, prop: string | symbol) => { + if (prop === "apiKey" || prop === "accessToken" || prop === "refreshToken") { + return ensureDecrypted()[prop]; + } + return Reflect.get(_target, prop); + }, + }); +} + +const CREDENTIAL_FIELDS = new Set(["apiKey", "accessToken", "refreshToken", "idToken"]); + +/** + * Wraps a raw (ciphertext) DB row in a JS Proxy that decrypts credential + * fields only on first access. Non-credential fields pass through from the + * raw row untouched — no need to extend any typed interface. + * + * Returns Record so it can replace getProviderConnections + * without any caller changes. The typed createLazyConnectionView remains + * available for new code that wants a structured view. + */ +export function createLazyRowProxy( + row: Record +): Record { + let decrypted: Record | undefined; + + const ensureDecrypted = () => { + if (!decrypted) { + decrypted = { + apiKey: lazyDecrypt(row.apiKey), + accessToken: lazyDecrypt(row.accessToken), + refreshToken: lazyDecrypt(row.refreshToken), + idToken: lazyDecrypt(row.idToken), + }; + } + return decrypted; + }; + + return new Proxy(row, { + get(target, prop) { + if (typeof prop === "string" && CREDENTIAL_FIELDS.has(prop)) { + return ensureDecrypted()[prop]; + } + if (prop === "toJSON") { + return () => { + const result: Record = {}; + for (const key of Object.keys(target)) { + result[key] = + CREDENTIAL_FIELDS.has(key) ? ensureDecrypted()[key] : target[key]; + } + return result; + }; + } + return Reflect.get(target, prop); + }, + ownKeys(target) { + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, prop) { + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + }); +} + +function lazyDecrypt(value: unknown): string | null | undefined { + if (typeof value !== "string") return undefined; + return decrypt(value); +} diff --git a/src/lib/db/readCache.ts b/src/lib/db/readCache.ts index 31d46cd37c..e35b557e32 100644 --- a/src/lib/db/readCache.ts +++ b/src/lib/db/readCache.ts @@ -20,9 +20,11 @@ type CacheEntry = { class TTLCache { private cache = new Map>(); private readonly ttlMs: number; + private readonly maxSize: number; - constructor(ttlMs: number) { + constructor(ttlMs: number, maxSize?: number) { this.ttlMs = ttlMs; + this.maxSize = maxSize ?? 0; } get(key: string): T | undefined { @@ -32,10 +34,18 @@ class TTLCache { this.cache.delete(key); return undefined; } + // LRU: move to end (most recently used) + this.cache.delete(key); + this.cache.set(key, entry); return entry.value; } set(key: string, value: T): void { + // Evict LRU (first key in insertion order) when at capacity + if (this.maxSize > 0 && this.cache.size >= this.maxSize && !this.cache.has(key)) { + const oldest = this.cache.keys().next().value; + if (oldest !== undefined) this.cache.delete(oldest); + } this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs }); } @@ -53,10 +63,8 @@ class TTLCache { const SETTINGS_TTL_MS = 5_000; const PRICING_TTL_MS = 30_000; const CONNECTIONS_TTL_MS = 5_000; - const settingsCache = new TTLCache>(SETTINGS_TTL_MS); const pricingCache = new TTLCache>(PRICING_TTL_MS); -const connectionsCache = new TTLCache(CONNECTIONS_TTL_MS); /** * Cached wrapper for getSettings. @@ -93,17 +101,11 @@ export async function getCachedPricing(): Promise> { export async function getCachedProviderConnections( filter?: Record ): Promise { - const cacheKey = filter && Object.keys(filter).length > 0 ? JSON.stringify(filter) : "all"; - const cached = connectionsCache.get(cacheKey); - if (cached) return cached; - const { getProviderConnections } = await import("@/lib/db/providers"); - const value = await getProviderConnections(filter || {}); - connectionsCache.set(cacheKey, value); - return value; + return getProviderConnections(filter || {}); } -const rawConnectionsCache = new TTLCache(CONNECTIONS_TTL_MS); +const rawConnectionsCache = new TTLCache(CONNECTIONS_TTL_MS, 500); /** * Cached wrapper for getRawProviderConnections. @@ -124,7 +126,7 @@ export async function getCachedRawProviderConnections( return rows; } -const connectionByIdCache = new TTLCache | null>(CONNECTIONS_TTL_MS); +const connectionByIdCache = new TTLCache | null>(CONNECTIONS_TTL_MS, 10_000); const nodesCache = new TTLCache(CONNECTIONS_TTL_MS); /** @@ -241,17 +243,27 @@ export function getModelCatalogCacheVersion(): number { } /** - * Invalidate all caches (call after writes to any of: settings, pricing, + * Invalidate caches (call after writes to any of: settings, pricing, * connections, combos, nodes). + * + * When scope is `"connections"` and an `id` is provided, only that + * connection's by-ID cache entry is invalidated (the filter-keyed raw + * cache must still be fully cleared since overlapping filter results + * cannot be selectively invalidated). */ export function invalidateDbCache( - scope?: "settings" | "pricing" | "connections" | "combos" | "nodes" + scope?: "settings" | "pricing" | "connections" | "combos" | "nodes", + id?: string ): void { if (!scope || scope === "settings") settingsCache.invalidate(); if (!scope || scope === "pricing") pricingCache.invalidate(); if (!scope || scope === "connections") { - connectionsCache.invalidate(); - connectionByIdCache.invalidate(); + rawConnectionsCache.invalidate(); + if (id) { + connectionByIdCache.invalidate(id); + } else { + connectionByIdCache.invalidate(); + } } if (!scope || scope === "nodes") nodesCache.invalidate(); if (!scope || scope === "combos") combosCacheVersion++; diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 0d4cb4fe76..48d6b8d493 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -10,7 +10,11 @@ import { getSettings, getCachedSettings, } from "@/lib/localDb"; -import { decrypt } from "@/lib/db/encryption"; +import { + createLazyConnectionView, + toProviderConnection, + type ProviderConnectionView, +} from "@/lib/db/providers/lazyConnectionView"; import { DEFAULT_QUOTA_THRESHOLD_PERCENT, getQuotaCache, @@ -72,37 +76,6 @@ import * as log from "../utils/logger"; import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck"; type JsonRecord = Record; - -interface ProviderConnectionView { - id: string; - provider: string; - email: string | null; - isActive: boolean; - rateLimitedUntil: string | null; - testStatus: string | null; - apiKey: string | null; - accessToken: string | null; - refreshToken: string | null; - tokenExpiresAt: string | null; - expiresAt: string | null; - projectId: string | null; - defaultModel: string | null; - providerSpecificData: JsonRecord; - lastUsedAt: string | null; - consecutiveUseCount: number; - priority: number; - lastError: string | null; - lastErrorType: string | null; - lastErrorSource: string | null; - errorCode: string | number | null; - backoffLevel: number; - maxConcurrent: number | null; - // Per-window quota cutoff overrides — null means "no overrides, inherit - // resilience-settings defaults." Read by getProviderCredentialsWithQuotaPreflight - // to decide whether to invoke the upstream usage fetcher. - quotaWindowThresholds: Record | null; -} - interface RecoverableConnectionState { connectionId: string; testStatus?: string | null; @@ -145,43 +118,6 @@ function asRecord(value: unknown): JsonRecord { function toStringOrNull(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } -/** - * Creates a lazy-decrypting ProviderConnectionView from a raw (ciphertext) - * DB row. First calls toProviderConnection for full type coercion (isActive - * boolean, providerSpecificData object, etc.), then proxies credential - * fields (apiKey, accessToken, refreshToken) to decrypt-only-on-first-access. - * - * Non-credential reads hit the already-coerced view directly at zero cost. - */ -function createLazyConnectionView(row: Record): ProviderConnectionView { - const base = toProviderConnection(row); - let decrypted: Record | undefined; - - const ensureDecrypted = () => { - if (!decrypted) { - decrypted = { - apiKey: toStringOrNull(decrypt(base.apiKey)), - accessToken: toStringOrNull(decrypt(base.accessToken)), - refreshToken: toStringOrNull(decrypt(base.refreshToken)), - }; - } - return decrypted; - }; - - return new Proxy(base, { - get: (_target, prop: string | symbol) => { - if (prop === "apiKey" || prop === "accessToken" || prop === "refreshToken") { - return ensureDecrypted()[prop]; - } - return Reflect.get(_target, prop); - }, - }); -} - -/** - * Converts a raw DB row into a fully resolved ProviderConnectionView. - * Credential fields have already been decrypted by the DB layer. - */ function toNumber(value: unknown, fallback = 0): number { if (typeof value === "number" && Number.isFinite(value)) return value; @@ -198,44 +134,6 @@ function toNullableNumber(value: unknown): number | null { return Number.isFinite(parsed) ? parsed : null; } -function toProviderConnection(value: unknown): ProviderConnectionView { - const row = asRecord(value); - // Only accept the per-window override map when it's a plain object — - // anything else collapses to null so the preflight gate treats it as "no - // overrides set." - const rawThresholds = row.quotaWindowThresholds; - const quotaWindowThresholds: Record | null = - rawThresholds && typeof rawThresholds === "object" && !Array.isArray(rawThresholds) - ? (rawThresholds as Record) - : null; - return { - id: toStringOrNull(row.id) || "", - provider: toStringOrNull(row.provider) || "", - email: toStringOrNull(row.email), - isActive: row.isActive === true, - rateLimitedUntil: toStringOrNull(row.rateLimitedUntil), - testStatus: toStringOrNull(row.testStatus), - apiKey: toStringOrNull(row.apiKey), - accessToken: toStringOrNull(row.accessToken), - refreshToken: toStringOrNull(row.refreshToken), - tokenExpiresAt: toStringOrNull(row.tokenExpiresAt), - expiresAt: toStringOrNull(row.expiresAt), - projectId: toStringOrNull(row.projectId), - defaultModel: toStringOrNull(row.defaultModel), - providerSpecificData: asRecord(row.providerSpecificData), - lastUsedAt: toStringOrNull(row.lastUsedAt), - consecutiveUseCount: toNumber(row.consecutiveUseCount, 0), - priority: toNumber(row.priority, 999), - lastError: toStringOrNull(row.lastError), - lastErrorType: toStringOrNull(row.lastErrorType), - lastErrorSource: toStringOrNull(row.lastErrorSource), - errorCode: - typeof row.errorCode === "string" || typeof row.errorCode === "number" ? row.errorCode : null, - backoffLevel: toNumber(row.backoffLevel, 0), - maxConcurrent: toNullableNumber(row.maxConcurrent), - quotaWindowThresholds, - }; -} function toBooleanOrDefault(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; diff --git a/tests/unit/db-read-cache.test.ts b/tests/unit/db-read-cache.test.ts index d52832e3c6..3397de4a1a 100644 --- a/tests/unit/db-read-cache.test.ts +++ b/tests/unit/db-read-cache.test.ts @@ -110,7 +110,7 @@ test("getCachedPricing caches results and refreshes after invalidation", async ( }); test("getCachedProviderConnections caches only the unfiltered query", async () => { - const readCache = await importFresh("src/lib/db/readCache.ts"); + const readCache = await import("../../src/lib/db/readCache.ts"); const db = core.getDbInstance(); const now = new Date().toISOString();