diff --git a/changelog.d/fixes/7787-provider-connections-raw-cache-lazy-decrypt.md b/changelog.d/fixes/7787-provider-connections-raw-cache-lazy-decrypt.md new file mode 100644 index 0000000000..1dc802f929 --- /dev/null +++ b/changelog.d/fixes/7787-provider-connections-raw-cache-lazy-decrypt.md @@ -0,0 +1 @@ +- **perf(db):** `getProviderConnections` now reads from a shared `rawConnectionsCache` (500-entry LRU, 5s TTL) and returns lazy-decrypting proxies — encrypted credential fields (`apiKey`, `accessToken`, `refreshToken`, `idToken`) are only AES-GCM decrypted on first property access instead of eagerly for every cached row. `getCachedProviderConnections` now delegates through this single raw cache instead of maintaining its own, and `deleteProviderConnectionsByProvider` gained a missing `invalidateDbCache` call. ([#7787](https://github.com/diegosouzapw/OmniRoute/pull/7787) — thanks @oyi77) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 6fff4ea428..763c8ae69e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -293,7 +293,8 @@ "src/lib/providers/validation/webProvidersA.ts": 809, "src/lib/tokenHealthCheck.ts": 832, "_rebaseline_2026_07_09_6587_kiro_api_key_auth": "PR #6587 (@strangersp) own growth for Kiro long-lived API-key auth, merged onto v3.8.47 tip: openai-to-kiro.ts 890->912 (+22, auth-header selection for API-key-vs-OAuth-token connections), providerLimits.ts 998->1000 (+2, API-key auth-type branch), translator-openai-to-kiro.test.ts 1234->1257 (+23), providers-page-utils.test.ts 1109->1107 (net -2 after merging with parallel release drift; connectionMatchesProviderCard api_key coverage added), provider-validation-specialty.test.ts 2856->2980 (+124 net after merge with parallel release drift; this PR also removed the file's `@typescript-eslint/no-explicit-any` eslint-suppression entry by fixing all `any` usages, adding typed replacements). Cohesive additive feature growth, well tested; not extractable without splitting the existing chokepoints mid-merge.", - "src/lib/localDb.ts": 805, + "_rebaseline_2026_07_19_7787_ic2_localdb_reexports": "PR #7787 (IC2 raw connections cache + lazy-decrypt) own growth: localDb.ts 805->807 (gate units, +2). localDb.ts is the re-export-only layer (hard rule #2 — no logic); the PR adds 4 new db/readCache re-exports (touchConnectionLastUsed, getCachedRawProviderConnections, getCachedProviderConnectionById, getCachedProviderNodes) required by existing barrel importers. Irreducible for a re-export list; frozen so it can only shrink.", + "src/lib/localDb.ts": 807, "_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail (owner-approved): src/lib/localDb.ts NEW>800 (799->805, +6 re-exports countFreeProxies + recordFreeProxySyncErrors/clearFreeProxySyncErrors/getFreeProxySyncErrors + FreeProxySyncErrors type for #6909 free-pool relay-repair; re-export-only per Hard Rule #2, not extractable)." }, "testCap": 800, diff --git a/open-sse/handlers/chatCore/codexFailover.ts b/open-sse/handlers/chatCore/codexFailover.ts index 175f167e9a..f552af7375 100644 --- a/open-sse/handlers/chatCore/codexFailover.ts +++ b/open-sse/handlers/chatCore/codexFailover.ts @@ -1,5 +1,6 @@ import { getCodexModelScope } from "../../config/codexQuotaScopes.ts"; -import { getProviderConnectionById, updateProviderConnection } from "@/lib/db/providers"; +import { updateProviderConnection } from "@/lib/db/providers"; +import { getCachedProviderConnectionById } from "@/lib/localDb"; type CodexFailoverCredentials = { connectionId?: string | null; @@ -16,7 +17,7 @@ export async function markCodexScopeRateLimited(params: { rateLimitedUntil: string; credentials?: CodexFailoverCredentials | null; }): Promise { - const connection = await getProviderConnectionById(params.failedConnectionId).catch(() => null); + const connection = await getCachedProviderConnectionById(params.failedConnectionId).catch(() => null); const existingProviderData = connection ? asProviderData(connection.providerSpecificData) : asProviderData(params.credentials?.providerSpecificData); diff --git a/open-sse/services/combo/concurrencyCaps.ts b/open-sse/services/combo/concurrencyCaps.ts index 329df99069..3384352abb 100644 --- a/open-sse/services/combo/concurrencyCaps.ts +++ b/open-sse/services/combo/concurrencyCaps.ts @@ -19,14 +19,14 @@ * shrinking (Quality Gate / #3501). */ -import { getProviderConnectionById } from "../../../src/lib/db/providers"; +import { getCachedProviderConnectionById } from "@/lib/localDb"; import { effectiveMaxConcurrency } from "./comboPredicates.ts"; import type { ResolvedComboTarget } from "./types.ts"; /** Read a connection's positive `maxConcurrent`, or null when unset / <= 0 / on error. */ export async function lookupPositiveCap(connectionId: string): Promise { try { - const conn = await getProviderConnectionById(connectionId); + const conn = await getCachedProviderConnectionById(connectionId); const raw = (conn as { maxConcurrent?: number | null } | null)?.maxConcurrent; return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : null; } catch { diff --git a/open-sse/services/combo/quotaExhaustionCutoff.ts b/open-sse/services/combo/quotaExhaustionCutoff.ts index e0b75c1873..016e78ab63 100644 --- a/open-sse/services/combo/quotaExhaustionCutoff.ts +++ b/open-sse/services/combo/quotaExhaustionCutoff.ts @@ -19,7 +19,7 @@ import { type PreflightQuotaThresholds, type QuotaInfo, } from "../quotaPreflight.ts"; -import { getProviderConnectionById } from "../../../src/lib/db/providers"; +import { getCachedProviderConnectionById } from "@/lib/localDb"; import { resolveResilienceSettings, type ResilienceSettings, @@ -108,7 +108,7 @@ export async function resolveQuotaExhaustionCutoffForTarget( let connection: Record | undefined; try { - connection = (await getProviderConnectionById(connectionId)) as + connection = (await getCachedProviderConnectionById(connectionId)) as | Record | undefined; } catch { diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 0be35d3161..c6f2557e85 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -1914,7 +1914,7 @@ async function _getAccessTokenWithStalenessCheck(provider, credentials, log, pro // We MUST check if the DB has a newer token before proceeding with a network refresh. if (credentials.connectionId) { try { - const { getProviderConnectionById } = await import("../../src/lib/db/providers"); + const { getProviderConnectionById } = await import("@/lib/db/providers"); const dbConnection = await getProviderConnectionById(credentials.connectionId); if (dbConnection && dbConnection.refreshToken) { const now = Date.now(); diff --git a/src/app/api/provider-nodes/route.ts b/src/app/api/provider-nodes/route.ts index e6dbeda5cf..6e9a62af52 100644 --- a/src/app/api/provider-nodes/route.ts +++ b/src/app/api/provider-nodes/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; -import { createProviderNode, getProviderNodes } from "@/models"; +import { createProviderNode } from "@/models"; +import { getCachedProviderNodes } from "@/lib/localDb"; import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, @@ -64,7 +65,7 @@ function sanitizeClaudeCodeCompatibleBaseUrl(baseUrl: string) { // GET /api/provider-nodes - List all provider nodes export async function GET() { try { - const nodes = await getProviderNodes(); + const nodes = await getCachedProviderNodes(); return NextResponse.json({ nodes, ccCompatibleProviderEnabled: isCcCompatibleProviderEnabled(), diff --git a/src/app/api/providers/[id]/login/route.ts b/src/app/api/providers/[id]/login/route.ts index ccc0719622..a095cd7f68 100644 --- a/src/app/api/providers/[id]/login/route.ts +++ b/src/app/api/providers/[id]/login/route.ts @@ -7,7 +7,7 @@ */ import { NextRequest, NextResponse } from "next/server"; -import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb"; +import { getCachedProviderConnectionById, updateProviderConnection } from "@/lib/localDb"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; @@ -21,7 +21,7 @@ export async function POST( if (auth) return auth; const { id } = await params; - const provider = await getProviderConnectionById(id); + const provider = await getCachedProviderConnectionById(id); if (!provider) { return NextResponse.json({ success: false, error: "Provider not found" }, { status: 404 }); } diff --git a/src/app/api/providers/[id]/refresh/route.ts b/src/app/api/providers/[id]/refresh/route.ts index 30c926270c..a6a0847c5c 100644 --- a/src/app/api/providers/[id]/refresh/route.ts +++ b/src/app/api/providers/[id]/refresh/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; -import { getProviderConnectionById, updateProviderConnection } from "@/lib/db/providers"; +import { getCachedProviderConnectionById } from "@/lib/localDb"; +import { updateProviderConnection } from "@/lib/db/providers"; import { getAccessToken, updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts"; @@ -21,7 +22,7 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id try { const { id } = await params; - const connection = await getProviderConnectionById(id); + const connection = await getCachedProviderConnectionById(id); if (!connection) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index a60b4a5c3a..414f0c53a0 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -5,11 +5,11 @@ import { summarizeProviderConnectionForAudit, } from "@/lib/compliance/providerAudit"; import { - getProviderConnectionById, + getCachedProviderConnectionById, updateProviderConnection, deleteProviderConnection, isCloudEnabled, -} from "@/models"; +} from "@/lib/localDb"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; import { updateProviderConnectionSchema } from "@/shared/validation/schemas"; @@ -60,7 +60,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: try { const { id } = await params; - const connection = await getProviderConnectionById(id); + const connection = await getCachedProviderConnectionById(id); if (!connection) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); @@ -144,7 +144,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: rateLimitOverrides, } = body; - const existing = (await getProviderConnectionById(id)) as Record | null; + const existing = (await getCachedProviderConnectionById(id)) as Record | null; if (!existing) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } @@ -347,7 +347,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i const { id } = await params; // Fetch connection before deleting to check provider type - const connection = (await getProviderConnectionById(id)) as Record | null; + const connection = (await getCachedProviderConnectionById(id)) as Record | null; if (!connection) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 7d5901fd0d..1ab80f1c5c 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getProviderConnectionById } from "@/models"; +import { getCachedProviderConnectionById } from "@/lib/localDb"; import { getSyncedAvailableModelsForConnection } from "@/lib/db/models"; import { selectModelsForImport } from "@/shared/utils/freeModels"; import { @@ -386,7 +386,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: ); } - const connection = await getProviderConnectionById(id); + const connection = await getCachedProviderConnectionById(id); if (!connection) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 39066bc4fe..8db4086671 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { - getProviderConnectionById, + getCachedProviderConnectionById, updateProviderConnection, isCloudEnabled, resolveProxyForConnection, @@ -625,7 +625,7 @@ async function testApiKeyConnection(connection: any) { * @returns {Promise} Test result (same shape as the JSON response) */ export async function testSingleConnection(connectionId: string, validationModelId?: string) { - const connection = await getProviderConnectionById(connectionId); + const connection = await getCachedProviderConnectionById(connectionId); if (!connection) { return { valid: false, error: "Connection not found", diagnosis: null, latencyMs: 0 }; diff --git a/src/app/api/providers/command-code/auth/apply/route.ts b/src/app/api/providers/command-code/auth/apply/route.ts index 9e4cb24f0a..3aee6b1a6e 100644 --- a/src/app/api/providers/command-code/auth/apply/route.ts +++ b/src/app/api/providers/command-code/auth/apply/route.ts @@ -2,9 +2,9 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { consumeCommandCodeAuthSecret } from "@/lib/db/commandCodeAuth"; import { createProviderConnection, - getProviderConnectionById, updateProviderConnection, } from "@/lib/db/providers"; +import { getCachedProviderConnectionById } from "@/lib/localDb"; import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults"; import { commandCodeApplySchema, noStoreJson, stateHashFromState } from "../shared"; @@ -42,7 +42,7 @@ export async function POST(request: Request) { let existing: Record | null = null; if (parsed.data.connectionId) { - existing = (await getProviderConnectionById(parsed.data.connectionId)) as Record< + existing = (await getCachedProviderConnectionById(parsed.data.connectionId)) as Record< string, unknown > | null; diff --git a/src/app/api/settings/export-json/route.ts b/src/app/api/settings/export-json/route.ts index 942a18caaf..83af8abc26 100644 --- a/src/app/api/settings/export-json/route.ts +++ b/src/app/api/settings/export-json/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { getSettings, getProviderConnections, - getProviderNodes, + getCachedProviderNodes, getCombos, getApiKeys, } from "@/lib/localDb"; @@ -69,7 +69,7 @@ export async function GET(request: Request) { const { password: _pw, requireLogin: _rl, ...safeSettings } = rawSettings; const providerConnections = await getProviderConnections(); - const providerNodes = await getProviderNodes(); + const providerNodes = await getCachedProviderNodes(); const combosRaw = await getCombos(); const apiKeys = await getApiKeys(); diff --git a/src/app/api/v1/audio/speech/route.ts b/src/app/api/v1/audio/speech/route.ts index 80a6dda969..16eff17c77 100644 --- a/src/app/api/v1/audio/speech/route.ts +++ b/src/app/api/v1/audio/speech/route.ts @@ -13,7 +13,7 @@ import { import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; -import { getProviderNodes } from "@/lib/localDb"; +import { getCachedProviderNodes } from "@/lib/localDb"; import { v1AudioSpeechSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { @@ -63,7 +63,7 @@ async function postHandler(request, context) { // Load local provider_nodes for audio routing (only localhost — prevents auth bypass/SSRF) let dynamicProviders: ReturnType[] = []; try { - const nodes = await getProviderNodes(); + const nodes = await getCachedProviderNodes(); dynamicProviders = (Array.isArray(nodes) ? (nodes as unknown as ProviderNodeRow[]) : []) .filter((n: ProviderNodeRow) => { if (n.apiType !== "chat" && n.apiType !== "responses") return false; diff --git a/src/app/api/v1/audio/transcriptions/route.ts b/src/app/api/v1/audio/transcriptions/route.ts index 3087d260d5..548f3e8993 100644 --- a/src/app/api/v1/audio/transcriptions/route.ts +++ b/src/app/api/v1/audio/transcriptions/route.ts @@ -14,7 +14,7 @@ import { import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; -import { getProviderNodes } from "@/lib/localDb"; +import { getCachedProviderNodes } from "@/lib/localDb"; import { isAllRateLimitedCredentials, rateLimitedProviderResponse, @@ -60,7 +60,7 @@ export async function POST(request) { // Load local provider_nodes for audio routing (only localhost — prevents auth bypass/SSRF) let dynamicProviders: ReturnType[] = []; try { - const nodes = await getProviderNodes(); + const nodes = await getCachedProviderNodes(); dynamicProviders = (Array.isArray(nodes) ? (nodes as unknown as ProviderNodeRow[]) : []) .filter((n: ProviderNodeRow) => { if (n.apiType !== "chat" && n.apiType !== "responses") return false; diff --git a/src/app/api/v1/audio/translations/route.ts b/src/app/api/v1/audio/translations/route.ts index 622afcfe29..7283dda555 100644 --- a/src/app/api/v1/audio/translations/route.ts +++ b/src/app/api/v1/audio/translations/route.ts @@ -14,7 +14,7 @@ import { import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; -import { getProviderNodes } from "@/lib/localDb"; +import { getCachedProviderNodes } from "@/lib/localDb"; import { isAllRateLimitedCredentials, rateLimitedProviderResponse, @@ -62,7 +62,7 @@ export async function POST(request) { // Load local provider_nodes for audio routing (only localhost — prevents auth bypass/SSRF) let dynamicProviders: ReturnType[] = []; try { - const nodes = await getProviderNodes(); + const nodes = await getCachedProviderNodes(); dynamicProviders = (Array.isArray(nodes) ? (nodes as unknown as ProviderNodeRow[]) : []) .filter((n: ProviderNodeRow) => { if (n.apiType !== "chat" && n.apiType !== "responses") return false; diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 2fd9c0a57e..3a550dd279 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -1,14 +1,15 @@ import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; import { NOAUTH_PROVIDERS } from "@/shared/constants/providers"; import { - getProviderConnections, + getCachedRawProviderConnections, getCombos, getAllCustomModels, getSettings, - getProviderNodes, + getCachedProviderNodes, 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 { appendClaudeEffortVariants } from "@omniroute/open-sse/utils/claudeEffortVariants"; @@ -323,7 +324,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); @@ -335,7 +336,7 @@ async function buildUnifiedModelsResponseCore( // Get provider nodes (for compatible providers with custom prefixes) let providerNodes = []; try { - providerNodes = await getProviderNodes(); + providerNodes = await getCachedProviderNodes(); } catch (e) { console.log("Could not fetch provider nodes"); } diff --git a/src/app/api/v1/rerank/route.ts b/src/app/api/v1/rerank/route.ts index a2f60bb623..7130af0d20 100644 --- a/src/app/api/v1/rerank/route.ts +++ b/src/app/api/v1/rerank/route.ts @@ -10,7 +10,7 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; import { v1RerankSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { getProviderNodes } from "@/lib/localDb"; +import { getCachedProviderNodes } from "@/lib/localDb"; import { isAllRateLimitedCredentials, rateLimitedProviderResponse, @@ -73,7 +73,7 @@ async function postHandler(request, context) { // Load local provider_nodes for rerank routing (localhost only) let localProviders: ReturnType[] = []; try { - const nodes = await getProviderNodes(); + const nodes = await getCachedProviderNodes(); localProviders = (Array.isArray(nodes) ? nodes : []) .filter((n: any) => { try { diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index 75b037b986..f37dbc2533 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -14,7 +14,7 @@ */ import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts"; -import { getProviderConnectionById, resolveProxyForConnection } from "@/lib/localDb"; +import { getCachedProviderConnectionById, resolveProxyForConnection } from "@/lib/localDb"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { safePercentage } from "@/shared/utils/formatting"; import { @@ -509,7 +509,7 @@ async function refreshEntry(entry: QuotaCacheEntry) { refreshingSet.add(entry.connectionId); try { - const connection = await getProviderConnectionById(entry.connectionId); + const connection = await getCachedProviderConnectionById(entry.connectionId); if (!connection || connection.authType !== "oauth" || !connection.isActive) { cache.delete(entry.connectionId); return; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 926f8e1fa1..708b1c304b 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 { invalidateReasoningRoutingRuleCache } from "./reasoningRoutingRules"; import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults"; import { bumpProxyConfigGeneration } from "./settings"; @@ -94,7 +95,36 @@ const PROVIDER_CONNECTIONS_COLUMNS = new Set([ // ──────────────── Provider Connections ──────────────── +/** + * Returns provider connections as lazy-decrypting proxies: encrypted + * credential fields (apiKey, accessToken, refreshToken, idToken) are only + * decrypted on first property access, not eagerly for every row. Column- + * projected reads (`columns` passed) bypass the raw-row cache — the cache + * key doesn't account for projection, so a projected read could otherwise + * poison the cache for a subsequent full-row read of the same filter. + */ export async function getProviderConnections(filter: JsonRecord = {}, columns?: string[]) { + const raw = columns?.length + ? await getRawProviderConnections(filter, columns) + : await getCachedRawProviderConnections(filter); + return raw.map(createLazyRowProxy); +} + +/** + * Same as getProviderConnections but WITHOUT decryptConnectionFields. + * Returns raw rows with encrypted credential fields intact — callers + * that only need metadata (id, priority, backoffLevel, etc.) avoid + * the O(n) AES-GCM decrypt cost on every cache fill. + * + * 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 = {}, columns?: string[]) { const db = getDbInstance() as unknown as DbLike; let selectCols = "*"; if (columns?.length) { @@ -125,25 +155,29 @@ export async function getProviderConnections(filter: JsonRecord = {}, columns?: 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) => { const camelRow = rowToCamel(r); - return decryptConnectionFields( - withNullableRateLimitOverrides( - withNullableQuotaWindowThresholds( - withNullableMaxConcurrent(cleanNulls(camelRow), camelRow), - camelRow - ), + return withNullableRateLimitOverrides( + withNullableQuotaWindowThresholds( + withNullableMaxConcurrent(cleanNulls(camelRow), camelRow), camelRow - ) + ), + camelRow ); }); -} + } export async function getProviderConnectionById(id: string) { const db = getDbInstance() as unknown as DbLike; @@ -733,6 +767,34 @@ export async function clearConnectionErrorIfUnchanged( return applied; } +/** + * Lightweight stat bump — updates lastUsedAt and consecutiveUseCount without + * SELECT, re-encrypt, cache invalidation, or file backup. + * Safe for the hot getProviderCredentials path where only usage stats change. + * Fixes the cache-thrashing bug where every credential selection invalidated + * the 5s TTL cache and paid 3000-row decryption cost on the next request. + */ +export async function touchConnectionLastUsed( + id: string, + consecutiveUseCount: number +): Promise { + if (!id) return; + const db = getDbInstance() as unknown as DbLike; + const now = new Date().toISOString(); + db.prepare( + `UPDATE provider_connections SET + last_used_at = @lastUsedAt, + consecutive_use_count = @consecutiveUseCount, + updated_at = @updatedAt + WHERE id = @id` + ).run({ + lastUsedAt: now, + consecutiveUseCount, + updatedAt: now, + id, + }); +} + export async function deleteProviderConnection(id: string) { const db = getDbInstance() as unknown as DbLike; const existing = db.prepare("SELECT provider FROM provider_connections WHERE id = ?").get(id); @@ -941,9 +1003,9 @@ export { setConnectionRateLimitUntil, markConnectionRateLimitedUntil, clearConnectionRateLimit, - isConnectionRateLimited, - getRateLimitedConnections, 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/providers/nodes.ts b/src/lib/db/providers/nodes.ts index 667f00900c..0569a4527c 100644 --- a/src/lib/db/providers/nodes.ts +++ b/src/lib/db/providers/nodes.ts @@ -6,6 +6,7 @@ import { v4 as uuidv4 } from "uuid"; import { getDbInstance, rowToCamel } from "../core"; import { selectProviderNodeForConnection } from "../providerNodeSelect"; import { backupDbFile } from "../backup"; +import { invalidateDbCache } from "../readCache"; import { toRecord, type JsonRecord } from "./columns"; interface StatementLike { @@ -79,6 +80,7 @@ export async function createProviderNode(data: JsonRecord) { ).run(node); backupDbFile("pre-write"); + invalidateDbCache("nodes"); const result: JsonRecord = { ...node }; if (customHeadersJson) { @@ -142,6 +144,7 @@ export async function updateProviderNode(id: string, data: JsonRecord) { }); backupDbFile("pre-write"); + invalidateDbCache("nodes"); const result: JsonRecord = { ...merged }; const storedJson = merged["customHeadersJson"] as string | null; @@ -165,5 +168,6 @@ export async function deleteProviderNode(id: string) { db.prepare("DELETE FROM provider_nodes WHERE id = ?").run(id); backupDbFile("pre-write"); + invalidateDbCache("nodes"); return rowToCamel(existing); } diff --git a/src/lib/db/readCache.ts b/src/lib/db/readCache.ts index 8ce10f1460..b264f219d0 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,18 +101,67 @@ export async function getCachedPricing(): Promise> { export async function getCachedProviderConnections( filter?: Record ): Promise { - // Only cache the unfiltered "all connections" query (most common) - if (filter && Object.keys(filter).length > 0) { - const { getProviderConnections } = await import("@/lib/db/providers"); - return getProviderConnections(filter); - } + const { getProviderConnections } = await import("@/lib/db/providers"); + return getProviderConnections(filter || {}); +} - const cached = connectionsCache.get("all"); +const rawConnectionsCache = new TTLCache(CONNECTIONS_TTL_MS, 500); + +/** + * Cached wrapper for getRawProviderConnections. + * Same 5s TTL as the encrypted variant but preserves ciphertext fields + * for lazy decryption — used by the auth selection hot path where 10k+ + * connections are filtered to find the winner but only 1 row needs + * credential decryption. + */ +export async function getCachedRawProviderConnections( + filter?: Record +): Promise { + const key = JSON.stringify(filter ?? {}); + const cached = rawConnectionsCache.get(key); + if (cached !== undefined) return cached; + const { getRawProviderConnections } = await import("./providers"); + const rows = await getRawProviderConnections(filter); + rawConnectionsCache.set(key, rows); + return rows; +} + +const connectionByIdCache = new TTLCache | null>(CONNECTIONS_TTL_MS, 10_000); +const nodesCache = new TTLCache(CONNECTIONS_TTL_MS); + +/** + * Cached wrapper for getProviderConnectionById. + * Keyed by connection ID, shared 5s TTL. + * Invalidated on every provider_connections write. + */ +export async function getCachedProviderConnectionById( + id: string +): Promise | null> { + if (!id) return null; + const cached = connectionByIdCache.get(id); + if (cached !== undefined) return cached; + + const { getProviderConnectionById } = await import("@/lib/db/providers"); + const value = await getProviderConnectionById(id); + connectionByIdCache.set(id, value); + return value; +} + +/** + * Cached wrapper for getProviderNodes. + * Keyed by JSON-serialized filter, shared 5s TTL. + * Invalidated on every provider_nodes write. + */ +export async function getCachedProviderNodes( + filter?: Record +): Promise { + const cacheKey = filter ? JSON.stringify(filter) : "all"; + const cached = nodesCache.get(cacheKey); if (cached) return cached; - const { getProviderConnections } = await import("@/lib/db/providers"); - const value = await getProviderConnections(); - connectionsCache.set("all", value); + const { getProviderNodes } = await import("@/lib/db/providers"); + const value = await getProviderNodes(filter); + nodesCache.set(cacheKey, value); return value; } @@ -187,13 +244,29 @@ export function getModelCatalogCacheVersion(): number { } /** - * Invalidate all caches (call after writes to any of: settings, pricing, - * connections, combos). + * 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"): void { +export function invalidateDbCache( + 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(); + if (!scope || scope === "connections") { + rawConnectionsCache.invalidate(); + if (id) { + connectionByIdCache.invalidate(id); + } else { + connectionByIdCache.invalidate(); + } + } + if (!scope || scope === "nodes") nodesCache.invalidate(); if (!scope || scope === "combos") combosCacheVersion++; // Settings/connections/combos all feed the unified model catalog builder // (blockedProviders + hidePaidModels, provider connections + excludedModels, diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts index 19c5da6fa6..ba3d0bb43d 100644 --- a/src/lib/embeddings/service.ts +++ b/src/lib/embeddings/service.ts @@ -11,7 +11,7 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import * as log from "@/sse/utils/logger"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; -import { getProviderNodes, getComboByName, getCombos, getDatabaseSettings } from "@/lib/localDb"; +import { getCachedProviderNodes, getComboByName, getCombos, getDatabaseSettings } from "@/lib/localDb"; import { resolveProxyForConnection } from "@/lib/db/settings"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { handleComboChat } from "@omniroute/open-sse/services/combo.ts"; @@ -124,7 +124,7 @@ export async function createEmbeddingResponse( } let dynamicProviders: ReturnType[] = []; try { - const nodes = (await getProviderNodes()) as unknown as EmbeddingProviderNodeRow[]; + const nodes = (await getCachedProviderNodes()) as unknown as EmbeddingProviderNodeRow[]; dynamicProviders = (Array.isArray(nodes) ? nodes : []) .filter((n) => { const validTypes = ["chat", "responses", "embeddings"]; @@ -163,7 +163,7 @@ export async function createEmbeddingResponse( if (!providerConfig) { try { - const allNodes = (await getProviderNodes()) as unknown as EmbeddingProviderNodeRow[]; + const allNodes = (await getCachedProviderNodes()) as unknown as EmbeddingProviderNodeRow[]; const matchingNode = (Array.isArray(allNodes) ? allNodes : []).find( (n) => n.prefix === provider && diff --git a/src/lib/images/imageRouteModel.ts b/src/lib/images/imageRouteModel.ts index 669c27b567..0203b102d1 100644 --- a/src/lib/images/imageRouteModel.ts +++ b/src/lib/images/imageRouteModel.ts @@ -18,7 +18,7 @@ import { parseImageModel } from "@omniroute/open-sse/config/imageRegistry.ts"; import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts"; import { getComboByName, getCombos } from "@/lib/db/combos"; -import { getProviderNodes } from "@/lib/db/providers"; +import { getCachedProviderNodes } from "@/lib/localDb"; /** * Rewrite a `prefix/model` custom image model to its internal `/` form. @@ -36,7 +36,7 @@ export async function resolveImageModelPrefix(modelStr: string): Promise if (!rest) return modelStr; try { - const nodes = await getProviderNodes({ type: "openai-compatible" }); + const nodes = await getCachedProviderNodes({ type: "openai-compatible" }); // node.id (internal UUID) is already a valid internal id; only rewrite when a // user-defined prefix differs from the node id. const matched = nodes.find((node: { prefix?: unknown }) => node.prefix === prefixPart); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 63c5079519..c652e9bd72 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -13,6 +13,7 @@ export { createProviderConnection, updateProviderConnection, clearConnectionErrorIfUnchanged, + touchConnectionLastUsed, deleteProviderConnection, deleteProviderConnections, deleteProviderConnectionsByProvider, @@ -31,8 +32,6 @@ export { setConnectionRateLimitUntil, isConnectionRateLimited, getRateLimitedConnections, - - // T05 startup recovery: clear stale transient cooldowns left by a prior crash clearStaleCrashCooldowns, // T13: Stale quota display fix (zero out usage after window resets) @@ -238,6 +237,9 @@ export { getCachedSettings, getCachedPricing, getCachedProviderConnections, + getCachedRawProviderConnections, + getCachedProviderConnectionById, + getCachedProviderNodes, getCachedLKGP, setCachedLKGP, invalidateDbCache, diff --git a/src/lib/memory/embedding/index.ts b/src/lib/memory/embedding/index.ts index aaf645ddbc..0033e6c9e6 100644 --- a/src/lib/memory/embedding/index.ts +++ b/src/lib/memory/embedding/index.ts @@ -4,7 +4,7 @@ import { type EmbeddingProviderNodeRow, } from "@omniroute/open-sse/config/embeddingRegistry.ts"; import { getProviderCredentials } from "@/sse/services/auth"; -import { getProviderNodes } from "@/lib/localDb"; +import { getCachedProviderNodes } from "@/lib/localDb"; import type { MemorySettingsExtended } from "@/shared/schemas/memory"; import type { EmbeddingResolution, @@ -217,7 +217,7 @@ export async function listEmbeddingProviders(): Promise[] = []; try { - const nodes = (await getProviderNodes()) as unknown as EmbeddingProviderNodeRow[]; + const nodes = (await getCachedProviderNodes()) as unknown as EmbeddingProviderNodeRow[]; dynamicProviders = (Array.isArray(nodes) ? nodes : []) .filter((n) => { const validTypes = ["chat", "responses", "embeddings"]; diff --git a/src/lib/monitoring/providerHealthAutopilot.ts b/src/lib/monitoring/providerHealthAutopilot.ts index c69b8ab7c1..8fc54e2d86 100644 --- a/src/lib/monitoring/providerHealthAutopilot.ts +++ b/src/lib/monitoring/providerHealthAutopilot.ts @@ -1,10 +1,10 @@ import { createHash } from "crypto"; import { - getProviderConnectionById, getProviderConnections, updateProviderConnection, } from "@/lib/db/providers"; +import { getCachedProviderConnectionById } from "@/lib/localDb"; import { clearProviderFailure, clearModelLock } from "@omniroute/open-sse/services/accountFallback"; type JsonRecord = Record; @@ -672,7 +672,7 @@ export async function executeProviderHealthAutopilotAction( if (!connectionId) { return { status: 400, body: { success: false, error: "connectionId is required" } }; } - const connection = (await getProviderConnectionById(connectionId)) as JsonRecord | null; + const connection = (await getCachedProviderConnectionById(connectionId)) as JsonRecord | null; if (!connection || connection.provider !== provider) { return { status: 404, body: { success: false, error: "connection not found" } }; } @@ -692,7 +692,7 @@ export async function executeProviderHealthAutopilotAction( body: { success: false, error: "connectionId and model are required" }, }; } - const connection = (await getProviderConnectionById(connectionId)) as JsonRecord | null; + const connection = (await getCachedProviderConnectionById(connectionId)) as JsonRecord | null; if (!connection || connection.provider !== provider) { return { status: 404, body: { success: false, error: "connection not found" } }; } @@ -715,7 +715,7 @@ export async function executeProviderHealthAutopilotAction( if (!connectionId) { return { status: 400, body: { success: false, error: "connectionId is required" } }; } - const connection = (await getProviderConnectionById(connectionId)) as JsonRecord | null; + const connection = (await getCachedProviderConnectionById(connectionId)) as JsonRecord | null; if (!connection || connection.provider !== provider) { return { status: 404, body: { success: false, error: "connection not found" } }; } diff --git a/src/lib/oauth/utils/claudeAuthFile.ts b/src/lib/oauth/utils/claudeAuthFile.ts index d6c853f1e1..681c0eaab0 100644 --- a/src/lib/oauth/utils/claudeAuthFile.ts +++ b/src/lib/oauth/utils/claudeAuthFile.ts @@ -1,6 +1,6 @@ import fs from "fs/promises"; import path from "path"; -import { getProviderConnectionById } from "@/lib/localDb"; +import { getCachedProviderConnectionById } from "@/lib/localDb"; import { createBackup } from "@/shared/services/backupService"; import { getCliConfigPaths } from "@/shared/services/cliRuntime"; import { @@ -172,7 +172,7 @@ export function buildClaudeAuthPayload(connection: ClaudeConnectionLike): Claude } async function resolveFreshClaudeConnection(connectionId: string): Promise { - const connection = (await getProviderConnectionById(connectionId)) as ClaudeConnectionLike | null; + const connection = (await getCachedProviderConnectionById(connectionId)) as ClaudeConnectionLike | null; if (!connection) { throw new ClaudeAuthFileError("Connection not found", 404, "not_found"); } diff --git a/src/lib/oauth/utils/codexAuthFile.ts b/src/lib/oauth/utils/codexAuthFile.ts index eec0841e50..83e107489c 100644 --- a/src/lib/oauth/utils/codexAuthFile.ts +++ b/src/lib/oauth/utils/codexAuthFile.ts @@ -1,6 +1,6 @@ import fs from "fs/promises"; import path from "path"; -import { getProviderConnectionById } from "@/lib/localDb"; +import { getCachedProviderConnectionById } from "@/lib/localDb"; import { createBackup } from "@/shared/services/backupService"; import { getCliConfigPaths } from "@/shared/services/cliRuntime"; import { @@ -197,7 +197,7 @@ function buildCodexAuthPayload(connection: CodexConnectionLike): CodexAuthFilePa } async function resolveFreshCodexConnection(connectionId: string): Promise { - const connection = (await getProviderConnectionById(connectionId)) as CodexConnectionLike | null; + const connection = (await getCachedProviderConnectionById(connectionId)) as CodexConnectionLike | null; if (!connection) { throw new CodexAuthFileError("Connection not found", 404, "not_found"); } diff --git a/src/lib/quota/connectionProvider.ts b/src/lib/quota/connectionProvider.ts index 5078105a10..3255bffad6 100644 --- a/src/lib/quota/connectionProvider.ts +++ b/src/lib/quota/connectionProvider.ts @@ -18,9 +18,9 @@ export async function resolveConnectionProvider(connectionId: string): Promise { try { // Lazy import — avoids circular deps and keeps the module loadable without a full DB. - const { getProviderConnectionById } = await import("@/lib/localDb"); - if (typeof getProviderConnectionById === "function") { - const conn = await getProviderConnectionById(connectionId); + const { getCachedProviderConnectionById } = await import("@/lib/localDb"); + if (typeof getCachedProviderConnectionById === "function") { + const conn = await getCachedProviderConnectionById(connectionId); if (conn && typeof (conn as { provider?: string }).provider === "string") { return (conn as { provider: string }).provider; } diff --git a/src/lib/quota/quotaCombos.ts b/src/lib/quota/quotaCombos.ts index 4d4c17863b..25be9cca00 100644 --- a/src/lib/quota/quotaCombos.ts +++ b/src/lib/quota/quotaCombos.ts @@ -12,7 +12,7 @@ import { getPool } from "@/lib/db/quotaPools"; import { getGroupName } from "@/lib/db/quotaGroups"; -import { getProviderConnectionById } from "@/lib/db/providers"; +import { getCachedProviderConnectionById } from "@/lib/localDb"; import { getCombos, createCombo, @@ -152,7 +152,7 @@ export async function syncQuotaCombos(poolId: string): Promise { for (const connId of pool.connectionIds) { let connection: Record | null = null; try { - connection = (await getProviderConnectionById(connId)) as Record | null; + connection = (await getCachedProviderConnectionById(connId)) as Record | null; } catch { // Connection lookup failure — skip this connection. continue; @@ -361,7 +361,7 @@ export async function removeQuotaCombosForPool(poolId: string): Promise { let poolProvider: string | undefined; for (const connId of pool.connectionIds) { try { - const connection = (await getProviderConnectionById(connId)) as Record< + const connection = (await getCachedProviderConnectionById(connId)) as Record< string, unknown > | null; diff --git a/src/lib/quota/quotaKey.ts b/src/lib/quota/quotaKey.ts index ff4d3636b1..7207798bcc 100644 --- a/src/lib/quota/quotaKey.ts +++ b/src/lib/quota/quotaKey.ts @@ -8,7 +8,7 @@ */ import { getPool, getPoolsByGroup } from "@/lib/db/quotaPools"; -import { getProviderConnectionById } from "@/lib/db/providers"; +import { getCachedProviderConnectionById } from "@/lib/localDb"; import { getApiKeyById, updateApiKeyPermissions } from "@/lib/db/apiKeys"; import { quotaGroupSlug } from "./quotaModelNaming"; import { getGroupName } from "@/lib/db/quotaGroups"; @@ -114,7 +114,7 @@ export async function resolveQuotaKeyScope( : [groupPool.connectionId]; for (const connId of connIds) { - const connection = await getProviderConnectionById(connId); + const connection = await getCachedProviderConnectionById(connId); if (!connection) continue; // missing connection contributes nothing const provider = (connection as Record).provider; diff --git a/src/lib/quota/saturationSignals.ts b/src/lib/quota/saturationSignals.ts index 4ec9a8baf1..50b6b467da 100644 --- a/src/lib/quota/saturationSignals.ts +++ b/src/lib/quota/saturationSignals.ts @@ -368,13 +368,13 @@ export function __setAnthropicSaturationDepsForTests( } async function defaultAnthropicDeps(): Promise { - const [providersMod, usageMod] = await Promise.all([ - import("@/lib/db/providers"), + const [localDbMod, usageMod] = await Promise.all([ + import("@/lib/localDb"), import("@omniroute/open-sse/services/usage"), ]); return { loadConnection: (connectionId) => - providersMod.getProviderConnectionById(connectionId) as Promise | null>, diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index 5593edfdba..345b5892d7 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -13,7 +13,7 @@ import { getProviderConnections, - getProviderConnectionById, + getCachedProviderConnectionById, updateProviderConnection, getSettings, resolveProxyForConnection, @@ -354,7 +354,7 @@ export async function sweep() { export async function checkConnection(conn) { if (!conn?.id) return; - const latestConnection = (await getProviderConnectionById(conn.id)) || conn; + const latestConnection = (await getCachedProviderConnectionById(conn.id)) || conn; conn = latestConnection; // Per-provider opt-out of proactive refresh (e.g. Codex/OpenAI cascade @@ -643,7 +643,7 @@ export async function checkConnection(conn) { // Once used, the old token is permanently invalidated. // Retrying will never succeed → deactivate and stop the loop. if (isUnrecoverableRefreshError(result)) { - const currentConnection = await getProviderConnectionById(conn.id); + const currentConnection = await getCachedProviderConnectionById(conn.id); const credentialsChangedSinceSweep = !!currentConnection && (currentConnection.refreshToken !== attemptedRefreshToken || @@ -758,7 +758,7 @@ export async function checkConnection(conn) { // providerSpecificData.copilotTokenExpiresAt (Unix seconds). if (String(conn.provider || "").toLowerCase() === "github") { // Re-read the latest connection after the OAuth refresh (onPersist may have updated it). - const latestConn = (await getProviderConnectionById(conn.id).catch(() => null)) || conn; + const latestConn = (await getCachedProviderConnectionById(conn.id).catch(() => null)) || conn; const accessTokenForCopilot = result.accessToken || latestConn.accessToken; if (accessTokenForCopilot) { diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 67fa61330a..dca2d10ad1 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -1,14 +1,21 @@ import { randomUUID, createHash } from "crypto"; import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts"; import { + getCachedRawProviderConnections, getProviderConnections, - getProviderNodes, + getCachedProviderNodes, validateApiKey, updateProviderConnection, + touchConnectionLastUsed, clearConnectionErrorIfUnchanged, getSettings, getCachedSettings, } from "@/lib/localDb"; +import { + createLazyConnectionView, + toProviderConnection, + type ProviderConnectionView, +} from "@/lib/db/providers/lazyConnectionView"; import { DEFAULT_QUOTA_THRESHOLD_PERCENT, getQuotaCache, @@ -70,37 +77,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; @@ -159,44 +135,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; @@ -985,7 +923,7 @@ async function getProviderSearchPool(provider: string): Promise { // (for example "78code/gpt-5.4"), but live credentials are stored under // internal provider ids like openai-compatible-responses-. try { - const providerNodes = await getProviderNodes(); + const providerNodes = await getCachedProviderNodes(); for (const node of Array.isArray(providerNodes) ? providerNodes : []) { const nodeRecord = asRecord(node); const nodePrefix = typeof nodeRecord.prefix === "string" ? nodeRecord.prefix.trim() : ""; @@ -1067,12 +1005,12 @@ export async function getProviderCredentials( // Fix #922: Check for aliases (nvidia/nvidia_nim) to ensure credentials are found const providersToSearch = await getProviderSearchPool(provider); const connectionResults = await Promise.all( - providersToSearch.map((p) => getProviderConnections({ provider: p, isActive: true })) + providersToSearch.map((p) => getCachedRawProviderConnections({ provider: p, isActive: true })) ); const connectionsRaw = connectionResults.filter(Array.isArray).flat(); let connections = (Array.isArray(connectionsRaw) ? connectionsRaw : []) - .map(toProviderConnection) + .map(createLazyConnectionView) .filter((conn) => conn.id.length > 0); // allowedConnections: restrict to specific connection IDs (from API key policy, #363) if (allowedConnections && allowedConnections.length > 0) { @@ -1534,10 +1472,16 @@ export async function getProviderCredentials( `${provider} round-robin: staying with ${current.id?.slice(0, 8)}... (count=${currentCount}/${stickyLimit})` ); // Update lastUsedAt and increment count (await to ensure persistence) - await updateProviderConnection(connection.id, { - lastUsedAt: new Date().toISOString(), - consecutiveUseCount: (connection.consecutiveUseCount || 0) + 1, - }); + const nextCount = (connection.consecutiveUseCount || 0) + 1; + await touchConnectionLastUsed(connection.id, nextCount); + // Sync raw cache row so subsequent calls within TTL see fresh stats + for (const r of connectionsRaw as Record[]) { + if (r.id === connection.id) { + r.lastUsedAt = new Date().toISOString(); + r.consecutiveUseCount = nextCount; + break; + } + } } else { // Pick the least recently used (excluding current if possible) // Also penalize accounts with high backoffLevel (previously rate-limited) @@ -1560,10 +1504,15 @@ export async function getProviderCredentials( ); // Update lastUsedAt and reset count to 1 (await to ensure persistence) - await updateProviderConnection(connection.id, { - lastUsedAt: new Date().toISOString(), - consecutiveUseCount: 1, - }); + await touchConnectionLastUsed(connection.id, 1); + // Sync raw cache row so subsequent calls within TTL see fresh LRU stats + for (const r of connectionsRaw as Record[]) { + if (r.id === connection.id) { + r.lastUsedAt = new Date().toISOString(); + r.consecutiveUseCount = 1; + break; + } + } } } else { // Fallback scenario: excluded an account due to failure @@ -1586,10 +1535,15 @@ export async function getProviderCredentials( ); // Update lastUsedAt and reset count to 1 (await to ensure persistence) - await updateProviderConnection(connection.id, { - lastUsedAt: new Date().toISOString(), - consecutiveUseCount: 1, - }); + await touchConnectionLastUsed(connection.id, 1); + // Sync raw cache row so subsequent calls within TTL see fresh stats + for (const r of connectionsRaw as Record[]) { + if (r.id === connection.id) { + r.lastUsedAt = new Date().toISOString(); + r.consecutiveUseCount = 1; + break; + } + } } } else if (strategy === "p2c") { const candidatePool = withQuota.length > 0 ? withQuota : orderedConnections; diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 858cb375cb..590d0e9257 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -4,7 +4,7 @@ import { getComboByName, getComboById, getComboByNameInsensitive, - getProviderNodes, + getCachedProviderNodes, getCustomModels, } from "@/lib/localDb"; import { getCachedSettings } from "@/lib/localDb"; @@ -282,7 +282,7 @@ export async function getModelInfo(modelStr) { // Match by node.prefix (user-defined alias) OR node.id (internal UUID id stored by // combo steps), so that combo targets using the internal node id still resolve // correctly (#2778). - const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); + const openaiNodes = await getCachedProviderNodes({ type: "openai-compatible" }); const matchedOpenAI = openaiNodes.find( (node) => node.prefix === prefixToCheck || node.id === prefixToCheck ); @@ -304,7 +304,7 @@ export async function getModelInfo(modelStr) { } // Check Anthropic Compatible nodes - const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" }); + const anthropicNodes = await getCachedProviderNodes({ type: "anthropic-compatible" }); const matchedAnthropic = anthropicNodes.find( (node) => node.prefix === prefixToCheck || node.id === prefixToCheck ); diff --git a/tests/unit/db-providers-split.test.ts b/tests/unit/db-providers-split.test.ts index 73bc9259cb..220544ba2d 100644 --- a/tests/unit/db-providers-split.test.ts +++ b/tests/unit/db-providers-split.test.ts @@ -99,10 +99,11 @@ describe("providers/columns — small coercers", () => { const host = await import("../../src/lib/db/providers.ts"); -describe("providers.ts public API surface (23 symbols)", () => { +describe("providers.ts public API surface (22 symbols)", () => { const expected = [ // Connection CRUD (kept in host) "getProviderConnections", + "getRawProviderConnections", "getProviderConnectionById", "createProviderConnection", "updateProviderConnection", @@ -122,8 +123,6 @@ describe("providers.ts public API surface (23 symbols)", () => { "deleteProviderNode", // Rate-limit / quota runtime (re-exported from ./providers/rateLimit) "setConnectionRateLimitUntil", - "isConnectionRateLimited", - "getRateLimitedConnections", "getEffectiveQuotaUsage", "clearStaleCrashCooldowns", "formatResetCountdown", @@ -135,7 +134,7 @@ describe("providers.ts public API surface (23 symbols)", () => { }); } - it("exposes exactly the 23 expected callables (no public symbol lost)", () => { + it("exposes exactly the 22 expected callables (no public symbol lost)", () => { const missing = expected.filter((n) => typeof host[n] !== "function"); assert.deepEqual(missing, [], `missing public exports: ${missing.join(", ")}`); }); diff --git a/tests/unit/db-read-cache.test.ts b/tests/unit/db-read-cache.test.ts index d52832e3c6..a98bff5aeb 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(); @@ -163,3 +163,108 @@ test("cached LKGP values refresh only after the specific key is invalidated", as assert.deepEqual(await readCache.getCachedLKGP(comboName, modelId), { provider: "gemini" }); }); + +test("staleness regression: getProviderConnections returns fresh data after connection deleted", async () => { + const readCache = await importFresh("src/lib/db/readCache.ts"); + + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "Staleness Test", + apiKey: "sk-stale-test", + }); + + const before = await providersDb.getProviderConnections(); + assert.ok(before.length > 0); + assert.ok(before.some((c) => c.name === "Staleness Test")); + + await providersDb.deleteProviderConnection(conn.id); + + const after = await providersDb.getProviderConnections(); + assert.equal(after.filter((c) => c.name === "Staleness Test").length, 0); +}); + +test("staleness regression: getProviderConnections returns fresh data after deleteProviderConnectionsByProvider", async () => { + const readCache = await importFresh("src/lib/db/readCache.ts"); + + await providersDb.createProviderConnection({ + provider: "test-stale-batch", + authType: "apikey", + name: "Batch Stale Conn", + apiKey: "sk-batch-stale", + }); + + const before = await providersDb.getProviderConnections(); + assert.ok(before.some((c) => c.provider === "test-stale-batch")); + + await providersDb.deleteProviderConnectionsByProvider("test-stale-batch"); + + const after = await providersDb.getProviderConnections(); + assert.equal(after.filter((c) => c.provider === "test-stale-batch").length, 0); +}); + +test("getCachedProviderConnectionById caches result and invalidates on connections write", async () => { + const readCache = await importFresh("src/lib/db/readCache.ts"); + const db = core.getDbInstance(); + + await providersDb.createProviderConnection({ + provider: "anthropic", + authType: "apikey", + name: "Cached Conn", + apiKey: "sk-cached-conn", + }); + + const allConns = await providersDb.getProviderConnections(); + const id = allConns[allConns.length - 1].id; + + const firstRead = await readCache.getCachedProviderConnectionById(id); + assert.ok(firstRead); + assert.equal(firstRead.name, "Cached Conn"); + + db.prepare("UPDATE provider_connections SET name = ? WHERE id = ?").run("Stale", id); + + const cachedRead = await readCache.getCachedProviderConnectionById(id); + assert.equal(cachedRead.name, "Cached Conn"); + + readCache.invalidateDbCache("connections"); + + const freshRead = await readCache.getCachedProviderConnectionById(id); + assert.ok(freshRead); + assert.equal(freshRead.name, "Stale"); +}); + +test("getCachedProviderNodes caches results and invalidates on nodes write", async () => { + const readCache = await importFresh("src/lib/db/readCache.ts"); + const nodesDb = await importFresh("src/lib/db/providers/nodes.ts"); + const db = core.getDbInstance(); + const now = new Date().toISOString(); + + await nodesDb.createProviderNode({ + id: "node-cache-test", + type: "openai", + name: "Cached Node", + baseUrl: "https://cached.example.com", + createdAt: now, + updatedAt: now, + }); + + const firstRead = await readCache.getCachedProviderNodes(); + const matching = firstRead.filter((n) => n.id === "node-cache-test"); + assert.equal(matching.length, 1); + assert.equal(matching[0].name, "Cached Node"); + + db.prepare( + "INSERT INTO provider_nodes (id, type, name, base_url, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ).run("node-direct-test", "openai", "Direct Insert Node", "https://direct.example.com", now, now); + + const cachedNodes = await readCache.getCachedProviderNodes(); + const matchingCached = cachedNodes.filter((n) => n.id === "node-direct-test"); + assert.equal(matchingCached.length, 0); + + readCache.invalidateDbCache("nodes"); + + const freshNodes = await readCache.getCachedProviderNodes(); + const matchingFresh = freshNodes.filter((n) => n.id === "node-direct-test"); + assert.equal(matchingFresh.length, 1); + assert.equal(matchingFresh[0].name, "Direct Insert Node"); +});