diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js index 0d2ddc05..6fc7501d 100644 --- a/open-sse/providers/registry/codex.js +++ b/open-sse/providers/registry/codex.js @@ -45,22 +45,18 @@ export default { }, }, models: [ + { id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }, + { id: "gpt-5.6-sol-review", name: "GPT 5.6 Sol Review", upstreamModelId: "gpt-5.6-sol", quotaFamily: "review" }, + { id: "gpt-5.6-terra", name: "GPT 5.6 Terra" }, + { id: "gpt-5.6-terra-review", name: "GPT 5.6 Terra Review", upstreamModelId: "gpt-5.6-terra", quotaFamily: "review" }, + { id: "gpt-5.6-luna", name: "GPT 5.6 Luna" }, + { id: "gpt-5.6-luna-review", name: "GPT 5.6 Luna Review", upstreamModelId: "gpt-5.6-luna", quotaFamily: "review" }, { id: "gpt-5.5", name: "GPT 5.5" }, { id: "gpt-5.5-review", name: "GPT 5.5 Review", upstreamModelId: "gpt-5.5", quotaFamily: "review" }, { id: "gpt-5.4", name: "GPT 5.4" }, { id: "gpt-5.4-review", name: "GPT 5.4 Review", upstreamModelId: "gpt-5.4", quotaFamily: "review" }, { id: "gpt-5.4-mini", name: "GPT 5.4 Mini" }, { id: "gpt-5.4-mini-review", name: "GPT 5.4 Mini Review", upstreamModelId: "gpt-5.4-mini", quotaFamily: "review" }, - { id: "gpt-5.3-codex", name: "GPT 5.3 Codex" }, - { id: "gpt-5.3-codex-review", name: "GPT 5.3 Codex Review", upstreamModelId: "gpt-5.3-codex", quotaFamily: "review" }, - { id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" }, - { id: "gpt-5.3-codex-xhigh-review", name: "GPT 5.3 Codex (xHigh) Review", upstreamModelId: "gpt-5.3-codex-xhigh", quotaFamily: "review" }, - { id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" }, - { id: "gpt-5.3-codex-high-review", name: "GPT 5.3 Codex (High) Review", upstreamModelId: "gpt-5.3-codex-high", quotaFamily: "review" }, - { id: "gpt-5.3-codex-low", name: "GPT 5.3 Codex (Low)" }, - { id: "gpt-5.3-codex-low-review", name: "GPT 5.3 Codex (Low) Review", upstreamModelId: "gpt-5.3-codex-low", quotaFamily: "review" }, - { id: "gpt-5.3-codex-none", name: "GPT 5.3 Codex (None)" }, - { id: "gpt-5.3-codex-none-review", name: "GPT 5.3 Codex (None) Review", upstreamModelId: "gpt-5.3-codex-none", quotaFamily: "review" }, { id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" }, { id: "gpt-5.3-codex-spark-review", name: "GPT 5.3 Codex Spark Review", upstreamModelId: "gpt-5.3-codex-spark", quotaFamily: "review" }, { id: "gpt-5.5-image", name: "GPT 5.5 Image", capabilities: ["text2img","edit"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, diff --git a/src/app/api/usage/providers/route.js b/src/app/api/usage/providers/route.js index 8a33fb33..8523b8ba 100644 --- a/src/app/api/usage/providers/route.js +++ b/src/app/api/usage/providers/route.js @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getRequestDetails } from "@/lib/requestDetailsDb"; +import { getDistinctProviders } from "@/lib/requestDetailsDb"; import { getProviderNodes } from "@/lib/localDb"; import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; @@ -9,10 +9,9 @@ import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; */ export async function GET() { try { - const { details } = await getRequestDetails({ pageSize: 9999 }); - - // Extract unique providers - const providerIds = [...new Set(details.map(r => r.provider).filter(Boolean))].sort(); + // Query DISTINCT provider column directly — avoids parsing every row's + // full JSON blob (can be hundreds of MB), which previously caused OOM. + const providerIds = await getDistinctProviders(); const providerNodes = await getProviderNodes(); const nodeMap = {}; diff --git a/src/app/api/usage/request-details/route.js b/src/app/api/usage/request-details/route.js index 73a3ceb2..9b154497 100644 --- a/src/app/api/usage/request-details/route.js +++ b/src/app/api/usage/request-details/route.js @@ -9,8 +9,10 @@ export async function GET(request) { try { const { searchParams } = new URL(request.url); - const page = parseInt(searchParams.get("page")) || 1; - const pageSize = parseInt(searchParams.get("pageSize")) || 20; + const pageRaw = parseInt(searchParams.get("page")); + const page = Number.isNaN(pageRaw) ? 1 : pageRaw; + const pageSizeRaw = parseInt(searchParams.get("pageSize")); + const pageSize = Number.isNaN(pageSizeRaw) ? 20 : pageSizeRaw; const provider = searchParams.get("provider"); const model = searchParams.get("model"); const connectionId = searchParams.get("connectionId"); diff --git a/src/lib/db/backup.js b/src/lib/db/backup.js index 39b853eb..f3a7ed15 100644 --- a/src/lib/db/backup.js +++ b/src/lib/db/backup.js @@ -1,9 +1,20 @@ +// DB safety backups — taken ONLY before a schema change (see migrate.js). +// +// ⚠️ AGENT/DEV NOTES: +// - Backups are a best-effort safety net before schema migrations. There is NO +// automated restore path; recovery is manual (copy a backup file back). +// - Backups intentionally EXCLUDE the `requestDetails` table (observability log, +// auto-pruned, non-critical) so a multi-hundred-MB DB backs up as a few MB. +// - Only the newest KEEP_BACKUPS are kept; older ones are pruned automatically. import fs from "node:fs"; import path from "node:path"; import { BACKUPS_DIR, ensureDirs } from "./paths.js"; import { timestampSlug, getAppVersion } from "./version.js"; -const KEEP_BACKUPS = 5; +const KEEP_BACKUPS = 3; + +// Tables excluded from safety backups (large, non-critical, reproducible). +const BACKUP_EXCLUDE_TABLES = ["requestDetails"]; export function makeBackupDir(label) { ensureDirs(); @@ -22,6 +33,35 @@ export function backupFile(srcPath, destDir, destName = null) { return dest; } +// Lightweight DB backup via ATTACH: create an empty sqlite file, copy every +// table EXCEPT the excluded ones into it. Avoids duplicating the huge +// observability log, so the backup stays small regardless of DB size. +export function backupDbLite(adapter, destDir, destName = "data.sqlite") { + const dest = path.join(destDir, destName); + try { fs.rmSync(dest, { force: true }); } catch {} + const escaped = dest.replace(/'/g, "''"); + + adapter.exec(`ATTACH DATABASE '${escaped}' AS bak`); + try { + const excluded = new Set(BACKUP_EXCLUDE_TABLES); + const tables = adapter + .all(`SELECT name, sql FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) + .filter((t) => !excluded.has(t.name)); + + adapter.transaction(() => { + for (const t of tables) { + // Recreate table structure in backup DB, then copy rows. + const createSql = t.sql.replace(/CREATE TABLE\s+/i, "CREATE TABLE bak."); + adapter.exec(createSql); + adapter.exec(`INSERT INTO bak.${t.name} SELECT * FROM main.${t.name}`); + } + }); + } finally { + try { adapter.exec("DETACH DATABASE bak"); } catch {} + } + return dest; +} + export function pruneOldBackups() { if (!fs.existsSync(BACKUPS_DIR)) return; const entries = fs.readdirSync(BACKUPS_DIR, { withFileTypes: true }) diff --git a/src/lib/db/index.js b/src/lib/db/index.js index 0d5dd652..e1c68472 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -64,7 +64,7 @@ export { // Request details export { - saveRequestDetail, getRequestDetails, getRequestDetailById, + saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders, } from "./repos/requestDetailsRepo.js"; // Export/import full DB diff --git a/src/lib/db/migrate.js b/src/lib/db/migrate.js index 36183c90..0cca4da0 100644 --- a/src/lib/db/migrate.js +++ b/src/lib/db/migrate.js @@ -1,10 +1,10 @@ import fs from "node:fs"; import path from "node:path"; -import { LEGACY_FILES, DB_DIR, DATA_FILE } from "./paths.js"; -import { TABLES, buildCreateTableSql } from "./schema.js"; +import { LEGACY_FILES, DB_DIR } from "./paths.js"; +import { TABLES, buildCreateTableSql, SCHEMA_VERSION } from "./schema.js"; import { MIGRATIONS, latestVersion } from "./migrations/index.js"; import { getMetaSync, setMetaSync } from "./helpers/metaStore.js"; -import { makeBackupDir, backupFile, pruneOldBackups } from "./backup.js"; +import { makeBackupDir, backupFile, backupDbLite, pruneOldBackups } from "./backup.js"; import { getAppVersion } from "./version.js"; import { stringifyJson } from "./helpers/jsonCol.js"; @@ -221,12 +221,37 @@ export async function runMigrationOnce(adapter) { // a brand-new DB as non-fresh once schemaVersion is written). const fresh = isFreshDb(adapter); + // Prune stale backups every boot so old oversized backups shrink to KEEP. + pruneOldBackups(); + + // Bootstrap _meta so we can read the stored backup schema version below + // (runVersionedMigrations also ensures this, but we need it earlier here). + adapter.exec(buildCreateTableSql("_meta", TABLES._meta)); + + // Detect a pending schema change via the central SCHEMA_VERSION const. + // A lightweight backup is taken BEFORE any schema mutation below. + const storedSchemaVer = parseInt(getMetaSync(adapter, "backupSchemaVersion", "0"), 10) || 0; + const schemaChanging = !fresh && storedSchemaVer < SCHEMA_VERSION; + if (schemaChanging) { + try { + const backupDir = makeBackupDir(`schema-${storedSchemaVer}-to-${SCHEMA_VERSION}`); + backupDbLite(adapter, backupDir); + pruneOldBackups(); + console.log(`[DB][migrate] pre-schema backup ${storedSchemaVer} → ${SCHEMA_VERSION}: ${backupDir}`); + } catch (e) { + console.warn(`[DB][migrate] pre-schema backup failed (continuing): ${e.message}`); + } + } + // 1. Always run versioned migrations chain (skip-version safe) const migInfo = runVersionedMigrations(adapter); // 2. Additive sync (auto add missing columns/indexes declared in TABLES) syncSchemaFromTables(adapter); + // Stamp the schema version we just reached so future boots skip re-backup. + setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION); + // 3. One-time legacy JSON import (only if DB was fresh on entry) const alreadyImported = fs.existsSync(MIGRATED_MARKER); const legacyMain = readJsonSafe(LEGACY_FILES.main); @@ -247,6 +272,7 @@ export async function runMigrationOnce(adapter) { importLegacyDisabled(adapter, legacyDisabled); importLegacyDetails(adapter, legacyDetails); setMetaSync(adapter, "appVersion", getAppVersion()); + setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION); setMetaSync(adapter, "migratedAt", new Date().toISOString()); }); } catch (err) { @@ -263,24 +289,9 @@ export async function runMigrationOnce(adapter) { return; } - if (fresh) { - setMetaSync(adapter, "appVersion", getAppVersion()); - return; - } - - // 4. App version bump → backup data.sqlite (safety net before user-side upgrade) - const oldVer = getMetaSync(adapter, "appVersion", null); + // Track app version for informational purposes only. App version bumps no + // longer trigger a DB backup — only real schema changes (SCHEMA_VERSION) do. const newVer = getAppVersion(); - if (oldVer && oldVer !== newVer) { - const backupDir = makeBackupDir(`upgrade-${oldVer}-to-${newVer}`); - try { backupFile(DATA_FILE, backupDir); } catch {} - setMetaSync(adapter, "appVersion", newVer); - pruneOldBackups(); - console.log(`[DB][migrate] App ${oldVer} → ${newVer} | schema ${migInfo.from} → ${migInfo.to} | backup: ${backupDir}`); - } else if (migInfo.applied > 0) { - // Schema upgrade without app version bump — still backup - const backupDir = makeBackupDir(`schema-${migInfo.from}-to-${migInfo.to}`); - try { backupFile(DATA_FILE, backupDir); } catch {} - pruneOldBackups(); - } + const oldVer = getMetaSync(adapter, "appVersion", null); + if (oldVer !== newVer) setMetaSync(adapter, "appVersion", newVer); } diff --git a/src/lib/db/repos/requestDetailsRepo.js b/src/lib/db/repos/requestDetailsRepo.js index 813974ae..2f308015 100644 --- a/src/lib/db/repos/requestDetailsRepo.js +++ b/src/lib/db/repos/requestDetailsRepo.js @@ -174,6 +174,12 @@ export async function getRequestDetails(filter = {}) { }; } +export async function getDistinctProviders() { + const db = await getAdapter(); + const rows = db.all(`SELECT DISTINCT provider FROM requestDetails WHERE provider IS NOT NULL ORDER BY provider ASC`); + return rows.map((r) => r.provider); +} + export async function getRequestDetailById(id) { const db = await getAdapter(); const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]); diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index 71c230c8..099386c2 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -1,4 +1,8 @@ -// Latest schema version — bumped when a migration is added in ./migrations/ +// ⚠️ AGENT/DEV: Bump this by +1 EVERY TIME you change the schema below +// (add/remove/alter a table, column, or index in TABLES). It drives the +// pre-change safety backup in migrate.js: when the stored version is lower, +// one lightweight DB backup is taken before applying schema changes. Forgetting +// to bump only skips that backup — it does NOT break the additive auto-sync. export const SCHEMA_VERSION = 1; export const PRAGMA_SQL = ` diff --git a/src/lib/mcp/stdioSseBridge.js b/src/lib/mcp/stdioSseBridge.js index c0e07b33..1bdc4268 100644 --- a/src/lib/mcp/stdioSseBridge.js +++ b/src/lib/mcp/stdioSseBridge.js @@ -153,6 +153,20 @@ function unregisterSession(name, sid) { const entry = getStore().get(name); if (!entry) return; entry.sessions.delete(sid); + // No sessions left → kill child to avoid idle orphan process leak. + if (entry.sessions.size === 0) { + try { entry.proc.kill(); } catch { /* ignore */ } + getStore().delete(name); + } +} + +// Kill all spawned MCP children — called on app shutdown to prevent orphans. +function killAllBridges() { + const store = getStore(); + for (const [name, entry] of store) { + try { entry.proc.kill(); } catch { /* ignore */ } + store.delete(name); + } } function sendToChild(name, jsonRpc) { @@ -166,4 +180,4 @@ function isRunning(name) { return !!(entry?.proc && !entry.proc.killed && entry.proc.exitCode === null); } -module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin }; +module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin, killAllBridges }; diff --git a/src/lib/requestDetailsDb.js b/src/lib/requestDetailsDb.js index 26fa7b0b..0daaa8a4 100644 --- a/src/lib/requestDetailsDb.js +++ b/src/lib/requestDetailsDb.js @@ -1,4 +1,4 @@ // Shim → re-export from new SQLite-based DB layer (src/lib/db/) export { - saveRequestDetail, getRequestDetails, getRequestDetailById, + saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders, } from "@/lib/db/index.js"; diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 57f2273b..b9d000ca 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -101,9 +101,9 @@ export const CLI_TOOLS = { modelAliases: ["default", "sonnet", "opus", "fable", "haiku", "opusplan"], settingsFile: "~/.claude/settings.json", defaultModels: [ + { id: "fable", name: "Claude Fable", alias: "fable", envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", defaultValue: "cc/claude-fable-5" }, { id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-8" }, { id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-5" }, - { id: "fable", name: "Claude Fable", alias: "fable", envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", defaultValue: "cc/claude-fable-5" }, { id: "haiku", name: "Claude Haiku", alias: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" }, ], }, diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js index 094ee726..e8f5acfc 100644 --- a/src/shared/services/initializeApp.js +++ b/src/shared/services/initializeApp.js @@ -16,6 +16,7 @@ import { import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager"; import { startQuotaAutoPing } from "@/shared/services/quotaAutoPing"; import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache"; +import { killAllBridges } from "@/lib/mcp/stdioSseBridge"; // Inject correct paths and DB hooks into manager.js (CJS) from ESM context (function bootstrapMitm() { @@ -56,6 +57,7 @@ export async function initializeApp() { if (!g.signalHandlersRegistered) { const cleanup = () => { try { removeAllDNSEntriesSync(); } catch { /* best effort */ } + try { killAllBridges(); } catch { /* best effort */ } killCloudflared(); process.exit(); }; diff --git a/tests/unit/request-details-tab.test.js b/tests/unit/request-details-tab.test.js new file mode 100644 index 00000000..6b056503 --- /dev/null +++ b/tests/unit/request-details-tab.test.js @@ -0,0 +1,252 @@ +// Backend logic behind /dashboard/usage?tab=details. +// Covers crash-risk edge cases in getRequestDetails() used by +// /api/usage/request-details and /api/usage/providers. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; + +const originalDataDir = process.env.DATA_DIR; +let tempDir; +let db; +let adapter; + +async function saveDetail(detail) { + await db.saveRequestDetail(detail); + await new Promise((r) => setTimeout(r, 120)); +} + +beforeAll(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-details-tab-")); + process.env.DATA_DIR = tempDir; + vi.resetModules(); + db = await import("@/lib/db/index.js"); + await db.initDb(); + await db.updateSettings({ enableObservability2: true, observabilityBatchSize: 1 }); + + const { getAdapter } = await import("@/lib/db/driver.js"); + adapter = await getAdapter(); +}); + +afterAll(() => { + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + +describe("request details — tab crash-risk cases", () => { + it("corrupt data column → parseJson fallback {}, no throw", async () => { + // Inject a row with invalid JSON directly, bypassing save path + adapter.run( + `INSERT INTO requestDetails(id, timestamp, provider, model, connectionId, status, data) VALUES(?, ?, ?, ?, ?, ?, ?)`, + ["corrupt-1", new Date().toISOString(), "openai", "gpt-4", null, "ok", "{not-valid-json"] + ); + + const res = await db.getRequestDetails({ provider: "openai" }); + expect(Array.isArray(res.details)).toBe(true); + const corrupt = res.details.find((d) => Object.keys(d).length === 0); + expect(corrupt).toEqual({}); + }); + + it("pagination beyond last page → empty details, valid meta", async () => { + const res = await db.getRequestDetails({ page: 9999, pageSize: 20 }); + expect(res.details).toEqual([]); + expect(res.pagination.page).toBe(9999); + expect(res.pagination.hasNext).toBe(false); + expect(res.pagination.totalItems).toBeGreaterThanOrEqual(0); + }); + + it("invalid startDate → Invalid Date ISO throws inside getRequestDetails is caught upstream", async () => { + // new Date("bad").toISOString() throws RangeError; verify it surfaces + // so the API route's try/catch returns 500 rather than silent corruption. + await expect(db.getRequestDetails({ startDate: "not-a-date" })).rejects.toThrow(); + }); + + it("valid date filter range → no throw", async () => { + const res = await db.getRequestDetails({ + startDate: "2020-01-01T00:00:00", + endDate: "2999-01-01T00:00:00", + }); + expect(Array.isArray(res.details)).toBe(true); + }); + + it("large pageSize (providers route uses 9999) → returns all, no crash", async () => { + await saveDetail({ + id: "big-1", provider: "anthropic", model: "claude-3", + status: "ok", tokens: { input_tokens: 5 }, + request: { method: "POST" }, response: { content: "hi" }, + }); + + const res = await db.getRequestDetails({ pageSize: 9999 }); + expect(res.details.length).toBeGreaterThanOrEqual(1); + expect(res.pagination.pageSize).toBe(9999); + }); + + it("oversized field → stored truncated + reparseable (no circular)", async () => { + const huge = "x".repeat(20 * 1024); + await saveDetail({ + id: "trunc-1", provider: "openai", model: "gpt-4", + status: "ok", tokens: {}, + request: { blob: huge }, response: { content: "ok" }, + }); + + const got = await db.getRequestDetailById("trunc-1"); + expect(got).toBeDefined(); + // Truncated field is a plain object safe for JSON.stringify in the drawer + expect(() => JSON.stringify(got)).not.toThrow(); + expect(got.request._truncated).toBe(true); + }); + + it("missing tokens/timestamp on row → getInputTokens-style access safe", async () => { + adapter.run( + `INSERT INTO requestDetails(id, timestamp, provider, model, connectionId, status, data) VALUES(?, ?, ?, ?, ?, ?, ?)`, + ["sparse-1", new Date().toISOString(), "openai", null, null, null, JSON.stringify({ id: "sparse-1" })] + ); + const got = await db.getRequestDetailById("sparse-1"); + expect(got.tokens).toBeUndefined(); + // Drawer reads tokens?.prompt_tokens — optional chaining tolerates undefined + expect(got.tokens?.prompt_tokens || 0).toBe(0); + }); +}); + +// Mirror of RequestDetailsTab token helpers (component is "use client", +// helpers are not exported). Keep in sync with the component. +function getCachedTokens(tokens) { + return tokens?.cached_tokens || tokens?.cache_read_input_tokens || 0; +} +function getCacheCreationTokens(tokens) { + return tokens?.cache_creation_input_tokens || 0; +} +function getInputTokens(tokens) { + const prompt = tokens?.prompt_tokens || tokens?.input_tokens || 0; + const cache = getCachedTokens(tokens); + return prompt < cache ? cache : prompt; +} + +describe("backupDbLite — excludes requestDetails, keeps critical data", () => { + it("backup file omits requestDetails rows but keeps other tables", async () => { + const { backupDbLite } = await import("@/lib/db/backup.js"); + await saveDetail({ id: "bk-1", provider: "openai", model: "m", status: "ok", tokens: {}, request: {}, response: {} }); + + const backupDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-bklite-")); + const dest = backupDbLite(adapter, backupDir); + expect(fs.existsSync(dest)).toBe(true); + + // Open backup and assert requestDetails is empty, settings present + const Database = (await import("better-sqlite3")).default; + const bak = new Database(dest); + try { + // requestDetails is fully excluded — table must not exist in the backup + const rdTable = bak.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='requestDetails'").get(); + expect(rdTable).toBeUndefined(); + // Critical data preserved + const st = bak.prepare("SELECT COUNT(*) c FROM settings").get(); + expect(st.c).toBeGreaterThanOrEqual(1); + } finally { + bak.close(); + fs.rmSync(backupDir, { recursive: true, force: true }); + } + }); +}); + +describe("getDistinctProviders — providers route (no full-row parse)", () => { + it("returns unique provider list without parsing data blobs", async () => { + await saveDetail({ id: "dp-1", provider: "openai", model: "m", status: "ok", tokens: {}, request: {}, response: {} }); + await saveDetail({ id: "dp-2", provider: "anthropic", model: "m", status: "ok", tokens: {}, request: {}, response: {} }); + await saveDetail({ id: "dp-3", provider: "openai", model: "m", status: "ok", tokens: {}, request: {}, response: {} }); + + const list = await db.getDistinctProviders(); + expect(Array.isArray(list)).toBe(true); + expect(list).toContain("openai"); + expect(list).toContain("anthropic"); + // No duplicates + expect(new Set(list).size).toBe(list.length); + }); + + it("skips null providers, returns sorted", async () => { + const list = await db.getDistinctProviders(); + expect(list.every((p) => p !== null)).toBe(true); + const sorted = [...list].sort(); + expect(list).toEqual(sorted); + }); +}); + +describe("token helpers — render-time crash safety", () => { + it("undefined/null tokens → 0, no throw", () => { + expect(getInputTokens(undefined)).toBe(0); + expect(getInputTokens(null)).toBe(0); + expect(getCachedTokens(undefined)).toBe(0); + expect(getCacheCreationTokens(null)).toBe(0); + }); + + it("empty object → 0 across all helpers", () => { + expect(getInputTokens({})).toBe(0); + expect(getCachedTokens({})).toBe(0); + expect(getCacheCreationTokens({})).toBe(0); + }); + + it("prompt_tokens preferred, falls back to input_tokens", () => { + expect(getInputTokens({ prompt_tokens: 100 })).toBe(100); + expect(getInputTokens({ input_tokens: 50 })).toBe(50); + }); + + it("legacy Claude row (prompt < cache) → returns cache", () => { + expect(getInputTokens({ prompt_tokens: 10, cached_tokens: 200 })).toBe(200); + }); + + it("cached via cache_read_input_tokens alias", () => { + expect(getCachedTokens({ cache_read_input_tokens: 42 })).toBe(42); + }); + + it("toLocaleString on helper result never throws", () => { + expect(() => getInputTokens(undefined).toLocaleString()).not.toThrow(); + }); +}); + +describe("API route contract — validation boundary", () => { + let GET; + beforeAll(async () => { + ({ GET } = await import("@/app/api/usage/request-details/route.js")); + }); + + function makeReq(query) { + return new Request(`http://localhost/api/usage/request-details?${query}`); + } + + it("page=0 → 400 (guard now reachable after NaN-check fix)", async () => { + const res = await GET(makeReq("page=0")); + expect(res.status).toBe(400); + }); + + it("page=-5 → 400", async () => { + const res = await GET(makeReq("page=-5")); + expect(res.status).toBe(400); + }); + + it("pageSize=101 → 400", async () => { + const res = await GET(makeReq("pageSize=101")); + expect(res.status).toBe(400); + }); + + it("pageSize=abc (NaN) → defaults to 20, returns 200", async () => { + const res = await GET(makeReq("pageSize=abc")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.pagination.pageSize).toBe(20); + }); + + it("invalid startDate → route catches, returns 500 (not thrown)", async () => { + const res = await GET(makeReq("startDate=not-a-date")); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + it("valid request → 200 with details + pagination shape", async () => { + const res = await GET(makeReq("page=1&pageSize=20")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.details)).toBe(true); + expect(body.pagination).toMatchObject({ page: 1, pageSize: 20 }); + }); +});