From 1d38900f17bb535647b27c6162d1aba8fb3b76f6 Mon Sep 17 00:00:00 2001 From: Gi99lin <74502520+Gi99lin@users.noreply.github.com> Date: Sun, 3 May 2026 22:19:08 +0300 Subject: [PATCH] fix(analytics): robust model pricing resolution, dark mode charts and SQL aggregation fixes (#1896) Integrated into release/v3.7.9 (migration renumbered to 044) --- src/app/api/usage/analytics/route.ts | 204 +++++++++++++----- .../044_usage_history_api_key_backfill.sql | 27 +++ src/shared/components/analytics/charts.tsx | 47 ++-- 3 files changed, 196 insertions(+), 82 deletions(-) create mode 100644 src/lib/db/migrations/044_usage_history_api_key_backfill.sql diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 60ed43c6a1..e89d9351e2 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -30,12 +30,6 @@ 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; -} - const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; type PricingByProvider = Record>>; @@ -65,28 +59,76 @@ function appendWhereCondition(whereClause: string, condition: string): string { return whereClause ? `${whereClause} AND (${condition})` : `WHERE (${condition})`; } +function findKeyInsensitive(obj: Record | undefined | null, key: string): any { + if (!obj || !key) return undefined; + return obj[key.toLowerCase()]; +} + function resolveModelPricing( pricingByProvider: PricingByProvider, - provider: string, + providerAliasMap: Record, + providerRaw: string, model: string, normalizeModelName: (model: string) => string ): Record | null { - const providerPricing = pricingByProvider[provider]; - if (!providerPricing) return null; + const pLower = (providerRaw || "").toLowerCase(); - const normalizedModel = normalizeModelName(model); - const shortModel = shortModelName(model); - return ( - providerPricing[model] || - providerPricing[normalizedModel] || - providerPricing[shortModel] || - null - ); + let providerPricing = findKeyInsensitive(pricingByProvider, pLower); + if (!providerPricing) { + const alias = providerAliasMap[pLower]; + if (alias) { + providerPricing = findKeyInsensitive(pricingByProvider, alias); + } else { + const np = pLower.replace(/-cn$/, ""); + if (np && np !== pLower) { + providerPricing = findKeyInsensitive(pricingByProvider, np); + } + } + } + + // Hardcoded known fallbacks + if (!providerPricing) { + if (pLower === "antigravity") providerPricing = findKeyInsensitive(pricingByProvider, "ag"); + } + + const normalizedModel = normalizeModelName(model).toLowerCase(); + const shortModel = normalizedModel; // normalizeModelName behaves exactly like shortModelName + const hyphenModel = model.toLowerCase().replace(/\./g, "-"); + const hyphenNormalized = normalizedModel.replace(/\./g, "-"); + const lowerModel = model.toLowerCase(); + + const tryFind = (prov: Record | null | undefined) => { + if (!prov || typeof prov !== "object") return null; + return ( + findKeyInsensitive(prov as Record, lowerModel) || + findKeyInsensitive(prov as Record, normalizedModel) || + findKeyInsensitive(prov as Record, shortModel) || + findKeyInsensitive(prov as Record, hyphenModel) || + findKeyInsensitive(prov as Record, hyphenNormalized) || + null + ); + }; + + let pricing = providerPricing ? tryFind(providerPricing) : null; + + if (!pricing) { + // Global fallback: search all providers for this exact model (helps with aliases) + for (const prov of Object.values(pricingByProvider)) { + const found = tryFind(prov as Record); + if (found) { + pricing = found; + break; + } + } + } + + return pricing as Record | null; } function computeUsageRowCost( row: Record, pricingByProvider: PricingByProvider, + providerAliasMap: Record, normalizeModelName: (model: string) => string, computeCostFromPricing: ComputeCostFromPricing ): number { @@ -94,7 +136,13 @@ function computeUsageRowCost( const model = toStringValue(row.model); if (!provider || !model) return 0; - const pricing = resolveModelPricing(pricingByProvider, provider, model, normalizeModelName); + const pricing = resolveModelPricing( + pricingByProvider, + providerAliasMap, + provider, + model, + normalizeModelName + ); if (!pricing) return 0; return computeCostFromPricing(pricing, { @@ -163,9 +211,20 @@ export async function GET(request: Request) { // Fetch pricing data for cost calculation (no rows loaded) const { getPricing } = await import("@/lib/db/settings"); - const pricingByProvider = (await getPricing()) as PricingByProvider; + const rawPricingByProvider = (await getPricing()) as PricingByProvider; + + // Pre-process pricing data to lowercase keys for O(1) lookups + const pricingByProvider: PricingByProvider = {}; + for (const [providerKey, providerVal] of Object.entries(rawPricingByProvider || {})) { + const lowerProvider = {}; + for (const [modelKey, modelVal] of Object.entries(providerVal || {})) { + (lowerProvider as any)[modelKey.toLowerCase()] = modelVal; + } + pricingByProvider[providerKey.toLowerCase()] = lowerProvider; + } const { computeCostFromPricing, normalizeModelName } = await import("@/lib/usage/costCalculator"); + const { PROVIDER_ID_TO_ALIAS } = await import("@omniroute/open-sse/config/providerModels"); const summaryRow = db .prepare( @@ -210,8 +269,8 @@ export async function GET(request: Request) { ` SELECT DATE(timestamp) as date, - provider, - model, + LOWER(provider) as provider, + LOWER(model) as model, COALESCE(SUM(tokens_input), 0) as promptTokens, COALESCE(SUM(tokens_output), 0) as completionTokens, COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens, @@ -219,7 +278,7 @@ export async function GET(request: Request) { COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens FROM usage_history ${whereClause} - GROUP BY DATE(timestamp), provider, model + GROUP BY DATE(timestamp), LOWER(provider), LOWER(model) ORDER BY date ASC ` ) @@ -263,8 +322,8 @@ export async function GET(request: Request) { .prepare( ` SELECT - model, - provider, + LOWER(model) as model, + LOWER(provider) as provider, COUNT(*) as requests, COALESCE(SUM(tokens_input), 0) as promptTokens, COALESCE(SUM(tokens_output), 0) as completionTokens, @@ -277,7 +336,7 @@ export async function GET(request: Request) { COALESCE(MAX(timestamp), '') as lastUsed FROM usage_history ${whereClause} - GROUP BY model, provider + GROUP BY LOWER(model), LOWER(provider) ORDER BY requests DESC LIMIT 50 ` @@ -288,8 +347,8 @@ export async function GET(request: Request) { .prepare( ` SELECT - provider, - model, + LOWER(provider) as provider, + LOWER(model) as model, COALESCE(SUM(tokens_input), 0) as promptTokens, COALESCE(SUM(tokens_output), 0) as completionTokens, COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens, @@ -297,7 +356,7 @@ export async function GET(request: Request) { COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens FROM usage_history ${whereClause} - GROUP BY provider, model + GROUP BY LOWER(provider), LOWER(model) ` ) .all(params) as Array>; @@ -306,7 +365,7 @@ export async function GET(request: Request) { .prepare( ` SELECT - provider, + LOWER(provider) as provider, COUNT(*) as requests, COALESCE(SUM(tokens_input), 0) as promptTokens, COALESCE(SUM(tokens_output), 0) as completionTokens, @@ -315,7 +374,7 @@ export async function GET(request: Request) { COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests FROM usage_history ${whereClause} - GROUP BY provider + GROUP BY LOWER(provider) ORDER BY requests DESC ` ) @@ -325,17 +384,18 @@ export async function GET(request: Request) { .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 + COALESCE(NULLIF(c.display_name, ''), NULLIF(c.email, ''), NULLIF(c.name, ''), usage_history.connection_id, 'unknown') as account, + LOWER(usage_history.provider) as provider, + LOWER(usage_history.model) as model, + COALESCE(SUM(usage_history.tokens_input), 0) as promptTokens, + COALESCE(SUM(usage_history.tokens_output), 0) as completionTokens, + COALESCE(SUM(usage_history.tokens_cache_read), 0) as cacheReadTokens, + COALESCE(SUM(usage_history.tokens_cache_creation), 0) as cacheCreationTokens, + COALESCE(SUM(usage_history.tokens_reasoning), 0) as reasoningTokens FROM usage_history - ${whereClause} - GROUP BY connection_id, provider, model + LEFT JOIN provider_connections c ON c.id = usage_history.connection_id + ${whereClause.replace(/timestamp/g, "usage_history.timestamp").replace(/api_key_/g, "usage_history.api_key_")} + GROUP BY account, LOWER(usage_history.provider), LOWER(usage_history.model) ` ) .all(params) as Array>; @@ -344,31 +404,35 @@ export async function GET(request: Request) { .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 + COALESCE(NULLIF(c.display_name, ''), NULLIF(c.email, ''), NULLIF(c.name, ''), usage_history.connection_id, 'unknown') as account, + COUNT(usage_history.id) as requests, + COALESCE(SUM(usage_history.tokens_input), 0) as promptTokens, + COALESCE(SUM(usage_history.tokens_output), 0) as completionTokens, + COALESCE(SUM(usage_history.tokens_input + usage_history.tokens_output), 0) as totalTokens, + COALESCE(AVG(usage_history.latency_ms), 0) as avgLatencyMs, + COALESCE(MAX(usage_history.timestamp), '') as lastUsed FROM usage_history - ${whereClause} - GROUP BY connection_id + LEFT JOIN provider_connections c ON c.id = usage_history.connection_id + ${whereClause.replace(/timestamp/g, "usage_history.timestamp").replace(/api_key_/g, "usage_history.api_key_")} + GROUP BY account ORDER BY requests DESC LIMIT 50 ` ) .all(params) as Array>; - const apiKeyWhereClause = whereClause; + 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, + LOWER(provider) as provider, + LOWER(model) as model, COUNT(*) as requests, COALESCE(SUM(tokens_input), 0) as promptTokens, COALESCE(SUM(tokens_output), 0) as completionTokens, @@ -378,7 +442,7 @@ export async function GET(request: Request) { COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens FROM usage_history ${apiKeyWhereClause} - GROUP BY api_key_id, api_key_name, provider, model + GROUP BY api_key_id, api_key_name, LOWER(provider), LOWER(model) ` ) .all(params) as Array>; @@ -471,17 +535,31 @@ export async function GET(request: Request) { streak: 0, }; + const dailyByModelMap: Record> = {}; + const allModels = new Set(); + const dailyCostByDate = new Map(); for (const row of dailyCostRows) { const date = toStringValue(row.date); if (!date) continue; + + // Calculate costs const cost = computeUsageRowCost( row, pricingByProvider, + PROVIDER_ID_TO_ALIAS, normalizeModelName, computeCostFromPricing ); dailyCostByDate.set(date, (dailyCostByDate.get(date) || 0) + cost); + + // Group tokens by model for the day + const model = normalizeModelName(row.model as string); + const tokens = Number(row.promptTokens) + Number(row.completionTokens); + + if (!dailyByModelMap[date]) dailyByModelMap[date] = {}; + dailyByModelMap[date][model] = (dailyByModelMap[date][model] || 0) + tokens; + allModels.add(model); } const dailyTrend = dailyRows.map((row) => ({ @@ -502,7 +580,7 @@ export async function GET(request: Request) { const byModel = modelRows.map((row) => { const model = row.model as string; const provider = row.provider as string; - const short = shortModelName(model); + const short = normalizeModelName(model); const tokens = { input: Number(row.promptTokens) || 0, output: Number(row.completionTokens) || 0, @@ -510,6 +588,7 @@ export async function GET(request: Request) { const cost = computeUsageRowCost( row, pricingByProvider, + PROVIDER_ID_TO_ALIAS, normalizeModelName, computeCostFromPricing ); @@ -541,6 +620,7 @@ export async function GET(request: Request) { const cost = computeUsageRowCost( row, pricingByProvider, + PROVIDER_ID_TO_ALIAS, normalizeModelName, computeCostFromPricing ); @@ -567,6 +647,7 @@ export async function GET(request: Request) { const cost = computeUsageRowCost( row, pricingByProvider, + PROVIDER_ID_TO_ALIAS, normalizeModelName, computeCostFromPricing ); @@ -619,6 +700,7 @@ export async function GET(request: Request) { existing.cost += computeUsageRowCost( row, pricingByProvider, + PROVIDER_ID_TO_ALIAS, normalizeModelName, computeCostFromPricing ); @@ -650,6 +732,11 @@ export async function GET(request: Request) { } } + const dailyByModel = Object.keys(dailyByModelMap) + .sort() + .map((date) => ({ date, ...dailyByModelMap[date] })); + const modelNames = Array.from(allModels); + const analytics = { summary, dailyTrend, @@ -661,6 +748,8 @@ export async function GET(request: Request) { weeklyPattern, weeklyTokens, weeklyCounts, + dailyByModel, + modelNames, range, } as any; @@ -699,8 +788,8 @@ export async function GET(request: Request) { .prepare( ` SELECT - model, - provider, + LOWER(model) as model, + LOWER(provider) as provider, COALESCE(SUM(tokens_input), 0) as promptTokens, COALESCE(SUM(tokens_output), 0) as completionTokens, COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens, @@ -708,7 +797,7 @@ export async function GET(request: Request) { COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens FROM usage_history ${presetWhere} - GROUP BY model, provider + GROUP BY LOWER(model), LOWER(provider) ` ) .all(presetParams) as Array>; @@ -718,6 +807,7 @@ export async function GET(request: Request) { presetTotalCost += computeUsageRowCost( row, pricingByProvider, + PROVIDER_ID_TO_ALIAS, normalizeModelName, computeCostFromPricing ); diff --git a/src/lib/db/migrations/044_usage_history_api_key_backfill.sql b/src/lib/db/migrations/044_usage_history_api_key_backfill.sql new file mode 100644 index 0000000000..870ea285bf --- /dev/null +++ b/src/lib/db/migrations/044_usage_history_api_key_backfill.sql @@ -0,0 +1,27 @@ +-- Usage History API Key Backfill +-- Backfills missing API key names and IDs in usage_history using the connection_id + +UPDATE usage_history +SET + api_key_name = ( + SELECT 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 = ( + SELECT api_key_id + FROM usage_history AS uh2 + WHERE uh2.connection_id = usage_history.connection_id + AND uh2.api_key_id IS NOT NULL + AND uh2.api_key_id != '' + GROUP BY uh2.api_key_id + ORDER BY COUNT(*) DESC + LIMIT 1 + ) +WHERE (api_key_name IS NULL OR api_key_name = '') + AND connection_id IS NOT NULL; diff --git a/src/shared/components/analytics/charts.tsx b/src/shared/components/analytics/charts.tsx index a7649305fb..809e5ba956 100644 --- a/src/shared/components/analytics/charts.tsx +++ b/src/shared/components/analytics/charts.tsx @@ -267,10 +267,7 @@ export function ActivityHeatmap({ activityMap }) { -
+
{monthLabels.map((m, i) => ( @@ -300,19 +297,19 @@ export function ActivityHeatmap({ activityMap }) {
{weeks.map((week, wi) => ( -
- {week.map((day, di) => ( -
- ))} -
- ))} +
+ {week.map((day, di) => ( +
+ ))} +
+ ))} +
-
Less @@ -364,7 +361,7 @@ export function DailyTrendChart({ dailyTrend }) { > @@ -794,7 +791,7 @@ export function WeeklyPattern({ weeklyPattern }) { />

Most Active Day

@@ -856,12 +853,12 @@ export function MostActiveDay7d({ activityMap }) { {data.weekday} - + {data.label} ยท {fmt(data.tokens)} tokens ) : ( - + No data in the last 7 days )} @@ -913,7 +910,7 @@ export function WeeklySquares7d({ activityMap }) {

Weekly

@@ -938,7 +935,7 @@ export function WeeklySquares7d({ activityMap }) { style={{ fontSize: 9, fontWeight: 600, - color: "var(--text-muted)", + color: "var(--color-text-muted)", letterSpacing: "0.03em", }} > @@ -1238,13 +1235,13 @@ export function ModelOverTimeChart({ dailyByModel, modelNames }) { fmt(v)} axisLine={false} tickLine={false}