mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(analytics): use pure SQL aggregations — no history rows loaded (#1802)
Integrated into release/v3.7.6
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ────────────────
|
||||
|
||||
171
tests/unit/usage-analytics-route.test.ts
Normal file
171
tests/unit/usage-analytics-route.test.ts
Normal 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);
|
||||
});
|
||||
122
tests/unit/usage-history-db.test.ts
Normal file
122
tests/unit/usage-history-db.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user