/** * db/providers.js — Provider connections and nodes CRUD. */ import { v4 as uuidv4 } from "uuid"; import { getDbInstance, rowToCamel, cleanNulls } from "./core"; import { backupDbFile } from "./backup"; import { encryptConnectionFields, decryptConnectionFields } from "./encryption"; import { invalidateDbCache } from "./readCache"; import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults"; type JsonRecord = Record; interface StatementLike { all: (...params: unknown[]) => TRow[]; get: (...params: unknown[]) => TRow | undefined; run: (...params: unknown[]) => { changes?: number }; } interface DbLike { prepare: (sql: string) => StatementLike; } function withNullableMaxConcurrent( record: JsonRecord, source: JsonRecord | null | undefined ): JsonRecord { if (!source || !Object.hasOwn(source, "maxConcurrent")) { return record; } const sourceMaxConcurrent = source.maxConcurrent; const normalizedMaxConcurrent = typeof sourceMaxConcurrent === "number" || sourceMaxConcurrent === null ? sourceMaxConcurrent : record.maxConcurrent; return { ...record, maxConcurrent: normalizedMaxConcurrent, }; } function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" ? (value as JsonRecord) : {}; } function toStringOrNull(value: unknown): string | null { return typeof value === "string" ? value : null; } function toNumberOrZero(value: unknown): number { return typeof value === "number" ? value : 0; } // ──────────────── 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 (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(withNullableMaxConcurrent(cleanNulls(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); if (!row) return null; const camelRow = rowToCamel(row); return decryptConnectionFields(withNullableMaxConcurrent(cleanNulls(camelRow), camelRow)); } export async function createProviderConnection(data: JsonRecord) { const db = getDbInstance() as unknown as DbLike; const now = new Date().toISOString(); const normalizedProviderSpecificData = normalizeProviderSpecificData( toStringOrNull(data.provider), data.providerSpecificData ); // Upsert check // For Codex/OpenAI, a single email can have multiple workspaces (Team + Personal) // We need to check for workspace uniqueness, not just email let existing: JsonRecord | null = null; if (data.authType === "oauth" && data.email) { // For Codex, check for existing connection with same workspace const providerSpecificData = toRecord(data.providerSpecificData); const workspaceId = toStringOrNull(providerSpecificData.workspaceId); if (data.provider === "codex" && workspaceId) { // For Codex, check for existing connection with same workspace AND email // A single workspace can have multiple users (Team/Business plans) // We need both workspace + email uniqueness to allow multiple accounts existing = (db .prepare( "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND email = ?" ) .get(data.provider, workspaceId, data.email) as JsonRecord | undefined) || null; // If no match with workspace+email, also check workspace-only for backward compat // (old connections without email should still be updated, not duplicated) if (!existing) { existing = (db .prepare( "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND (email IS NULL OR email = '')" ) .get(data.provider, workspaceId) as JsonRecord | undefined) || null; } // For Codex with workspaceId, don't fall back to email-only check // This allows creating new connections for different workspaces } else { // For other providers (or Codex without workspaceId), use email check existing = (db .prepare( "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND email = ?" ) .get(data.provider, data.email) as JsonRecord | undefined) || null; } } else if (data.authType === "apikey" && data.name) { existing = (db .prepare( "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'apikey' AND name = ?" ) .get(data.provider, data.name) as JsonRecord | undefined) || null; } if (existing) { const existingId = toStringOrNull(existing.id); if (!existingId) return null; const merged: JsonRecord = { ...toRecord(rowToCamel(existing)), ...data, updatedAt: now }; merged.providerSpecificData = normalizeProviderSpecificData( toStringOrNull(merged.provider), merged.providerSpecificData ); _updateConnectionRow(db, existingId, merged); backupDbFile("pre-write"); return withNullableMaxConcurrent(cleanNulls(merged), merged); } // Generate name: prefer explicit name, then email, then a stable short-ID label. // Avoid sequential "Account N" — it reassigns when accounts are deleted/reordered. let connectionName = data.name || null; if (!connectionName && data.authType === "oauth") { if (data.email) { connectionName = data.email as string; } else if (data.displayName) { connectionName = data.displayName as string; } // Otherwise leave null — UI will fall back to getAccountDisplayName() → "Account #" } // Auto-increment priority let connectionPriority = data.priority; if (!connectionPriority) { const max = db .prepare("SELECT MAX(priority) as maxP FROM provider_connections WHERE provider = ?") .get(data.provider) as JsonRecord | undefined; const maxPriority = toNumberOrZero(toRecord(max).maxP); connectionPriority = maxPriority + 1; } const connection: Record = { id: uuidv4(), provider: data.provider, authType: data.authType || "oauth", name: connectionName, priority: connectionPriority, isActive: data.isActive !== undefined ? data.isActive : true, createdAt: now, updatedAt: now, }; // Optional fields const optionalFields = [ "displayName", "email", "globalPriority", "defaultModel", "accessToken", "refreshToken", "expiresAt", "tokenType", "scope", "idToken", "projectId", "apiKey", "testStatus", "lastTested", "lastError", "lastErrorAt", "lastErrorType", "lastErrorSource", "rateLimitedUntil", "expiresIn", "errorCode", "consecutiveUseCount", "rateLimitProtection", "group", "maxConcurrent", ]; for (const field of optionalFields) { if (data[field] !== undefined && data[field] !== null) { connection[field] = data[field]; } } if (normalizedProviderSpecificData && Object.keys(normalizedProviderSpecificData).length > 0) { connection.providerSpecificData = normalizedProviderSpecificData; } _insertConnectionRow(db, encryptConnectionFields({ ...connection })); const providerId = toStringOrNull(data.provider); if (providerId) { _reorderConnections(db, providerId); } backupDbFile("pre-write"); invalidateDbCache("connections"); // Bust connections read cache return withNullableMaxConcurrent(cleanNulls(connection), connection); } function _insertConnectionRow(db: DbLike, conn: JsonRecord) { db.prepare( ` INSERT INTO provider_connections ( id, provider, auth_type, name, email, priority, is_active, access_token, refresh_token, expires_at, token_expires_at, scope, project_id, test_status, error_code, last_error, last_error_at, last_error_type, last_error_source, backoff_level, rate_limited_until, health_check_interval, last_health_check_at, last_tested, api_key, id_token, provider_specific_data, expires_in, display_name, global_priority, default_model, token_type, consecutive_use_count, rate_limit_protection, last_used_at, "group", max_concurrent, created_at, updated_at ) VALUES ( @id, @provider, @authType, @name, @email, @priority, @isActive, @accessToken, @refreshToken, @expiresAt, @tokenExpiresAt, @scope, @projectId, @testStatus, @errorCode, @lastError, @lastErrorAt, @lastErrorType, @lastErrorSource, @backoffLevel, @rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt, @lastTested, @apiKey, @idToken, @providerSpecificData, @expiresIn, @displayName, @globalPriority, @defaultModel, @tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @group, @maxConcurrent, @createdAt, @updatedAt ) ` ).run({ id: conn.id, provider: conn.provider, authType: conn.authType || null, name: conn.name || null, email: conn.email || null, priority: conn.priority || 0, isActive: conn.isActive === false ? 0 : 1, accessToken: conn.accessToken || null, refreshToken: conn.refreshToken || null, expiresAt: conn.expiresAt || null, tokenExpiresAt: conn.tokenExpiresAt || null, scope: conn.scope || null, projectId: conn.projectId || null, testStatus: conn.testStatus || null, errorCode: conn.errorCode || null, lastError: conn.lastError || null, lastErrorAt: conn.lastErrorAt || null, lastErrorType: conn.lastErrorType || null, lastErrorSource: conn.lastErrorSource || null, backoffLevel: conn.backoffLevel || 0, rateLimitedUntil: conn.rateLimitedUntil || null, healthCheckInterval: conn.healthCheckInterval || null, lastHealthCheckAt: conn.lastHealthCheckAt || null, lastTested: conn.lastTested || null, apiKey: conn.apiKey || null, idToken: conn.idToken || null, providerSpecificData: conn.providerSpecificData ? JSON.stringify(conn.providerSpecificData) : null, expiresIn: conn.expiresIn || null, displayName: conn.displayName || null, globalPriority: conn.globalPriority || null, defaultModel: conn.defaultModel || null, tokenType: conn.tokenType || null, consecutiveUseCount: conn.consecutiveUseCount || 0, rateLimitProtection: conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0, lastUsedAt: conn.lastUsedAt || null, group: conn.group || null, maxConcurrent: conn.maxConcurrent ?? null, createdAt: conn.createdAt, updatedAt: conn.updatedAt, }); } function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) { const now = data.updatedAt || new Date().toISOString(); db.prepare( ` UPDATE provider_connections SET provider = @provider, auth_type = @authType, name = @name, email = @email, priority = @priority, is_active = @isActive, access_token = @accessToken, refresh_token = @refreshToken, expires_at = @expiresAt, token_expires_at = @tokenExpiresAt, scope = @scope, project_id = @projectId, test_status = @testStatus, error_code = @errorCode, last_error = @lastError, last_error_at = @lastErrorAt, last_error_type = @lastErrorType, last_error_source = @lastErrorSource, backoff_level = @backoffLevel, rate_limited_until = @rateLimitedUntil, health_check_interval = @healthCheckInterval, last_health_check_at = @lastHealthCheckAt, last_tested = @lastTested, api_key = @apiKey, id_token = @idToken, provider_specific_data = @providerSpecificData, expires_in = @expiresIn, display_name = @displayName, global_priority = @globalPriority, default_model = @defaultModel, token_type = @tokenType, consecutive_use_count = @consecutiveUseCount, rate_limit_protection = @rateLimitProtection, last_used_at = @lastUsedAt, "group" = @group, max_concurrent = @maxConcurrent, updated_at = @updatedAt WHERE id = @id ` ).run({ id, provider: data.provider, authType: data.authType || null, name: data.name || null, email: data.email || null, priority: data.priority || 0, isActive: data.isActive === false ? 0 : 1, accessToken: data.accessToken || null, refreshToken: data.refreshToken || null, expiresAt: data.expiresAt || null, tokenExpiresAt: data.tokenExpiresAt || null, scope: data.scope || null, projectId: data.projectId || null, testStatus: data.testStatus || null, errorCode: data.errorCode || null, lastError: data.lastError || null, lastErrorAt: data.lastErrorAt || null, lastErrorType: data.lastErrorType || null, lastErrorSource: data.lastErrorSource || null, backoffLevel: data.backoffLevel || 0, rateLimitedUntil: data.rateLimitedUntil || null, healthCheckInterval: data.healthCheckInterval || null, lastHealthCheckAt: data.lastHealthCheckAt || null, lastTested: data.lastTested || null, apiKey: data.apiKey || null, idToken: data.idToken || null, providerSpecificData: data.providerSpecificData ? JSON.stringify(data.providerSpecificData) : null, expiresIn: data.expiresIn || null, displayName: data.displayName || null, globalPriority: data.globalPriority || null, defaultModel: data.defaultModel || null, tokenType: data.tokenType || null, consecutiveUseCount: data.consecutiveUseCount || 0, rateLimitProtection: data.rateLimitProtection === true || data.rateLimitProtection === 1 ? 1 : 0, lastUsedAt: data.lastUsedAt || null, group: data.group || null, maxConcurrent: data.maxConcurrent ?? null, updatedAt: now, }); } export async function updateProviderConnection(id: string, data: JsonRecord) { const db = getDbInstance() as unknown as DbLike; const existing = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id); if (!existing) return null; const merged: JsonRecord = { ...toRecord(rowToCamel(existing)), ...data, updatedAt: new Date().toISOString(), }; merged.providerSpecificData = normalizeProviderSpecificData( toStringOrNull(merged.provider), merged.providerSpecificData ); _updateConnectionRow(db, id, encryptConnectionFields({ ...merged })); backupDbFile("pre-write"); invalidateDbCache("connections"); // Bust connections read cache if (data.priority !== undefined) { const existingRecord = toRecord(existing); const providerId = typeof existingRecord.provider === "string" ? existingRecord.provider : String(existingRecord.provider || ""); _reorderConnections(db, providerId); } return withNullableMaxConcurrent(cleanNulls(merged), merged); } 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); if (!existing) return false; db.prepare("DELETE FROM quota_snapshots WHERE connection_id = ?").run(id); db.prepare("DELETE FROM provider_connections WHERE id = ?").run(id); const existingRecord = toRecord(existing); const providerId = typeof existingRecord.provider === "string" ? existingRecord.provider : String(existingRecord.provider || ""); _reorderConnections(db, providerId); backupDbFile("pre-write"); invalidateDbCache("connections"); // Bust connections read cache return true; } export async function deleteProviderConnectionsByProvider(providerId: string) { const db = getDbInstance() as unknown as DbLike; const connectionIds = db .prepare("SELECT id FROM provider_connections WHERE provider = ?") .all(providerId) .map((row) => { const record = toRecord(row); return typeof record.id === "string" ? record.id : null; }) .filter((id): id is string => id !== null); if (connectionIds.length > 0) { const deleteSnapshots = db.prepare("DELETE FROM quota_snapshots WHERE connection_id = ?"); for (const connectionId of connectionIds) { deleteSnapshots.run(connectionId); } } const result = db.prepare("DELETE FROM provider_connections WHERE provider = ?").run(providerId); backupDbFile("pre-write"); return result.changes; } export async function reorderProviderConnections(providerId: string) { const db = getDbInstance() as unknown as DbLike; _reorderConnections(db, providerId); } function _reorderConnections(db: DbLike, providerId: string) { const rows = db .prepare( "SELECT id, priority, updated_at FROM provider_connections WHERE provider = ? ORDER BY priority ASC, updated_at DESC" ) .all(providerId); const update = db.prepare("UPDATE provider_connections SET priority = ? WHERE id = ?"); rows.forEach((row, index) => { const current = toRecord(row); update.run(index + 1, current.id); }); } export async function cleanupProviderConnections() { return 0; } export async function getDistinctGroups(): Promise { const db = getDbInstance() as unknown as DbLike; const rows = db .prepare( 'SELECT DISTINCT "group" FROM provider_connections WHERE "group" IS NOT NULL ORDER BY "group"' ) .all() as Array<{ group?: string }>; return rows.map((r) => String(r.group ?? "")).filter(Boolean); } // ──────────────── Provider Nodes ──────────────── export async function getProviderNodes(filter: JsonRecord = {}) { const db = getDbInstance() as unknown as DbLike; let sql = "SELECT * FROM provider_nodes"; const params: Record = {}; if (filter.type) { sql += " WHERE type = @type"; params.type = filter.type; } return db.prepare(sql).all(params).map(rowToCamel); } export async function getProviderNodeById(id: string) { const db = getDbInstance() as unknown as DbLike; const row = db.prepare("SELECT * FROM provider_nodes WHERE id = ?").get(id); return row ? rowToCamel(row) : null; } export async function createProviderNode(data: JsonRecord) { const db = getDbInstance() as unknown as DbLike; const now = new Date().toISOString(); const node = { id: data.id || uuidv4(), type: data.type, name: data.name, prefix: data.prefix || null, apiType: data.apiType || null, baseUrl: data.baseUrl || null, chatPath: data.chatPath || null, modelsPath: data.modelsPath || null, createdAt: now, updatedAt: now, }; db.prepare( ` INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, chat_path, models_path, created_at, updated_at) VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @chatPath, @modelsPath, @createdAt, @updatedAt) ` ).run(node); backupDbFile("pre-write"); return node; } export async function updateProviderNode(id: string, data: JsonRecord) { const db = getDbInstance() as unknown as DbLike; const existing = db.prepare("SELECT * FROM provider_nodes WHERE id = ?").get(id); if (!existing) return null; const merged: JsonRecord = { ...toRecord(rowToCamel(existing)), ...data, updatedAt: new Date().toISOString(), }; db.prepare( ` UPDATE provider_nodes SET type = @type, name = @name, prefix = @prefix, api_type = @apiType, base_url = @baseUrl, chat_path = @chatPath, models_path = @modelsPath, updated_at = @updatedAt WHERE id = @id ` ).run({ id, type: merged["type"], name: merged["name"], prefix: merged["prefix"] || null, apiType: merged["apiType"] || null, baseUrl: merged["baseUrl"] || null, chatPath: merged["chatPath"] || null, modelsPath: merged["modelsPath"] || null, updatedAt: merged["updatedAt"], }); backupDbFile("pre-write"); return merged; } export async function deleteProviderNode(id: string) { const db = getDbInstance() as unknown as DbLike; const existing = db.prepare("SELECT * FROM provider_nodes WHERE id = ?").get(id); if (!existing) return null; db.prepare("DELETE FROM provider_nodes WHERE id = ?").run(id); backupDbFile("pre-write"); return rowToCamel(existing); } // ──────────────── T05: Rate-Limit DB Persistence ────────────────────────── // Allows rate-limit state to survive token refresh without being accidentally // cleared. DB column rate_limited_until already exists in schema. // Ref: sub2api PR #1218 (fix(openai): prevent rescheduling rate-limited accounts) /** * T05: Persist when a connection is rate-limited, directly in DB. * This survives token refresh — OAuth flows must NOT override this field. * * @param connectionId - The provider_connections.id * @param until - Epoch ms when the rate limit expires (null to clear) */ export function setConnectionRateLimitUntil(connectionId: string, until: number | null): void { const db = getDbInstance() as unknown as DbLike; db.prepare( "UPDATE provider_connections SET rate_limited_until = ?, updated_at = ? WHERE id = ?" ).run(until, new Date().toISOString(), connectionId); invalidateDbCache("connections"); } /** * T05: Check if a connection is currently rate-limited (DB-backed). * Use this before account selection to skip transiently rate-limited accounts. * * @returns true if rate_limited_until is set and in the future */ export function isConnectionRateLimited(connectionId: string): boolean { const db = getDbInstance() as unknown as DbLike; const row = db .prepare("SELECT rate_limited_until FROM provider_connections WHERE id = ?") .get(connectionId) as { rate_limited_until?: number | null } | undefined; if (!row?.rate_limited_until) return false; return Date.now() < row.rate_limited_until; } /** * T05: Get all connections for a provider that are currently rate-limited. * Returns an array of { id, rateLimitedUntil } for dashboard display. */ export function getRateLimitedConnections( provider: string ): Array<{ id: string; rateLimitedUntil: number }> { const db = getDbInstance() as unknown as DbLike; const now = Date.now(); const rows = db .prepare( "SELECT id, rate_limited_until FROM provider_connections WHERE provider = ? AND rate_limited_until > ?" ) .all(provider, now) as Array<{ id: string; rate_limited_until: number }>; return rows.map((r) => ({ id: r.id, rateLimitedUntil: r.rate_limited_until })); } // ──────────────── T13: Stale Quota Display Fix ───────────────────────────── // Codex/Claude quotas display stale cumulative usage after the window resets. // By comparing resetAt timestamp to now(), we can show 0 when window has passed. // Ref: sub2api PR #1171 (fix: quota display shows stale cumulative usage after reset) /** * T13: Get effective quota usage, zeroing it out if the window has already reset. * * @param used - Stored usage value (tokens used in the window) * @param resetAt - ISO-8601 string or epoch ms when the window resets, or null * @returns Effective usage: 0 if window expired, original value otherwise */ export function getEffectiveQuotaUsage( used: number, resetAt: string | number | null | undefined ): number { if (!resetAt) return used; const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime(); if (isNaN(resetTime)) return used; // Window has passed — display should show 0 (pending next snapshot) if (Date.now() >= resetTime) return 0; return used; } /** * T13: Format a reset countdown as a human-readable string: "2h 35m" or "4m 30s". * Returns null if resetAt is in the past or not set. */ export function formatResetCountdown(resetAt: string | number | null | undefined): string | null { if (!resetAt) return null; const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime(); if (isNaN(resetTime)) return null; const diffMs = resetTime - Date.now(); if (diffMs <= 0) return null; const totalSeconds = Math.floor(diffMs / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) return `${hours}h ${minutes}m`; if (minutes > 0) return `${minutes}m ${seconds}s`; return `${seconds}s`; }