fix(analytics): use pure SQL aggregations — no history rows loaded (#1802)

Integrated into release/v3.7.6
This commit is contained in:
Paijo
2026-04-30 21:47:32 +07:00
committed by GitHub
parent 2bdc72a3ea
commit 950fdba44c
4 changed files with 675 additions and 78 deletions

View File

@@ -1,6 +1,4 @@
import { NextResponse } from "next/server";
import { getUsageDb } from "@/lib/usageDb";
import { computeAnalytics } from "@/lib/usageAnalytics";
import { getDbInstance } from "@/lib/db/core";
function getRangeStartIso(range: string): string | null {
@@ -32,85 +30,326 @@ function getRangeStartIso(range: string): string | null {
return start.toISOString();
}
function shortModelName(model: string | null): string {
if (!model) return "-";
const parts = model.split(/[/:-]/);
return parts[parts.length - 1] || model;
}
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const range = searchParams.get("range") || "30d";
const sinceIso = getRangeStartIso(range);
const presetsParam = searchParams.get("presets");
// Cap history load to last 365 days — the heatmap never looks beyond that,
// and all named ranges (1d/7d/30d/90d/ytd) fall within this window.
const heatmapSince = new Date();
heatmapSince.setDate(heatmapSince.getDate() - 365);
const db = await getUsageDb(heatmapSince.toISOString());
const history = db.data.history || [];
const db = getDbInstance();
const whereClause = sinceIso ? "WHERE timestamp >= @since" : "";
const params = sinceIso ? { since: sinceIso } : {};
// Build connection map for account names
const { getProviderConnections } = await import("@/lib/localDb");
const connectionMap: Record<string, string> = {};
try {
const connections = await getProviderConnections();
for (const connRaw of connections as unknown[]) {
const conn =
connRaw && typeof connRaw === "object" && !Array.isArray(connRaw)
? (connRaw as Record<string, unknown>)
: {};
const connectionId =
typeof conn.id === "string" && conn.id.trim().length > 0 ? conn.id : null;
if (!connectionId) continue;
// Fetch pricing data for cost calculation (no rows loaded)
const { getPricing } = await import("@/lib/db/settings");
const pricingByProvider = await getPricing();
const { normalizeModelName } = await import("@/lib/usage/costCalculator");
const name =
(typeof conn.name === "string" && conn.name.trim()) ||
(typeof conn.email === "string" && conn.email.trim()) ||
connectionId;
connectionMap[connectionId] = name;
}
} catch {
/* ignore */
}
const analytics: any = await computeAnalytics(history, range, connectionMap);
// T01: fallback transparency metrics from call_logs (requested_model vs routed model).
try {
const db = getDbInstance();
const sinceIso = getRangeStartIso(range);
const whereClause = sinceIso ? "WHERE timestamp >= @since" : "";
const row = db
.prepare(
`
SELECT
COUNT(*) as total,
SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' THEN 1 ELSE 0 END) as with_requested,
SUM(CASE
WHEN requested_model IS NOT NULL
AND requested_model != ''
AND model IS NOT NULL
AND requested_model != model
THEN 1 ELSE 0 END
) as fallbacks
FROM call_logs
${whereClause}
const summaryRow = db
.prepare(
`
)
.get(sinceIso ? { since: sinceIso } : {}) as
| { total?: number; with_requested?: number; fallbacks?: number }
| undefined;
SELECT
COUNT(*) as totalRequests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens,
COUNT(DISTINCT model) as uniqueModels,
COUNT(DISTINCT connection_id) as uniqueAccounts,
COUNT(DISTINCT api_key_id) as uniqueApiKeys,
COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests,
COALESCE(AVG(latency_ms), 0) as avgLatencyMs,
COALESCE(MIN(timestamp), '') as firstRequest,
COALESCE(MAX(timestamp), '') as lastRequest
FROM usage_history
${whereClause}
`
)
.get(params) as Record<string, unknown>;
const total = Number(row?.total || 0);
const withRequested = Number(row?.with_requested || 0);
const fallbackCount = Number(row?.fallbacks || 0);
const dailyRows = db
.prepare(
`
SELECT
DATE(timestamp) as date,
COUNT(*) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
FROM usage_history
${whereClause}
GROUP BY DATE(timestamp)
ORDER BY date ASC
`
)
.all(params) as Array<Record<string, unknown>>;
analytics.summary.fallbackCount = fallbackCount;
analytics.summary.fallbackRatePct =
withRequested > 0 ? Number(((fallbackCount / withRequested) * 100).toFixed(2)) : 0;
analytics.summary.requestedModelCoveragePct =
total > 0 ? Number(((withRequested / total) * 100).toFixed(2)) : 0;
} catch {
analytics.summary.fallbackCount = 0;
analytics.summary.fallbackRatePct = 0;
analytics.summary.requestedModelCoveragePct = 0;
// Use requested range for heatmap if available, else default to 364 days (1 year) as fallback
const heatmapStart = sinceIso ? new Date(sinceIso) : new Date();
if (!sinceIso) {
heatmapStart.setDate(heatmapStart.getDate() - 364);
}
const heatmapRows = db
.prepare(
`
SELECT
DATE(timestamp) as date,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
FROM usage_history
WHERE timestamp >= @heatmapStart
GROUP BY DATE(timestamp)
ORDER BY date ASC
`
)
.all({ heatmapStart: heatmapStart.toISOString() }) as Array<Record<string, unknown>>;
const modelRows = db
.prepare(
`
SELECT
model,
provider,
COUNT(*) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens,
COALESCE(AVG(latency_ms), 0) as avgLatencyMs,
COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests,
COALESCE(MAX(timestamp), '') as lastUsed
FROM usage_history
${whereClause}
GROUP BY model, provider
ORDER BY requests DESC
LIMIT 50
`
)
.all(params) as Array<Record<string, unknown>>;
const providerRows = db
.prepare(
`
SELECT
provider,
COUNT(*) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens,
COALESCE(AVG(latency_ms), 0) as avgLatencyMs,
COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests
FROM usage_history
${whereClause}
GROUP BY provider
ORDER BY requests DESC
`
)
.all(params) as Array<Record<string, unknown>>;
const accountRows = db
.prepare(
`
SELECT
connection_id as account,
COUNT(*) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens,
COALESCE(AVG(latency_ms), 0) as avgLatencyMs,
COALESCE(MAX(timestamp), '') as lastUsed
FROM usage_history
${whereClause}
GROUP BY connection_id
ORDER BY requests DESC
LIMIT 50
`
)
.all(params) as Array<Record<string, unknown>>;
const weeklyRows = db
.prepare(
`
SELECT
strftime('%w', timestamp) as dayOfWeek,
COUNT(*) as requests,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
FROM usage_history
${whereClause}
GROUP BY strftime('%w', timestamp)
ORDER BY dayOfWeek ASC
`
)
.all(params) as Array<Record<string, unknown>>;
const fallbackRow = db
.prepare(
`
SELECT
COUNT(*) as total,
SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' THEN 1 ELSE 0 END) as with_requested,
SUM(CASE
WHEN requested_model IS NOT NULL
AND requested_model != ''
AND model IS NOT NULL
AND requested_model != model
THEN 1 ELSE 0 END
) as fallbacks
FROM call_logs
${whereClause}
`
)
.get(params) as Record<string, unknown>;
const summary = {
totalRequests: Number(summaryRow?.totalRequests || 0),
promptTokens: Number(summaryRow?.promptTokens || 0),
completionTokens: Number(summaryRow?.completionTokens || 0),
totalTokens: Number(summaryRow?.totalTokens || 0),
uniqueModels: Number(summaryRow?.uniqueModels || 0),
uniqueAccounts: Number(summaryRow?.uniqueAccounts || 0),
uniqueApiKeys: Number(summaryRow?.uniqueApiKeys || 0),
successfulRequests: Number(summaryRow?.successfulRequests || 0),
successRatePct:
Number(summaryRow?.totalRequests || 0) > 0
? Number(
(
(Number(summaryRow?.successfulRequests || 0) /
Number(summaryRow?.totalRequests || 1)) *
100
).toFixed(2)
)
: 0,
avgLatencyMs: Math.round(Number(summaryRow?.avgLatencyMs || 0)),
// Compute totalCost by summing cost from byModel (calculated later)
totalCost: 0, // Will be updated after byModel is processed
firstRequest: summaryRow?.firstRequest || "",
lastRequest: summaryRow?.lastRequest || "",
fallbackCount: Number(fallbackRow?.fallbacks || 0),
fallbackRatePct:
Number(fallbackRow?.with_requested || 0) > 0
? Number(
(
(Number(fallbackRow?.fallbacks || 0) / Number(fallbackRow?.with_requested || 1)) *
100
).toFixed(2)
)
: 0,
requestedModelCoveragePct:
Number(fallbackRow?.total || 0) > 0
? Number(
(
(Number(fallbackRow?.with_requested || 0) / Number(fallbackRow?.total || 1)) *
100
).toFixed(2)
)
: 0,
};
const dailyTrend = dailyRows.map((row) => ({
date: row.date,
requests: Number(row.requests),
promptTokens: Number(row.promptTokens),
completionTokens: Number(row.completionTokens),
totalTokens: Number(row.totalTokens),
}));
const activityMap: Record<string, number> = {};
for (const row of heatmapRows) {
activityMap[row.date as string] = Number(row.totalTokens);
}
const byModel = modelRows.map((row) => {
const model = row.model as string;
const provider = row.provider as string;
const short = shortModelName(model);
const tokens = {
input: Number(row.promptTokens) || 0,
output: Number(row.completionTokens) || 0,
};
// Compute cost from pricing and aggregated tokens
let cost = 0;
try {
const modelPricing =
pricingByProvider[provider]?.[model] || pricingByProvider[provider]?.[short];
if (modelPricing && typeof modelPricing === "object") {
const p = modelPricing as Record<string, unknown>;
const inputPrice = Number(p.input) || 0;
const outputPrice = Number(p.output) || 0;
cost = (tokens.input * inputPrice + tokens.output * outputPrice) / 1_000_000;
}
} catch {
/* ignore */
}
return {
model: short,
provider,
rawModel: model,
requests: Number(row.requests),
promptTokens: tokens.input,
completionTokens: tokens.output,
totalTokens: Number(row.totalTokens),
avgLatencyMs: Math.round(Number(row.avgLatencyMs)),
successRatePct:
Number(row.requests) > 0
? Number((Number(row.successfulRequests) / Number(row.requests)) * 100).toFixed(2)
: 0,
lastUsed: row.lastUsed,
cost: Math.round(cost * 100) / 100,
};
});
// Compute totalCost from byModel sum
const totalCost = byModel.reduce((sum, m) => sum + (m.cost || 0), 0);
summary.totalCost = Math.round(totalCost * 100) / 100;
const byProvider = providerRows.map((row) => ({
provider: row.provider,
requests: Number(row.requests),
promptTokens: Number(row.promptTokens),
completionTokens: Number(row.completionTokens),
totalTokens: Number(row.totalTokens),
avgLatencyMs: Math.round(Number(row.avgLatencyMs)),
successRatePct:
Number(row.requests) > 0
? Number((Number(row.successfulRequests) / Number(row.requests)) * 100).toFixed(2)
: 0,
}));
const byAccount = accountRows.map((row) => ({
account: row.account,
requests: Number(row.requests),
promptTokens: Number(row.promptTokens),
completionTokens: Number(row.completionTokens),
totalTokens: Number(row.totalTokens),
avgLatencyMs: Math.round(Number(row.avgLatencyMs)),
lastUsed: row.lastUsed,
}));
const weeklyTokens = [0, 0, 0, 0, 0, 0, 0];
const weeklyCounts = [0, 0, 0, 0, 0, 0, 0];
for (const row of weeklyRows) {
const dayIdx = Number(row.dayOfWeek);
if (dayIdx >= 0 && dayIdx <= 6) {
weeklyTokens[dayIdx] = Number(row.totalTokens);
weeklyCounts[dayIdx] = Number(row.requests);
}
}
const analytics = {
summary,
dailyTrend,
activityMap,
byModel,
byProvider,
byAccount,
weeklyTokens,
weeklyCounts,
range,
};
if (presetsParam) {
const allowedRanges = new Set(["1d", "7d", "30d", "90d", "ytd", "all"]);
@@ -128,9 +367,46 @@ export async function GET(request) {
continue;
}
const presetAnalytics: any = await computeAnalytics(history, presetRange, connectionMap);
const presetSinceIso = getRangeStartIso(presetRange);
const presetWhere = presetSinceIso ? "WHERE timestamp >= @presetSince" : "";
const presetParams = presetSinceIso ? { presetSince: presetSinceIso } : {};
const presetModelRows = db
.prepare(
`
SELECT
model,
provider,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens
FROM usage_history
${presetWhere}
GROUP BY model, provider
`
)
.all(presetParams) as Array<Record<string, unknown>>;
let presetTotalCost = 0;
for (const row of presetModelRows) {
const m = row.model as string;
const p = row.provider as string;
const short = shortModelName(m);
const inputTokens = Number(row.promptTokens) || 0;
const outputTokens = Number(row.completionTokens) || 0;
try {
const modelPricing = pricingByProvider[p]?.[m] || pricingByProvider[p]?.[short];
if (modelPricing && typeof modelPricing === "object") {
const mp = modelPricing as Record<string, unknown>;
const inputPrice = Number(mp.input) || 0;
const outputPrice = Number(mp.output) || 0;
presetTotalCost += (inputTokens * inputPrice + outputTokens * outputPrice) / 1_000_000;
}
} catch {}
}
presetSummaries[presetRange] = {
totalCost: Number(presetAnalytics.summary?.totalCost || 0),
totalCost: Math.round(presetTotalCost * 100) / 100,
};
}

View File

@@ -254,17 +254,42 @@ export function clearPendingRequests() {
// ──────────────── getUsageDb Shim (backward compat) ────────────────
const MAX_ROWS = 10000;
/**
* Returns an object compatible with the old LowDB interface.
* Only `api/usage/analytics/route.js` uses this — it reads `db.data.history`.
*
* @param sinceIso - ISO timestamp to filter from (inclusive)
* @param limit - Max rows to return (default 10,000)
* @param cursor - Timestamp cursor for pagination (exclusive, for next page)
*/
export async function getUsageDb(sinceIso?: string | null) {
export async function getUsageDb(sinceIso?: string | null, limit?: number, cursor?: string | null) {
const db = getDbInstance();
const rows = sinceIso
? db
.prepare("SELECT * FROM usage_history WHERE timestamp >= ? ORDER BY timestamp ASC")
.all(sinceIso)
: db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all();
const maxRows = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Number(limit) : MAX_ROWS;
let rows;
if (cursor) {
// Cursor-based pagination (next page after cursor)
// Use > cursor to get rows after the last timestamp of previous page (ASC order)
rows = sinceIso
? db
.prepare(
`SELECT * FROM usage_history WHERE timestamp >= ? AND timestamp > ? ORDER BY timestamp ASC LIMIT ?`
)
.all(sinceIso, cursor, maxRows)
: db
.prepare(`SELECT * FROM usage_history WHERE timestamp > ? ORDER BY timestamp ASC LIMIT ?`)
.all(cursor, maxRows);
} else if (sinceIso) {
// Initial query with date filter
rows = db
.prepare(`SELECT * FROM usage_history WHERE timestamp >= ? ORDER BY timestamp ASC LIMIT ?`)
.all(sinceIso, maxRows);
} else {
// No filter - get all (with limit)
rows = db.prepare(`SELECT * FROM usage_history ORDER BY timestamp ASC LIMIT ?`).all(maxRows);
}
const history = rows.map((row) => {
const r = asRecord(row);
@@ -290,7 +315,10 @@ export async function getUsageDb(sinceIso?: string | null) {
};
});
return { data: { history } };
// Provide next cursor if we hit the limit (more rows exist)
const nextCursor = rows.length === maxRows ? (rows[rows.length - 1] as any)?.timestamp : null;
return { data: { history, nextCursor } };
}
// ──────────────── Save Request Usage ────────────────

View File

@@ -0,0 +1,171 @@
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(), "omniroute-usage-analytics-route-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
const analyticsRoute = await import("../../src/app/api/usage/analytics/route.ts");
const clearPendingRequests = usageHistory.clearPendingRequests;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
clearPendingRequests();
}
async function seedAnalyticsData() {
const db = core.getDbInstance();
const now = new Date();
for (let i = 0; i < 20; i++) {
const timestamp = new Date(now.getTime() - i * 60 * 60 * 1000).toISOString();
db.prepare(
`INSERT INTO usage_history (provider, model, connection_id, api_key_id, tokens_input, tokens_output, success, latency_ms, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
i % 2 === 0 ? "openai" : "anthropic",
i % 2 === 0 ? "gpt-4o" : "claude-sonnet",
"test-conn",
"test-key",
100 + i,
50 + i,
1,
200 + i * 10,
timestamp
);
}
db.prepare(
`INSERT INTO call_logs (provider, model, requested_model, connection_id, timestamp)
VALUES (?, ?, ?, ?, ?)`
).run("openai", "gpt-4o", "gpt-4o-mini", "test-conn", new Date().toISOString());
}
function makeRequest(url: string) {
return new Request(url, { method: "GET" });
}
test.beforeEach(async () => {
await resetStorage();
await localDb.updatePricing({
openai: { "gpt-4o": { input: 2.5, output: 10 } },
anthropic: { "claude-sonnet": { input: 3, output: 15 } },
});
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("GET /api/usage/analytics returns summary with aggregated metrics", async () => {
await seedAnalyticsData();
const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.summary.totalRequests, 20);
assert.equal(body.summary.uniqueModels, 2);
assert.equal(body.summary.uniqueAccounts, 1);
assert.ok(body.summary.totalTokens > 0);
assert.ok(body.summary.avgLatencyMs > 0);
});
test("GET /api/usage/analytics includes dailyTrend array", async () => {
await seedAnalyticsData();
const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
const body = await response.json();
assert.equal(response.status, 200);
assert.ok(Array.isArray(body.dailyTrend));
assert.ok(body.dailyTrend.length > 0);
});
test("GET /api/usage/analytics includes byModel array with cost calculations", async () => {
await seedAnalyticsData();
const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
const body = await response.json();
assert.equal(response.status, 200);
assert.ok(Array.isArray(body.byModel));
assert.ok(body.byModel.length > 0);
const gptEntry = body.byModel.find((m) => m.model === "4o" && m.provider === "openai");
assert.ok(gptEntry);
assert.ok(typeof gptEntry.cost === "number");
});
test("GET /api/usage/analytics filters by range parameter", async () => {
await seedAnalyticsData();
const response = await analyticsRoute.GET(
makeRequest("http://localhost/api/usage/analytics?range=1d")
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.range, "1d");
});
test("GET /api/usage/analytics includes byProvider array", async () => {
await seedAnalyticsData();
const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
const body = await response.json();
assert.equal(response.status, 200);
assert.ok(Array.isArray(body.byProvider));
assert.ok(body.byProvider.length > 0);
});
test("GET /api/usage/analytics includes byAccount array", async () => {
await seedAnalyticsData();
const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
const body = await response.json();
assert.equal(response.status, 200);
assert.ok(Array.isArray(body.byAccount));
assert.ok(body.byAccount.length > 0);
assert.equal(body.byAccount[0].account, "test-conn");
});
test("GET /api/usage/analytics returns weeklyTokens and weeklyCounts", async () => {
await seedAnalyticsData();
const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
const body = await response.json();
assert.equal(response.status, 200);
assert.ok(Array.isArray(body.weeklyTokens));
assert.equal(body.weeklyTokens.length, 7);
assert.ok(Array.isArray(body.weeklyCounts));
assert.equal(body.weeklyCounts.length, 7);
});
test("GET /api/usage/analytics includes activityMap for heatmap", async () => {
await seedAnalyticsData();
const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
const body = await response.json();
assert.equal(response.status, 200);
assert.ok(typeof body.activityMap === "object");
assert.ok(Object.keys(body.activityMap).length > 0);
});
test("GET /api/usage/analytics returns 500 on database errors", async () => {
const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
const body = await response.json();
assert.equal(response.status, 200);
assert.ok(body.summary.totalRequests === 0);
});

View File

@@ -0,0 +1,122 @@
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(), "omniroute-usage-history-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
const clearPendingRequests = usageHistory.clearPendingRequests;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
clearPendingRequests();
}
async function seedUsageEntries(
entries: Array<{
provider: string;
model: string;
connectionId?: string;
tokens: { input?: number; output?: number };
success?: boolean;
latencyMs?: number;
minutesAgo?: number;
}>
) {
for (const [i, e] of entries.entries()) {
await usageHistory.saveRequestUsage({
provider: e.provider,
model: e.model,
connectionId: e.connectionId || `conn-${i}`,
apiKeyId: `key-${i}`,
apiKeyName: `Key ${i}`,
tokens: e.tokens,
success: e.success !== false,
latencyMs: e.latencyMs || 100,
timestamp: new Date(Date.now() - (e.minutesAgo || i) * 60 * 1000).toISOString(),
});
}
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ──────────────── getUsageDb ────────────────
test("getUsageDb returns all rows up to MAX_ROWS limit in ASC order", async () => {
// Save 12 entries (limit is 10000, so all should return)
const entries = Array.from({ length: 12 }, (_, i) => ({
provider: `prov-${i}`,
model: `model-${i}`,
tokens: { input: i * 10, output: i },
success: true,
minutesAgo: i,
}));
await seedUsageEntries(entries);
const result = await usageHistory.getUsageDb();
assert.equal(result.data.history.length, 12);
assert.equal(result.data.history[0].provider, "prov-11");
assert.equal(result.data.history[11].provider, "prov-0");
assert.equal(result.data.nextCursor, null); // No next cursor (didn't hit limit)
});
test("getUsageDb filters by sinceIso date", async () => {
const entries = [
{ provider: "old", model: "m", tokens: { input: 10 }, minutesAgo: 120 },
{ provider: "new", model: "m", tokens: { input: 20 }, minutesAgo: 1 },
];
await seedUsageEntries(entries);
const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const result = await usageHistory.getUsageDb(cutoff);
assert.equal(result.data.history.length, 1);
assert.equal(result.data.history[0].provider, "new");
});
test("getUsageDb provides nextCursor when rows exceed MAX_ROWS", async () => {
const db = core.getDbInstance();
// Insert exactly MAX_ROWS + 1 = 10001 rows so getUsageDb returns a cursor
for (let i = 0; i < 10001; i++) {
db.prepare(
`INSERT INTO usage_history (provider, model, timestamp, tokens_input, tokens_output, success, latency_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(`prov-${i}`, `model-${i}`, new Date(Date.now() - i * 1000).toISOString(), 10, 5, 1, 100);
}
const result = await usageHistory.getUsageDb();
assert.equal(result.data.history.length, 10000);
assert.notEqual(result.data.nextCursor, null);
});
test("getUsageDb cursor-based pagination fetches subsequent pages", async () => {
const entries = Array.from({ length: 5 }, (_, i) => ({
provider: `prov-${i}`,
model: `model-${i}`,
tokens: { input: 10 },
success: true,
minutesAgo: i,
}));
await seedUsageEntries(entries);
// First page (limit=2)
const page1 = await usageHistory.getUsageDb(undefined, 2);
assert.equal(page1.data.history.length, 2);
assert.notEqual(page1.data.nextCursor, null);
});