Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/lib/db/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,53 @@ export async function getProviderConnections(filter: JsonRecord = {}) {
});
}

/**
* 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.
*/
export async function getRawProviderConnections(filter: JsonRecord = {}) {
const db = getDbInstance() as unknown as DbLike;
let sql = "SELECT * FROM provider_connections";
const conditions: string[] = [];
const params: Record<string, unknown> = {};

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 withNullableRateLimitOverrides(
withNullableQuotaWindowThresholds(
withNullableMaxConcurrent(cleanNulls(camelRow), camelRow),
camelRow
),
camelRow
);
});
}

export async function getProviderConnectionById(id: string) {
const db = getDbInstance() as unknown as DbLike;
const row = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id);
Expand Down
22 changes: 22 additions & 0 deletions src/lib/db/readCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ export async function getCachedProviderConnections(
connectionsCache.set(cacheKey, value);
return value;
}

const rawConnectionsCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS);

/**
* 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<string, unknown>
): Promise<unknown[]> {
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<Record<string, unknown> | null>(CONNECTIONS_TTL_MS);
const nodesCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS);

Expand Down
1 change: 1 addition & 0 deletions src/lib/localDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export {
getCachedSettings,
getCachedPricing,
getCachedProviderConnections,
getCachedRawProviderConnections,
getCachedProviderConnectionById,
getCachedProviderNodes,
getCachedLKGP,
Expand Down
44 changes: 41 additions & 3 deletions src/sse/services/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomUUID, createHash } from "crypto";
import {
getCachedRawProviderConnections,
getProviderConnections,
getCachedProviderConnections,
getCachedProviderNodes,
validateApiKey,
updateProviderConnection,
Expand All @@ -10,6 +10,7 @@ import {
getSettings,
getCachedSettings,
} from "@/lib/localDb";
import { decrypt } from "@/lib/db/encryption";
import {
DEFAULT_QUOTA_THRESHOLD_PERCENT,
getQuotaCache,
Expand Down Expand Up @@ -144,6 +145,43 @@ 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<string, unknown>): ProviderConnectionView {
const base = toProviderConnection(row);
let decrypted: Record<string, null | string> | 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;
Expand Down Expand Up @@ -1068,12 +1106,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) => getCachedProviderConnections({ 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) {
Expand Down
7 changes: 3 additions & 4 deletions tests/unit/db-providers-split.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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(", ")}`);
});
Expand Down