diff --git a/CHANGELOG.md b/CHANGELOG.md index 35bfc3243ad7..39222bb699f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - **refactor(db): move `community_servers` auth look-up into `gamification` db module** ([#3500]): extracted raw SQL from two federation route handlers (`/api/gamification/federation/leaderboard`, `/api/gamification/federation/score`) into a new `getConnectedServerByKeyHash(apiKeyHash)` function in `src/lib/db/gamification.ts`. Third slice of #3500 (gamification federation cluster). Behavior unchanged; the two routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (3 assertions seeding a temp SQLite fixture). - **refactor(db): move `skills UPDATE` + `db-backups` SQL into db modules** ([#3500]): fifth slice of #3500. Extracted the dynamic `UPDATE skills SET …` from `src/app/api/skills/[id]/route.ts` into a new `src/lib/db/skills.ts` module (`updateSkill(id, patch)`). The dynamic SET clause is injection-safe: column names are validated against a hard-coded allowlist of known writable columns before being interpolated; unknown keys are silently ignored. Extended `src/lib/db/backup.ts` with three new functions: `exportAllSummaryRows()` (multi-table SELECT for key_value / combos / provider_connections / api_keys, used by exportAll), `getTableNamesFromAdapter()` (sqlite_master introspection via an adapter arg, used by import validation), and `countImportedRows()` (post-import COUNT(*) per table). The backup domain module is the correct home for sqlite_master introspection — it is not "raw SQL in a route" once moved there. `KNOWN_RAW_SQL` drops by 3 (from 8 → 5). Validated with 11 TDD unit tests (`tests/unit/db-backups-skills-3500.test.ts`). +- **refactor(db): move `usage_logs`/`semantic_cache`/`proxy_logs` SQL into db modules** ([#3500]): extracted raw `db.prepare(...)` SQL from three route handlers (`/api/analytics/auto-routing` → `usageLogs.ts`; `/api/cache/entries` → `semanticCache.ts`; `/api/logs/export` → `proxyLogs.ts`) into new `src/lib/db/` domain modules. New exports: `getAutoRoutingTotalCount`, `getAutoRoutingVariantBreakdown`, `getAutoRoutingTopProviders` (usage_logs), `listSemanticCacheEntries`, `deleteSemanticCacheBySignature`, `deleteSemanticCacheByModel` (semantic_cache), and `exportProxyLogsSince` (proxy_logs). Fourth slice of #3500. `KNOWN_RAW_SQL` drops from 8 → 5. Validated with 13 TDD unit tests (`tests/unit/db-logs-cache-3500.test.ts`) seeding temp SQLite fixtures. - **Provider-detail god-component decomposition — Phase 0** ([#3501]): introduced `ProviderDetailPageClient.tsx` and reduced `providers/[id]/page.tsx` to a thin 9-line route wrapper (was 12,882 LOC), following the repo's `*PageClient` convention. Added the first-ever smoke render test for the page (Hard Rule #8) as the safety net every later extraction phase is diffed against. Behavior unchanged; the `check-file-size` ratchet now tracks the extracted client. Foundation for Phases 1–6 (strangler-fig). Thanks @oyi77 for the parallel modularization effort in #3627. - **Provider-detail god-component decomposition — Phase 1a** ([#3501]): extracted the three self-contained auth-import modal clusters (Codex/Claude/Gemini `Import*AuthModal` + `Apply*AuthModal` + their co-located helpers, ~2,160 LOC) into `providers/[id]/components/modals/`. `ProviderDetailPageClient.tsx` drops 12,882 → 10,719 LOC. Behavior unchanged (smoke test green; clusters had clean `{ onClose, onSuccess }` / inline-prop interfaces). Co-authored with @oyi77. diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index d278f236d480..e1ad554d1ccd 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -72,9 +72,6 @@ export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL; // adicione novos aqui sem justificativa — crie/estenda um módulo db/ em vez disso. // (Chaves = caminho relativo POSIX a partir da raiz do repo.) const KNOWN_RAW_SQL = new Set([ - "src/app/api/analytics/auto-routing/route.ts", // SELECT … FROM usage_logs - "src/app/api/cache/entries/route.ts", // semantic_cache COUNT/DELETE inline - "src/app/api/logs/export/route.ts", // SELECT de proxy_logs "src/app/api/oauth/cursor/auto-import/route.ts", // SELECT no itemTable do Cursor (DB externo) "src/app/api/oauth/kiro/auto-import/route.ts", // SELECT no SQLite do Kiro (DB externo) ]); diff --git a/src/app/api/analytics/auto-routing/route.ts b/src/app/api/analytics/auto-routing/route.ts index 9f0b4e5c8434..f4a5874b20a6 100644 --- a/src/app/api/analytics/auto-routing/route.ts +++ b/src/app/api/analytics/auto-routing/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; -import { getDbInstance } from "@/lib/db/core"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { + getAutoRoutingTotalCount, + getAutoRoutingVariantBreakdown, + getAutoRoutingTopProviders, +} from "@/lib/db/usageLogs"; export const dynamic = "force-dynamic"; @@ -12,37 +16,11 @@ export async function GET(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; try { - const db = getDbInstance(); - // Query usage_logs for auto/ prefix requests - const totalRequests = db - .prepare( - ` - SELECT COUNT(*) as count - FROM usage_logs - WHERE model = 'auto' OR model LIKE 'auto/%' - ` - ) - .get() as { count: number }; + const totalRequests = getAutoRoutingTotalCount(); // Variant breakdown - const variantRows = db - .prepare( - ` - SELECT - CASE - WHEN model = 'auto' THEN 'default' - WHEN model LIKE 'auto/%' THEN SUBSTR(model, 6) - ELSE 'other' - END as variant, - COUNT(*) as count - FROM usage_logs - WHERE model = 'auto' OR model LIKE 'auto/%' - GROUP BY variant - ORDER BY count DESC - ` - ) - .all() as Array<{ variant: string; count: number }>; + const variantRows = getAutoRoutingVariantBreakdown(); const variantBreakdown: Record = {}; variantRows.forEach((row) => { @@ -50,18 +28,7 @@ export async function GET(request: Request) { }); // Top providers (from LKGP cache or usage logs) - const topProviders = db - .prepare( - ` - SELECT provider, COUNT(*) as count - FROM usage_logs - WHERE model = 'auto' OR model LIKE 'auto/%' - GROUP BY provider - ORDER BY count DESC - LIMIT 10 - ` - ) - .all() as Array<{ provider: string; count: number }>; + const topProviders = getAutoRoutingTopProviders(); return NextResponse.json({ totalRequests: totalRequests.count, diff --git a/src/app/api/cache/entries/route.ts b/src/app/api/cache/entries/route.ts index 97ff51ca9a5c..48fc5e88feba 100644 --- a/src/app/api/cache/entries/route.ts +++ b/src/app/api/cache/entries/route.ts @@ -1,16 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { getDbInstance } from "@/lib/db/core"; import { isAuthenticated } from "@/shared/utils/apiAuth"; - -interface CacheEntry { - id: string; - signature: string; - model: string; - hit_count: number; - tokens_saved: number; - created_at: string; - expires_at: string; -} +import { + listSemanticCacheEntries, + deleteSemanticCacheBySignature, + deleteSemanticCacheByModel, +} from "@/lib/db/semanticCache"; export async function GET(req: NextRequest) { if (!(await isAuthenticated(req))) { @@ -26,48 +20,15 @@ export async function GET(req: NextRequest) { const sortBy = searchParams.get("sortBy") || "created_at"; const sortOrder = searchParams.get("sortOrder") || "desc"; - const db = getDbInstance(); - const offset = (page - 1) * limit; - - const conditions: string[] = []; - const params: unknown[] = []; - - if (search) { - conditions.push("(signature LIKE ? OR model LIKE ?)"); - params.push(`%${search}%`, `%${search}%`); - } - - if (model) { - conditions.push("model = ?"); - params.push(model); - } - - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - - const validSortColumns = ["created_at", "expires_at", "hit_count", "tokens_saved", "model"]; - const orderBy = validSortColumns.includes(sortBy) ? sortBy : "created_at"; - const order = sortOrder === "asc" ? "ASC" : "DESC"; - - const countRow = db - .prepare(`SELECT COUNT(*) as total FROM semantic_cache ${whereClause}`) - .get(...params) as { total: number }; - - const entries = db - .prepare( - `SELECT id, signature, model, hit_count, tokens_saved, created_at, expires_at - FROM semantic_cache ${whereClause} - ORDER BY ${orderBy} ${order} - LIMIT ? OFFSET ?` - ) - .all(...params, limit, offset) as CacheEntry[]; + const { entries, total } = listSemanticCacheEntries({ page, limit, search, model, sortBy, sortOrder }); return NextResponse.json({ entries, pagination: { page, limit, - total: countRow?.total || 0, - totalPages: Math.ceil((countRow?.total || 0) / limit), + total, + totalPages: Math.ceil(total / limit), }, }); } catch (error) { @@ -85,16 +46,14 @@ export async function DELETE(req: NextRequest) { const signature = searchParams.get("signature"); const model = searchParams.get("model"); - const db = getDbInstance(); - if (signature) { - db.prepare("DELETE FROM semantic_cache WHERE signature = ?").run(signature); - return NextResponse.json({ ok: true, deleted: 1 }); + const { deleted } = deleteSemanticCacheBySignature(signature); + return NextResponse.json({ ok: true, deleted }); } if (model) { - const result = db.prepare("DELETE FROM semantic_cache WHERE model = ?").run(model); - return NextResponse.json({ ok: true, deleted: result.changes }); + const { deleted } = deleteSemanticCacheByModel(model); + return NextResponse.json({ ok: true, deleted }); } return NextResponse.json({ error: "Provide signature or model parameter" }, { status: 400 }); diff --git a/src/app/api/logs/export/route.ts b/src/app/api/logs/export/route.ts index ea10b60ae941..8efe2fd4864b 100644 --- a/src/app/api/logs/export/route.ts +++ b/src/app/api/logs/export/route.ts @@ -1,6 +1,6 @@ -import { getDbInstance } from "@/lib/db/core"; import { exportCallLogsSince } from "@/lib/usage/callLogs"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { exportProxyLogsSince } from "@/lib/db/proxyLogs"; /** * GET /api/logs/export — export logs as JSON @@ -17,7 +17,6 @@ export async function GET(request: Request) { const logType = searchParams.get("type") || "call-logs"; const since = new Date(Date.now() - hours * 3600 * 1000).toISOString(); - const db = getDbInstance(); let rows: unknown[] = []; let tableName = ""; @@ -27,14 +26,11 @@ export async function GET(request: Request) { rows = await exportCallLogsSince(since); } else if (logType === "proxy-logs") { tableName = "proxy_logs"; - // NOTE: raw SELECT * returns the historical `public_ip` column, NOT `clientIp`. + // NOTE: exportProxyLogsSince returns the historical `public_ip` column, NOT `clientIp`. // This intentionally differs from GET /api/usage/proxy-logs which exposes the // value as `clientIp`. Callers of this export endpoint should read `public_ip`. // This inconsistency will be resolved in a future DB migration (#2880). - const stmt = db.prepare( - "SELECT * FROM proxy_logs WHERE timestamp >= @since ORDER BY timestamp DESC" - ); - rows = stmt.all({ since }); + rows = exportProxyLogsSince(since); } const filename = `omniroute-${tableName}-${hours}h-${new Date().toISOString().slice(0, 10)}.json`; diff --git a/src/lib/db/proxyLogs.ts b/src/lib/db/proxyLogs.ts new file mode 100644 index 000000000000..033945d9d22a --- /dev/null +++ b/src/lib/db/proxyLogs.ts @@ -0,0 +1,35 @@ +/** + * db/proxyLogs.ts — Read queries over the `proxy_logs` table. + * Extracted from the /api/logs/export route handler. + * + * Hard Rule #5: routes must not embed raw SQL — these queries live here so the + * /api/logs/export route can delegate. + * + * NOTE: The SELECT * intentionally returns the historical `public_ip` column, + * NOT `clientIp`. This differs from GET /api/usage/proxy-logs which exposes + * the value as `clientIp`. Callers of the export endpoint should read + * `public_ip`. This inconsistency will be resolved in a future DB migration + * (#2880). + * + * Sliced out of #3500 (proxy_logs cluster, slice 4). + */ + +import { getDbInstance } from "./core"; + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +/** + * Returns all proxy_logs rows with timestamp >= `since`, ordered by timestamp + * descending (most recent first). + * + * @param since - ISO-8601 timestamp lower bound, e.g. "2024-01-01T00:00:00.000Z". + */ +export function exportProxyLogsSince(since: string): Record[] { + const db = getDbInstance(); + const stmt = db.prepare( + "SELECT * FROM proxy_logs WHERE timestamp >= @since ORDER BY timestamp DESC" + ); + return stmt.all({ since }) as Record[]; +} diff --git a/src/lib/db/semanticCache.ts b/src/lib/db/semanticCache.ts new file mode 100644 index 000000000000..5b3d38664eea --- /dev/null +++ b/src/lib/db/semanticCache.ts @@ -0,0 +1,117 @@ +/** + * db/semanticCache.ts — CRUD queries over the `semantic_cache` table. + * Extracted from the /api/cache/entries route handler. + * + * Hard Rule #5: routes must not embed raw SQL — these queries live here so the + * /api/cache/entries route can delegate. + * + * Sliced out of #3500 (semantic_cache cluster, slice 4). + */ + +import { getDbInstance } from "./core"; + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +export interface SemanticCacheEntry { + id: string; + signature: string; + model: string; + hit_count: number; + tokens_saved: number; + created_at: string; + expires_at: string; +} + +export interface SemanticCacheListOptions { + page: number; + limit: number; + search: string; + model: string; + sortBy: string; + sortOrder: string; +} + +export interface SemanticCacheListResult { + entries: SemanticCacheEntry[]; + total: number; +} + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +const VALID_SORT_COLUMNS = ["created_at", "expires_at", "hit_count", "tokens_saved", "model"]; + +/** + * Returns a paginated, filtered, sorted list of semantic cache entries. + * All dynamic inputs (sortBy/sortOrder) are validated before use. + */ +export function listSemanticCacheEntries(opts: SemanticCacheListOptions): SemanticCacheListResult { + const db = getDbInstance(); + const { page, limit, search, model, sortBy, sortOrder } = opts; + const offset = (page - 1) * limit; + + const conditions: string[] = []; + const params: unknown[] = []; + + if (search) { + conditions.push("(signature LIKE ? OR model LIKE ?)"); + params.push(`%${search}%`, `%${search}%`); + } + + if (model) { + conditions.push("model = ?"); + params.push(model); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const orderBy = VALID_SORT_COLUMNS.includes(sortBy) ? sortBy : "created_at"; + const order = sortOrder === "asc" ? "ASC" : "DESC"; + + const countRow = db + .prepare(`SELECT COUNT(*) as total FROM semantic_cache ${whereClause}`) + .get(...params) as { total: number }; + + const entries = db + .prepare( + `SELECT id, signature, model, hit_count, tokens_saved, created_at, expires_at + FROM semantic_cache ${whereClause} + ORDER BY ${orderBy} ${order} + LIMIT ? OFFSET ?` + ) + .all(...params, limit, offset) as SemanticCacheEntry[]; + + return { entries, total: countRow?.total || 0 }; +} + +export interface DeleteSemanticCacheBySignatureResult { + deleted: number; +} + +/** + * Deletes the single semantic cache entry matching the given signature. + * Returns `{ deleted: 1 }` on success. + */ +export function deleteSemanticCacheBySignature( + signature: string +): DeleteSemanticCacheBySignatureResult { + const db = getDbInstance(); + db.prepare("DELETE FROM semantic_cache WHERE signature = ?").run(signature); + return { deleted: 1 }; +} + +export interface DeleteSemanticCacheByModelResult { + deleted: number; +} + +/** + * Deletes all semantic cache entries for the given model. + * Returns `{ deleted: N }` where N is the number of rows removed. + */ +export function deleteSemanticCacheByModel(model: string): DeleteSemanticCacheByModelResult { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM semantic_cache WHERE model = ?").run(model); + return { deleted: result.changes }; +} diff --git a/src/lib/db/usageLogs.ts b/src/lib/db/usageLogs.ts new file mode 100644 index 000000000000..7549f9388078 --- /dev/null +++ b/src/lib/db/usageLogs.ts @@ -0,0 +1,94 @@ +/** + * db/usageLogs.ts — Read-only aggregation queries over `usage_logs` + * extracted from the /api/analytics/auto-routing route handler. + * + * Hard Rule #5: routes must not embed raw SQL — these queries live here so the + * /api/analytics/auto-routing route can delegate. + * + * Sliced out of #3500 (usage_logs cluster, slice 4). + */ + +import { getDbInstance } from "./core"; + +// --------------------------------------------------------------------------- +// Auto-routing analytics +// --------------------------------------------------------------------------- + +export interface AutoRoutingTotalResult { + count: number; +} + +/** + * Returns the total number of requests routed through auto/ prefix models. + * Matches model = 'auto' OR model LIKE 'auto/%'. + */ +export function getAutoRoutingTotalCount(): AutoRoutingTotalResult { + const db = getDbInstance(); + const row = db + .prepare( + ` + SELECT COUNT(*) as count + FROM usage_logs + WHERE model = 'auto' OR model LIKE 'auto/%' + ` + ) + .get() as AutoRoutingTotalResult | undefined; + return row ?? { count: 0 }; +} + +export interface AutoRoutingVariantRow { + variant: string; + count: number; +} + +/** + * Returns per-variant request counts for auto/ prefix models. + * Variant is derived from the model name: + * 'auto' → 'default' + * 'auto/X' → 'X' + * other → 'other' (should not occur given the WHERE clause) + */ +export function getAutoRoutingVariantBreakdown(): AutoRoutingVariantRow[] { + const db = getDbInstance(); + return db + .prepare( + ` + SELECT + CASE + WHEN model = 'auto' THEN 'default' + WHEN model LIKE 'auto/%' THEN SUBSTR(model, 6) + ELSE 'other' + END as variant, + COUNT(*) as count + FROM usage_logs + WHERE model = 'auto' OR model LIKE 'auto/%' + GROUP BY variant + ORDER BY count DESC + ` + ) + .all() as AutoRoutingVariantRow[]; +} + +export interface AutoRoutingTopProviderRow { + provider: string; + count: number; +} + +/** + * Returns the top 10 providers used for auto/ prefix model requests. + */ +export function getAutoRoutingTopProviders(): AutoRoutingTopProviderRow[] { + const db = getDbInstance(); + return db + .prepare( + ` + SELECT provider, COUNT(*) as count + FROM usage_logs + WHERE model = 'auto' OR model LIKE 'auto/%' + GROUP BY provider + ORDER BY count DESC + LIMIT 10 + ` + ) + .all() as AutoRoutingTopProviderRow[]; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 71b686fcd2dc..f1bf1cbedc8e 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -690,3 +690,38 @@ export type { WeeklyPatternRow, PresetCostModelRow, } from "./db/usageAnalytics"; + +// --------------------------------------------------------------------------- +// usage_logs — auto-routing analytics (#3500 slice 4) +// --------------------------------------------------------------------------- +export { + getAutoRoutingTotalCount, + getAutoRoutingVariantBreakdown, + getAutoRoutingTopProviders, +} from "./db/usageLogs"; +export type { + AutoRoutingTotalResult, + AutoRoutingVariantRow, + AutoRoutingTopProviderRow, +} from "./db/usageLogs"; + +// --------------------------------------------------------------------------- +// semantic_cache — cache entries CRUD (#3500 slice 4) +// --------------------------------------------------------------------------- +export { + listSemanticCacheEntries, + deleteSemanticCacheBySignature, + deleteSemanticCacheByModel, +} from "./db/semanticCache"; +export type { + SemanticCacheEntry, + SemanticCacheListOptions, + SemanticCacheListResult, + DeleteSemanticCacheBySignatureResult, + DeleteSemanticCacheByModelResult, +} from "./db/semanticCache"; + +// --------------------------------------------------------------------------- +// proxy_logs — export query (#3500 slice 4) +// --------------------------------------------------------------------------- +export { exportProxyLogsSince } from "./db/proxyLogs"; diff --git a/tests/unit/db-logs-cache-3500.test.ts b/tests/unit/db-logs-cache-3500.test.ts new file mode 100644 index 000000000000..6377bbe3d088 --- /dev/null +++ b/tests/unit/db-logs-cache-3500.test.ts @@ -0,0 +1,348 @@ +/** + * #3500 — usage_logs / semantic_cache / proxy_logs SQL extracted into db modules + * (Hard Rule #5, slice 4). + * + * Seeds an in-memory temp SQLite DB for each table and asserts each new db + * function returns the correct rows / counts. DB handles are released in + * test.after to prevent Node native test runner from hanging + * (CLAUDE.md PII/Stream Learnings #3). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-db-logs-cache-3500-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const usageLogs = await import("../../src/lib/db/usageLogs.ts"); +const semanticCache = await import("../../src/lib/db/semanticCache.ts"); +const proxyLogs = await import("../../src/lib/db/proxyLogs.ts"); + +// --------------------------------------------------------------------------- +// Helpers — usage_logs seeding +// usage_logs is NOT in the core.ts schema; create it as a lightweight table +// mirroring the columns used by the auto-routing queries (model, provider). +// --------------------------------------------------------------------------- + +function ensureUsageLogsTable() { + const db = core.getDbInstance(); + db.prepare( + `CREATE TABLE IF NOT EXISTS usage_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + model TEXT NOT NULL, + provider TEXT NOT NULL, + timestamp TEXT NOT NULL + )` + ).run(); +} + +function insertUsageLog(row: { model: string; provider: string }) { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO usage_logs (model, provider, timestamp) VALUES (?, ?, ?)` + ).run(row.model, row.provider, new Date().toISOString()); +} + +// --------------------------------------------------------------------------- +// Helpers — semantic_cache seeding +// --------------------------------------------------------------------------- + +function insertSemanticCache(row: { + id: string; + signature: string; + model: string; + hit_count?: number; + tokens_saved?: number; +}) { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO semantic_cache + (id, signature, model, prompt_hash, response, tokens_saved, hit_count, created_at, expires_at) + VALUES + (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now', '+1 hour'))` + ).run( + row.id, + row.signature, + row.model, + "hash_" + row.id, + "{}", + row.tokens_saved ?? 0, + row.hit_count ?? 0, + ); +} + +// --------------------------------------------------------------------------- +// Helpers — proxy_logs seeding +// --------------------------------------------------------------------------- + +function insertProxyLog(row: { id: string; timestamp: string; provider?: string }) { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO proxy_logs (id, timestamp, provider, status, proxy_type) VALUES (?, ?, ?, ?, ?)` + ).run(row.id, row.timestamp, row.provider ?? "openai", "ok", "http"); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +test.before(() => { + core.resetDbInstance(); + ensureUsageLogsTable(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// =========================================================================== +// usageLogs — getAutoRoutingTotalCount +// =========================================================================== + +test("#3500 getAutoRoutingTotalCount — returns 0 when no rows", () => { + const result = usageLogs.getAutoRoutingTotalCount(); + assert.equal(result.count, 0); +}); + +test("#3500 getAutoRoutingTotalCount — counts auto and auto/* models", () => { + insertUsageLog({ model: "auto", provider: "openai" }); + insertUsageLog({ model: "auto/fast", provider: "anthropic" }); + insertUsageLog({ model: "gpt-4", provider: "openai" }); // must NOT be counted + + const result = usageLogs.getAutoRoutingTotalCount(); + assert.ok(result.count >= 2, `expected >= 2, got ${result.count}`); +}); + +// =========================================================================== +// usageLogs — getAutoRoutingVariantBreakdown +// =========================================================================== + +test("#3500 getAutoRoutingVariantBreakdown — maps auto → default, auto/X → X", () => { + // Insert another auto and auto/fast to have stable counts + insertUsageLog({ model: "auto", provider: "openai" }); + insertUsageLog({ model: "auto/fast", provider: "anthropic" }); + + const rows = usageLogs.getAutoRoutingVariantBreakdown(); + const byVariant: Record = {}; + for (const r of rows) byVariant[r.variant] = r.count; + + assert.ok("default" in byVariant, "should have a 'default' variant for bare 'auto'"); + assert.ok("fast" in byVariant, "should have a 'fast' variant for 'auto/fast'"); + assert.ok(byVariant["default"] >= 1, "default count >= 1"); + assert.ok(byVariant["fast"] >= 1, "fast count >= 1"); +}); + +// =========================================================================== +// usageLogs — getAutoRoutingTopProviders +// =========================================================================== + +test("#3500 getAutoRoutingTopProviders — returns top providers for auto/* models", () => { + const rows = usageLogs.getAutoRoutingTopProviders(); + assert.ok(Array.isArray(rows), "result is array"); + assert.ok(rows.length > 0, "at least one provider row"); + for (const r of rows) { + assert.equal(typeof r.provider, "string"); + assert.equal(typeof r.count, "number"); + } + // Should be ordered descending by count (first row has highest count) + if (rows.length > 1) { + assert.ok(rows[0].count >= rows[1].count, "ordered descending by count"); + } +}); + +// =========================================================================== +// semanticCache — listSemanticCacheEntries +// =========================================================================== + +test("#3500 listSemanticCacheEntries — returns entries with pagination", () => { + insertSemanticCache({ id: "sc-1", signature: "sig-alpha", model: "gpt-4", hit_count: 5 }); + insertSemanticCache({ id: "sc-2", signature: "sig-beta", model: "claude-3", hit_count: 2 }); + insertSemanticCache({ id: "sc-3", signature: "sig-gamma", model: "gpt-4", hit_count: 1 }); + + const result = semanticCache.listSemanticCacheEntries({ + page: 1, + limit: 10, + search: "", + model: "", + sortBy: "hit_count", + sortOrder: "desc", + }); + + assert.ok(result.total >= 3, `total >= 3, got ${result.total}`); + assert.ok(result.entries.length >= 3, "at least 3 entries returned"); + + // First entry should have highest hit_count (sorted desc) + if (result.entries.length >= 2) { + assert.ok( + result.entries[0].hit_count >= result.entries[1].hit_count, + "sorted desc by hit_count" + ); + } +}); + +test("#3500 listSemanticCacheEntries — search filter narrows results", () => { + const result = semanticCache.listSemanticCacheEntries({ + page: 1, + limit: 10, + search: "sig-alpha", + model: "", + sortBy: "created_at", + sortOrder: "desc", + }); + + assert.ok(result.total >= 1, "should find at least 1 matching entry"); + assert.ok( + result.entries.some((e) => e.signature === "sig-alpha"), + "sig-alpha in results" + ); +}); + +test("#3500 listSemanticCacheEntries — model filter works", () => { + const result = semanticCache.listSemanticCacheEntries({ + page: 1, + limit: 10, + search: "", + model: "claude-3", + sortBy: "created_at", + sortOrder: "desc", + }); + + assert.ok(result.total >= 1, "at least 1 claude-3 entry"); + for (const e of result.entries) { + assert.equal(e.model, "claude-3", "all entries are claude-3"); + } +}); + +test("#3500 listSemanticCacheEntries — pagination offset works", () => { + const p1 = semanticCache.listSemanticCacheEntries({ + page: 1, + limit: 2, + search: "", + model: "", + sortBy: "created_at", + sortOrder: "asc", + }); + const p2 = semanticCache.listSemanticCacheEntries({ + page: 2, + limit: 2, + search: "", + model: "", + sortBy: "created_at", + sortOrder: "asc", + }); + + if (p1.entries.length > 0 && p2.entries.length > 0) { + assert.notEqual( + p1.entries[0].id, + p2.entries[0].id, + "page 1 and page 2 must not have the same first entry" + ); + } +}); + +// =========================================================================== +// semanticCache — deleteSemanticCacheBySignature +// =========================================================================== + +test("#3500 deleteSemanticCacheBySignature — deletes exactly the matching entry", () => { + insertSemanticCache({ id: "sc-del-sig", signature: "sig-to-delete", model: "gpt-4" }); + + // Verify it exists first + const before = semanticCache.listSemanticCacheEntries({ + page: 1, + limit: 100, + search: "sig-to-delete", + model: "", + sortBy: "created_at", + sortOrder: "desc", + }); + assert.ok(before.total >= 1, "entry exists before delete"); + + const result = semanticCache.deleteSemanticCacheBySignature("sig-to-delete"); + assert.equal(result.deleted, 1); + + const after = semanticCache.listSemanticCacheEntries({ + page: 1, + limit: 100, + search: "sig-to-delete", + model: "", + sortBy: "created_at", + sortOrder: "desc", + }); + assert.equal(after.total, 0, "entry removed after delete"); +}); + +// =========================================================================== +// semanticCache — deleteSemanticCacheByModel +// =========================================================================== + +test("#3500 deleteSemanticCacheByModel — deletes all entries for the given model", () => { + insertSemanticCache({ id: "sc-m1", signature: "sig-model-a-1", model: "model-to-purge" }); + insertSemanticCache({ id: "sc-m2", signature: "sig-model-a-2", model: "model-to-purge" }); + + const before = semanticCache.listSemanticCacheEntries({ + page: 1, + limit: 100, + search: "", + model: "model-to-purge", + sortBy: "created_at", + sortOrder: "desc", + }); + assert.ok(before.total >= 2, "2 entries before delete"); + + const result = semanticCache.deleteSemanticCacheByModel("model-to-purge"); + assert.ok(result.deleted >= 2, `deleted >= 2, got ${result.deleted}`); + + const after = semanticCache.listSemanticCacheEntries({ + page: 1, + limit: 100, + search: "", + model: "model-to-purge", + sortBy: "created_at", + sortOrder: "desc", + }); + assert.equal(after.total, 0, "no entries remain after delete"); +}); + +// =========================================================================== +// proxyLogs — exportProxyLogsSince +// =========================================================================== + +test("#3500 exportProxyLogsSince — returns rows with timestamp >= since", () => { + const base = new Date("2025-01-15T10:00:00.000Z"); + const old = new Date("2025-01-14T10:00:00.000Z"); + + insertProxyLog({ id: "pl-new-1", timestamp: new Date("2025-01-15T11:00:00.000Z").toISOString(), provider: "openai" }); + insertProxyLog({ id: "pl-new-2", timestamp: new Date("2025-01-15T12:00:00.000Z").toISOString(), provider: "anthropic" }); + insertProxyLog({ id: "pl-old-1", timestamp: old.toISOString(), provider: "openai" }); // outside window + + const rows = proxyLogs.exportProxyLogsSince(base.toISOString()); + + assert.ok(Array.isArray(rows), "result is array"); + const ids = rows.map((r) => (r as { id: string }).id); + assert.ok(ids.includes("pl-new-1"), "pl-new-1 included"); + assert.ok(ids.includes("pl-new-2"), "pl-new-2 included"); + assert.ok(!ids.includes("pl-old-1"), "pl-old-1 excluded (before since)"); +}); + +test("#3500 exportProxyLogsSince — results are ordered descending by timestamp", () => { + const rows = proxyLogs.exportProxyLogsSince(new Date("2025-01-01T00:00:00.000Z").toISOString()); + assert.ok(rows.length >= 2, "at least 2 rows"); + + // Verify descending order + for (let i = 1; i < rows.length; i++) { + const prev = (rows[i - 1] as { timestamp: string }).timestamp; + const curr = (rows[i] as { timestamp: string }).timestamp; + assert.ok(prev >= curr, `row ${i - 1} timestamp (${prev}) >= row ${i} (${curr})`); + } +}); + +test("#3500 exportProxyLogsSince — returns empty array when no rows match", () => { + const future = new Date(Date.now() + 86_400_000 * 365).toISOString(); + const rows = proxyLogs.exportProxyLogsSince(future); + assert.deepEqual(rows, []); +});