mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
chore(release): v3.7.7 — changelog, docs, version sync
This commit is contained in:
16
CHANGELOG.md
16
CHANGELOG.md
@@ -4,6 +4,22 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.7.7] — 2026-04-30
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Analytics Custom Filters:** Added custom date range selection, API key filtering, and NULL key analytics backfilling to the Costs Dashboard (#1830)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Rate-limit Watchdog:** Implemented a new rate-limit watchdog with environment override capabilities and Stage Tracing to prevent and diagnose silent wedges (#1828)
|
||||
|
||||
### 🛠️ Maintenance
|
||||
|
||||
- **Workflow:** Fixed the changelog extraction logic to accurately capture GitHub release descriptions
|
||||
|
||||
---
|
||||
|
||||
## [3.7.6] — 2026-04-30
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.7.6
|
||||
version: 3.7.7
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.7.6",
|
||||
"version": "3.7.7",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
||||
4
llm.txt
4
llm.txt
@@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
|
||||
**Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost.
|
||||
|
||||
**Current version:** 3.7.6
|
||||
**Current version:** 3.7.7
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
└── .env.example # Environment variable template
|
||||
```
|
||||
|
||||
## Key Features (v3.7.6)
|
||||
## Key Features (v3.7.7)
|
||||
|
||||
### Core Proxy
|
||||
- **160+ AI providers** with automatic format translation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.7.6",
|
||||
"version": "3.7.7",
|
||||
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.7.6",
|
||||
"version": "3.7.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.7.6",
|
||||
"version": "3.7.7",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -15201,7 +15201,7 @@
|
||||
},
|
||||
"open-sse": {
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.7.6"
|
||||
"version": "3.7.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.7.6",
|
||||
"version": "3.7.7",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
708
scripts/scratch/fix_route.ts
Normal file
708
scripts/scratch/fix_route.ts
Normal file
@@ -0,0 +1,708 @@
|
||||
import fs from "fs";
|
||||
|
||||
const content = fs.readFileSync("src/app/api/usage/analytics/route.ts", "utf8");
|
||||
|
||||
// We'll replace the GET function entirely
|
||||
const getFnStart = content.indexOf("export async function GET(request: Request) {");
|
||||
const beforeGet = content.slice(0, getFnStart);
|
||||
|
||||
const newGetFn = `export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const range = searchParams.get("range") || "30d";
|
||||
const startDate = searchParams.get("startDate") || undefined;
|
||||
const endDate = searchParams.get("endDate") || undefined;
|
||||
const apiKeyIdsParam = searchParams.get("apiKeyIds") || "";
|
||||
const apiKeyIds = apiKeyIdsParam ? apiKeyIdsParam.split(",").filter(Boolean) : [];
|
||||
|
||||
const sinceIso = startDate || getRangeStartIso(range);
|
||||
const untilIso = endDate || null;
|
||||
const presetsParam = searchParams.get("presets");
|
||||
|
||||
const db = getDbInstance();
|
||||
|
||||
// ── Enrich entries with missing apiKeyName ──────────────────────────
|
||||
try {
|
||||
// Only run enrichment if there are actually NULL entries
|
||||
const hasNull = db.prepare("SELECT 1 FROM usage_history WHERE (api_key_name IS NULL OR api_key_name = '') AND connection_id IS NOT NULL LIMIT 1").get();
|
||||
if (hasNull) {
|
||||
// Step 1: dominant key per connectionId from existing usage data
|
||||
db.prepare(\`
|
||||
UPDATE usage_history
|
||||
SET
|
||||
api_key_name = (
|
||||
SELECT uh2.api_key_name
|
||||
FROM usage_history AS uh2
|
||||
WHERE uh2.connection_id = usage_history.connection_id
|
||||
AND uh2.api_key_name IS NOT NULL AND uh2.api_key_name != ''
|
||||
GROUP BY uh2.api_key_name
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 1
|
||||
),
|
||||
api_key_id = COALESCE(api_key_id, (
|
||||
SELECT uh2.api_key_id
|
||||
FROM usage_history AS uh2
|
||||
WHERE uh2.connection_id = usage_history.connection_id
|
||||
AND uh2.api_key_name IS NOT NULL AND uh2.api_key_name != ''
|
||||
GROUP BY uh2.api_key_name
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 1
|
||||
))
|
||||
WHERE (api_key_name IS NULL OR api_key_name = '')
|
||||
AND connection_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM usage_history AS uh3
|
||||
WHERE uh3.connection_id = usage_history.connection_id
|
||||
AND uh3.api_key_name IS NOT NULL AND uh3.api_key_name != ''
|
||||
)
|
||||
\`).run();
|
||||
|
||||
// Step 2 & 3: For still unresolved connections, check apiKeys config
|
||||
const stillNull = db.prepare("SELECT DISTINCT connection_id FROM usage_history WHERE (api_key_name IS NULL OR api_key_name = '') AND connection_id IS NOT NULL").all();
|
||||
if (stillNull.length > 0) {
|
||||
const { getApiKeys } = await import("@/lib/localDb");
|
||||
const apiKeys = (await getApiKeys()) as any[];
|
||||
|
||||
const updateStmt = db.prepare("UPDATE usage_history SET api_key_name = ?, api_key_id = ? WHERE connection_id = ? AND (api_key_name IS NULL OR api_key_name = '')");
|
||||
const updateMany = db.transaction((updates: any[]) => {
|
||||
for (const u of updates) updateStmt.run(u.name, u.id, u.cid);
|
||||
});
|
||||
|
||||
const updates = [];
|
||||
const orphanIds = new Set(stillNull.map((r: any) => r.connection_id));
|
||||
|
||||
for (const ak of apiKeys) {
|
||||
const allowed = Array.isArray(ak.allowedConnections) ? ak.allowedConnections : [];
|
||||
const keyName = ak.name || ak.id;
|
||||
const keyId = ak.id || null;
|
||||
for (const cid of allowed) {
|
||||
if (typeof cid === "string" && orphanIds.has(cid)) {
|
||||
updates.push({ name: keyName, id: keyId, cid });
|
||||
orphanIds.delete(cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanIds.size > 0) {
|
||||
const unrestrictedKeys = apiKeys.filter(
|
||||
(ak: any) => !Array.isArray(ak.allowedConnections) || ak.allowedConnections.length === 0
|
||||
);
|
||||
if (unrestrictedKeys.length > 0) {
|
||||
let bestKey = unrestrictedKeys[0];
|
||||
let bestCount = -1;
|
||||
for (const uk of unrestrictedKeys) {
|
||||
const countRow = db.prepare("SELECT COUNT(*) as c FROM usage_history WHERE api_key_name = ?").get(uk.name || uk.id) as any;
|
||||
if (countRow.c > bestCount) { bestCount = countRow.c; bestKey = uk; }
|
||||
}
|
||||
const fallbackName = bestKey.name || bestKey.id;
|
||||
const fallbackId = bestKey.id || null;
|
||||
for (const cid of orphanIds) {
|
||||
updates.push({ name: fallbackName, id: fallbackId, cid });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length > 0) updateMany(updates);
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error("Failed to backfill missing api_key_name:", e);
|
||||
}
|
||||
|
||||
const conditions = [];
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (sinceIso) {
|
||||
conditions.push("timestamp >= @since");
|
||||
params.since = sinceIso;
|
||||
}
|
||||
if (untilIso) {
|
||||
conditions.push("timestamp <= @until");
|
||||
params.until = untilIso;
|
||||
}
|
||||
|
||||
let apiKeyWhere = "";
|
||||
if (apiKeyIds.length > 0) {
|
||||
const placeholders = apiKeyIds.map((_, i) => \`@apiKey\${i}\`);
|
||||
apiKeyIds.forEach((key, i) => {
|
||||
params[\`apiKey\${i}\`] = key;
|
||||
});
|
||||
apiKeyWhere = \`(api_key_name IN (\${placeholders.join(",")}) OR api_key_id IN (\${placeholders.join(",")}))\`;
|
||||
conditions.push(apiKeyWhere);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? \`WHERE \${conditions.join(" AND ")}\` : "";
|
||||
|
||||
// Fetch pricing data for cost calculation (no rows loaded)
|
||||
const { getPricing } = await import("@/lib/db/settings");
|
||||
const pricingByProvider = (await getPricing()) as PricingByProvider;
|
||||
const { computeCostFromPricing, normalizeModelName } =
|
||||
await import("@/lib/usage/costCalculator");
|
||||
|
||||
const summaryRow = db
|
||||
.prepare(
|
||||
\`
|
||||
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 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>>;
|
||||
|
||||
const dailyCostRows = db
|
||||
.prepare(
|
||||
\`
|
||||
SELECT
|
||||
DATE(timestamp) as date,
|
||||
provider,
|
||||
model,
|
||||
COALESCE(SUM(tokens_input), 0) as promptTokens,
|
||||
COALESCE(SUM(tokens_output), 0) as completionTokens,
|
||||
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
|
||||
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
|
||||
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens
|
||||
FROM usage_history
|
||||
\${whereClause}
|
||||
GROUP BY DATE(timestamp), provider, model
|
||||
ORDER BY date ASC
|
||||
\`
|
||||
)
|
||||
.all(params) as Array<Record<string, unknown>>;
|
||||
|
||||
const heatmapStart = new Date();
|
||||
heatmapStart.setUTCDate(heatmapStart.getUTCDate() - 364);
|
||||
// Custom date range might need a wider heatmap window
|
||||
if (startDate) {
|
||||
const customStart = new Date(startDate);
|
||||
if (customStart.getTime() < heatmapStart.getTime()) {
|
||||
heatmapStart.setTime(customStart.getTime());
|
||||
}
|
||||
}
|
||||
|
||||
// Heatmap needs its own whereClause if api keys are filtered
|
||||
const heatmapConditions = ["timestamp >= @heatmapStart"];
|
||||
if (apiKeyWhere) heatmapConditions.push(apiKeyWhere);
|
||||
const heatmapParams: Record<string, string> = { heatmapStart: heatmapStart.toISOString() };
|
||||
if (apiKeyIds.length > 0) {
|
||||
apiKeyIds.forEach((key, i) => { heatmapParams[\`apiKey\${i}\`] = key; });
|
||||
}
|
||||
|
||||
const heatmapRows = db
|
||||
.prepare(
|
||||
\`
|
||||
SELECT
|
||||
DATE(timestamp) as date,
|
||||
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
|
||||
FROM usage_history
|
||||
WHERE \${heatmapConditions.join(" AND ")}
|
||||
GROUP BY DATE(timestamp)
|
||||
ORDER BY date ASC
|
||||
\`
|
||||
)
|
||||
.all(heatmapParams) 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_cache_read), 0) as cacheReadTokens,
|
||||
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
|
||||
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens,
|
||||
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 providerCostRows = db
|
||||
.prepare(
|
||||
\`
|
||||
SELECT
|
||||
provider,
|
||||
model,
|
||||
COALESCE(SUM(tokens_input), 0) as promptTokens,
|
||||
COALESCE(SUM(tokens_output), 0) as completionTokens,
|
||||
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
|
||||
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
|
||||
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens
|
||||
FROM usage_history
|
||||
\${whereClause}
|
||||
GROUP BY provider, model
|
||||
\`
|
||||
)
|
||||
.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 accountCostRows = db
|
||||
.prepare(
|
||||
\`
|
||||
SELECT
|
||||
connection_id as account,
|
||||
provider,
|
||||
model,
|
||||
COALESCE(SUM(tokens_input), 0) as promptTokens,
|
||||
COALESCE(SUM(tokens_output), 0) as completionTokens,
|
||||
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
|
||||
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
|
||||
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens
|
||||
FROM usage_history
|
||||
\${whereClause}
|
||||
GROUP BY connection_id, provider, model
|
||||
\`
|
||||
)
|
||||
.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 apiKeyWhereClause = appendWhereCondition(
|
||||
whereClause,
|
||||
"(api_key_id IS NOT NULL AND api_key_id != '') OR (api_key_name IS NOT NULL AND api_key_name != '')"
|
||||
);
|
||||
const apiKeyRows = db
|
||||
.prepare(
|
||||
\`
|
||||
SELECT
|
||||
api_key_id as apiKeyId,
|
||||
COALESCE(NULLIF(api_key_name, ''), NULLIF(api_key_id, ''), 'Unknown API key') as apiKeyName,
|
||||
provider,
|
||||
model,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(tokens_input), 0) as promptTokens,
|
||||
COALESCE(SUM(tokens_output), 0) as completionTokens,
|
||||
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
|
||||
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
|
||||
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens,
|
||||
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
|
||||
FROM usage_history
|
||||
\${apiKeyWhereClause}
|
||||
GROUP BY api_key_id, api_key_name, provider, model
|
||||
\`
|
||||
)
|
||||
.all(params) as Array<Record<string, unknown>>;
|
||||
|
||||
const weeklyRows = db
|
||||
.prepare(
|
||||
\`
|
||||
SELECT
|
||||
dayOfWeek,
|
||||
COUNT(*) as days,
|
||||
COALESCE(SUM(requests), 0) as requests,
|
||||
COALESCE(SUM(totalTokens), 0) as totalTokens
|
||||
FROM (
|
||||
SELECT
|
||||
DATE(timestamp) as date,
|
||||
strftime('%w', timestamp) as dayOfWeek,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
|
||||
FROM usage_history
|
||||
\${whereClause}
|
||||
GROUP BY DATE(timestamp), strftime('%w', timestamp)
|
||||
)
|
||||
GROUP BY dayOfWeek
|
||||
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)),
|
||||
totalCost: 0,
|
||||
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,
|
||||
streak: 0,
|
||||
};
|
||||
|
||||
const dailyCostByDate = new Map<string, number>();
|
||||
for (const row of dailyCostRows) {
|
||||
const date = toStringValue(row.date);
|
||||
if (!date) continue;
|
||||
const cost = computeUsageRowCost(
|
||||
row,
|
||||
pricingByProvider,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing
|
||||
);
|
||||
dailyCostByDate.set(date, (dailyCostByDate.get(date) || 0) + cost);
|
||||
}
|
||||
|
||||
const dailyTrend = dailyRows.map((row) => ({
|
||||
date: row.date,
|
||||
requests: Number(row.requests),
|
||||
promptTokens: Number(row.promptTokens),
|
||||
completionTokens: Number(row.completionTokens),
|
||||
totalTokens: Number(row.totalTokens),
|
||||
cost: roundCost(dailyCostByDate.get(toStringValue(row.date)) || 0),
|
||||
}));
|
||||
|
||||
const activityMap: Record<string, number> = {};
|
||||
for (const row of heatmapRows) {
|
||||
activityMap[row.date as string] = Number(row.totalTokens);
|
||||
}
|
||||
summary.streak = computeActivityStreak(activityMap);
|
||||
|
||||
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,
|
||||
};
|
||||
const cost = computeUsageRowCost(
|
||||
row,
|
||||
pricingByProvider,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing
|
||||
);
|
||||
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: roundCost(cost),
|
||||
};
|
||||
});
|
||||
|
||||
const totalCost = Array.from(dailyCostByDate.values()).reduce((sum, cost) => sum + cost, 0);
|
||||
summary.totalCost = roundCost(totalCost);
|
||||
|
||||
const providerCostByProvider = new Map<string, number>();
|
||||
for (const row of providerCostRows) {
|
||||
const provider = toStringValue(row.provider);
|
||||
if (!provider) continue;
|
||||
const cost = computeUsageRowCost(
|
||||
row,
|
||||
pricingByProvider,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing
|
||||
);
|
||||
providerCostByProvider.set(provider, (providerCostByProvider.get(provider) || 0) + cost);
|
||||
}
|
||||
|
||||
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,
|
||||
cost: roundCost(providerCostByProvider.get(toStringValue(row.provider)) || 0),
|
||||
}));
|
||||
|
||||
const accountCostByAccount = new Map<string, number>();
|
||||
for (const row of accountCostRows) {
|
||||
const account = toStringValue(row.account, "unknown");
|
||||
const cost = computeUsageRowCost(
|
||||
row,
|
||||
pricingByProvider,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing
|
||||
);
|
||||
accountCostByAccount.set(account, (accountCostByAccount.get(account) || 0) + cost);
|
||||
}
|
||||
|
||||
const byAccount = accountRows.map((row) => ({
|
||||
account: toStringValue(row.account, "unknown"),
|
||||
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,
|
||||
cost: roundCost(accountCostByAccount.get(toStringValue(row.account, "unknown")) || 0),
|
||||
}));
|
||||
|
||||
const apiKeyMap = new Map<
|
||||
string,
|
||||
{
|
||||
apiKey: string;
|
||||
apiKeyId: string | null;
|
||||
apiKeyName: string;
|
||||
requests: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
cost: number;
|
||||
}
|
||||
>();
|
||||
for (const row of apiKeyRows) {
|
||||
const apiKeyId = toStringValue(row.apiKeyId);
|
||||
const apiKeyName = toStringValue(row.apiKeyName, apiKeyId || "Unknown API key");
|
||||
const key = \`\${apiKeyId || "unknown"}::\${apiKeyName}\`;
|
||||
const existing = apiKeyMap.get(key) || {
|
||||
apiKey: apiKeyId && apiKeyName !== apiKeyId ? \`\${apiKeyName} (\${apiKeyId})\` : apiKeyName,
|
||||
apiKeyId: apiKeyId || null,
|
||||
apiKeyName,
|
||||
requests: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cost: 0,
|
||||
};
|
||||
|
||||
existing.requests += Number(row.requests);
|
||||
existing.promptTokens += Number(row.promptTokens);
|
||||
existing.completionTokens += Number(row.completionTokens);
|
||||
existing.totalTokens += Number(row.totalTokens);
|
||||
existing.cost += computeUsageRowCost(
|
||||
row,
|
||||
pricingByProvider,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing
|
||||
);
|
||||
apiKeyMap.set(key, existing);
|
||||
}
|
||||
const byApiKey = Array.from(apiKeyMap.values())
|
||||
.map((row) => ({ ...row, cost: roundCost(row.cost) }))
|
||||
.sort((left, right) => right.cost - left.cost);
|
||||
|
||||
const weeklyTokens = [0, 0, 0, 0, 0, 0, 0];
|
||||
const weeklyCounts = [0, 0, 0, 0, 0, 0, 0];
|
||||
const weeklyPattern = WEEKDAY_LABELS.map((day) => ({
|
||||
day,
|
||||
avgTokens: 0,
|
||||
totalTokens: 0,
|
||||
}));
|
||||
for (const row of weeklyRows) {
|
||||
const dayIdx = Number(row.dayOfWeek);
|
||||
if (dayIdx >= 0 && dayIdx <= 6) {
|
||||
const totalTokens = Number(row.totalTokens);
|
||||
const days = Number(row.days);
|
||||
weeklyTokens[dayIdx] = totalTokens;
|
||||
weeklyCounts[dayIdx] = Number(row.requests);
|
||||
weeklyPattern[dayIdx] = {
|
||||
day: WEEKDAY_LABELS[dayIdx],
|
||||
avgTokens: days > 0 ? Math.round(totalTokens / days) : 0,
|
||||
totalTokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const analytics = {
|
||||
summary,
|
||||
dailyTrend,
|
||||
activityMap,
|
||||
byModel,
|
||||
byProvider,
|
||||
byApiKey,
|
||||
byAccount,
|
||||
weeklyPattern,
|
||||
weeklyTokens,
|
||||
weeklyCounts,
|
||||
range,
|
||||
} as any;
|
||||
|
||||
if (presetsParam) {
|
||||
const allowedRanges = new Set(["1d", "7d", "30d", "90d", "ytd", "all"]);
|
||||
const presetRanges = presetsParam
|
||||
.split(",")
|
||||
.map((preset) => preset.trim())
|
||||
.filter((preset) => allowedRanges.has(preset));
|
||||
const presetSummaries: Record<string, { totalCost: number }> = {};
|
||||
|
||||
for (const presetRange of presetRanges) {
|
||||
if (presetRange === range) {
|
||||
presetSummaries[presetRange] = {
|
||||
totalCost: Number(analytics.summary?.totalCost || 0),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
const presetSinceIso = getRangeStartIso(presetRange);
|
||||
const presetConditions = [];
|
||||
const presetParams: Record<string, string> = {};
|
||||
if (presetSinceIso) { presetConditions.push("timestamp >= @presetSince"); presetParams.presetSince = presetSinceIso; }
|
||||
if (apiKeyWhere) { presetConditions.push(apiKeyWhere); Object.assign(presetParams, params); }
|
||||
|
||||
const presetWhere = presetConditions.length > 0 ? \`WHERE \${presetConditions.join(" AND ")}\` : "";
|
||||
|
||||
const presetModelRows = db
|
||||
.prepare(
|
||||
\`
|
||||
SELECT
|
||||
model,
|
||||
provider,
|
||||
COALESCE(SUM(tokens_input), 0) as promptTokens,
|
||||
COALESCE(SUM(tokens_output), 0) as completionTokens,
|
||||
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
|
||||
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
|
||||
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens
|
||||
FROM usage_history
|
||||
\${presetWhere}
|
||||
GROUP BY model, provider
|
||||
\`
|
||||
)
|
||||
.all(presetParams) as Array<Record<string, unknown>>;
|
||||
|
||||
let presetTotalCost = 0;
|
||||
for (const row of presetModelRows) {
|
||||
presetTotalCost += computeUsageRowCost(
|
||||
row,
|
||||
pricingByProvider,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing
|
||||
);
|
||||
}
|
||||
|
||||
presetSummaries[presetRange] = {
|
||||
totalCost: roundCost(presetTotalCost),
|
||||
};
|
||||
}
|
||||
|
||||
analytics.presetSummaries = presetSummaries;
|
||||
}
|
||||
|
||||
return NextResponse.json(analytics);
|
||||
} catch (error) {
|
||||
console.error("Error computing analytics:", error);
|
||||
return NextResponse.json({ error: "Failed to compute analytics" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
fs.writeFileSync("src/app/api/usage/analytics/route.ts", beforeGet + newGetFn, "utf8");
|
||||
Reference in New Issue
Block a user