From fbff252b62894a83bef0f3b5f596e32ec679949a Mon Sep 17 00:00:00 2001 From: Zireael <3856578+Zireael@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:00:36 +0200 Subject: [PATCH 1/4] feat(usage): add cached tokens and cache hit % metrics Surface cache token data that was already captured but not displayed: Backend (usageRepo.js): - Add cacheReadTokens to addToCounter, aggregateEntryToDay, all byProvider/byModel/byAccount/byApiKey/byEndpoint aggregations - Add totalCacheReadTokens and cacheHitRatio to getUsageStats output - Add cachedTokens, promptTokens, cacheHitRatio to getChartData buckets Frontend: - OverviewCards: add Cached Tokens and Cache Hit % summary cards - UsageChart: add Cached and Cache % view modes with filter dropdown - UsageTable: add Cached Tokens and Cache Hit % columns in tokens mode - RequestDetailsTab: add cache columns to table and detail drawer - UsageStats orchestrator: propagate cache data through sortData and groupDataByKey to all child components --- .../usage/components/OverviewCards.js | 13 +- .../usage/components/RequestDetailsTab.js | 34 +++++- .../dashboard/usage/components/UsageChart.js | 114 +++++++++++++++--- .../dashboard/usage/components/UsageTable.js | 9 ++ src/lib/db/repos/usageRepo.js | 75 ++++++++---- src/shared/components/UsageStats.js | 11 +- 6 files changed, 210 insertions(+), 46 deletions(-) diff --git a/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js b/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js index 5d08933de9..dfb47d29e2 100644 --- a/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js +++ b/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js @@ -5,10 +5,13 @@ import Card from "@/shared/components/Card"; const fmt = (n) => new Intl.NumberFormat().format(n || 0); const fmtCost = (n) => `$${(n || 0).toFixed(2)}`; +const fmtPct = (n) => `${(n * 100).toFixed(1)}%`; export default function OverviewCards({ stats }) { + const cacheHitRatio = stats.totalPromptTokens > 0 ? stats.totalCacheReadTokens / stats.totalPromptTokens : 0; + return ( -
+
Total Requests {fmt(stats.totalRequests)} @@ -21,6 +24,14 @@ export default function OverviewCards({ stats }) { Output Tokens {fmt(stats.totalCompletionTokens)} + + Cached Tokens + {fmt(stats.totalCacheReadTokens || 0)} + + + Cache Hit % + {fmtPct(cacheHitRatio)} + Est. Cost ~{fmtCost(stats.totalCost)} diff --git a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js index e450dd941e..485668ce85 100644 --- a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js +++ b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js @@ -88,6 +88,16 @@ function getInputTokens(tokens) { return prompt < cache ? cache : prompt; } +function getCacheReadTokens(tokens) { + return tokens?.cache_read_input_tokens || tokens?.cached_tokens || 0; +} + +function getCacheHitPercent(tokens) { + const prompt = tokens?.prompt_tokens || tokens?.input_tokens || 0; + const cache = tokens?.cache_read_input_tokens || tokens?.cached_tokens || 0; + return prompt > 0 ? (cache / prompt * 100) : 0; +} + export default function RequestDetailsTab() { const [details, setDetails] = useState([]); const [pagination, setPagination] = useState({ @@ -246,6 +256,8 @@ export default function RequestDetailsTab() { Provider Input Tokens Output Tokens + Cached Tokens + Cache Hit % Latency Action @@ -253,7 +265,7 @@ export default function RequestDetailsTab() { {loading ? ( - +
progress_activity Loading... @@ -262,7 +274,7 @@ export default function RequestDetailsTab() { ) : details.length === 0 ? ( - + No request details found @@ -289,6 +301,12 @@ export default function RequestDetailsTab() { {detail.tokens?.completion_tokens?.toLocaleString() || 0} + + {getCacheReadTokens(detail.tokens).toLocaleString()} + + + {getCacheHitPercent(detail.tokens).toFixed(1)}% +
TTFT: {detail.latency?.ttft || 0}ms
@@ -376,6 +394,18 @@ export default function RequestDetailsTab() { {selectedDetail.tokens?.completion_tokens?.toLocaleString() || 0}
+
+ Cached Tokens:{" "} + + {getCacheReadTokens(selectedDetail.tokens).toLocaleString()} + +
+
+ Cache Hit %:{" "} + + {getCacheHitPercent(selectedDetail.tokens).toFixed(1)}% + +
diff --git a/src/app/(dashboard)/dashboard/usage/components/UsageChart.js b/src/app/(dashboard)/dashboard/usage/components/UsageChart.js index 14a19c42ac..f43fbfec58 100644 --- a/src/app/(dashboard)/dashboard/usage/components/UsageChart.js +++ b/src/app/(dashboard)/dashboard/usage/components/UsageChart.js @@ -21,11 +21,13 @@ const fmtTokens = (n) => { }; const fmtCost = (n) => `$${(n || 0).toFixed(4)}`; +const fmtPct = (n) => `${(n * 100).toFixed(1)}%`; export default function UsageChart({ period = "7d" }) { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [viewMode, setViewMode] = useState("tokens"); + const [filterBy, setFilterBy] = useState("all"); const fetchData = useCallback(async () => { setLoading(true); @@ -46,23 +48,52 @@ export default function UsageChart({ period = "7d" }) { fetchData(); }, [fetchData]); - const hasData = data.some((d) => d.tokens > 0 || d.cost > 0); + const hasData = data.some((d) => d.tokens > 0 || d.cost > 0 || d.cachedTokens > 0); return ( -
- - +
+
+ + + + +
+
+ Filter: + +
{loading ? ( @@ -77,6 +108,10 @@ export default function UsageChart({ period = "7d" }) { + + + + @@ -94,7 +129,7 @@ export default function UsageChart({ period = "7d" }) { tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }} tickLine={false} axisLine={false} - tickFormatter={viewMode === "tokens" ? fmtTokens : fmtCost} + tickFormatter={viewMode === "cost" ? fmtCost : viewMode === "cacheHit" ? fmtPct : fmtTokens} width={50} /> - name === "tokens" ? [fmtTokens(value), "Tokens"] : [fmtCost(value), "Cost"] - } + formatter={(value, name) => { + if (name === "tokens") return [fmtTokens(value), "Tokens"]; + if (name === "cachedTokens") return [fmtTokens(value), "Cached Tokens"]; + if (name === "cacheHitRatio") return [fmtPct(value), "Cache Hit %"]; + if (name === "cost") return [fmtCost(value), "Cost"]; + return [value, name]; + }} /> - {viewMode === "tokens" ? ( + {viewMode === "tokens" && ( - ) : ( + )} + {viewMode === "cached" && ( + <> + + + + )} + {viewMode === "cacheHit" && ( + + )} + {viewMode === "cost" && ( 0 ? ((item.cacheReadTokens || 0) / item.promptTokens * 100) : 0; return ( <> @@ -41,6 +42,12 @@ function ValueCells({ item, viewMode, isSummary = false }) { {isSummary && item.completionTokens === undefined ? "—" : fmt(item.completionTokens)} + + {isSummary && item.cacheReadTokens === undefined ? "—" : fmt(item.cacheReadTokens || 0)} + + + {isSummary && item.cacheReadTokens === undefined ? "—" : `${cacheHitPct.toFixed(1)}%`} + {fmt(item.totalTokens)} @@ -134,6 +141,8 @@ export default function UsageTable({ return [ { field: "promptTokens", label: "Input Tokens" }, { field: "completionTokens", label: "Output Tokens" }, + { field: "cacheReadTokens", label: "Cached Tokens" }, + { field: "cacheHitRatio", label: "Cache Hit %" }, { field: "totalTokens", label: "Total Tokens" }, ]; } diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index 8bd632ad75..cc0e5535a9 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -33,10 +33,11 @@ function getLocalDateKey(timestamp) { } function addToCounter(target, key, values) { - if (!target[key]) target[key] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 }; + if (!target[key]) target[key] = { requests: 0, promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cost: 0 }; target[key].requests += values.requests || 1; target[key].promptTokens += values.promptTokens || 0; target[key].completionTokens += values.completionTokens || 0; + target[key].cacheReadTokens += values.cacheReadTokens || 0; target[key].cost += values.cost || 0; if (values.meta) Object.assign(target[key], values.meta); } @@ -44,12 +45,14 @@ function addToCounter(target, key, values) { function aggregateEntryToDay(day, entry) { const promptTokens = entry.tokens?.prompt_tokens || entry.tokens?.input_tokens || 0; const completionTokens = entry.tokens?.completion_tokens || entry.tokens?.output_tokens || 0; + const cacheReadTokens = entry.tokens?.cache_read_input_tokens || entry.tokens?.cached_tokens || 0; const cost = entry.cost || 0; - const vals = { promptTokens, completionTokens, cost }; + const vals = { promptTokens, completionTokens, cacheReadTokens, cost }; day.requests = (day.requests || 0) + 1; day.promptTokens = (day.promptTokens || 0) + promptTokens; day.completionTokens = (day.completionTokens || 0) + completionTokens; + day.cacheReadTokens = (day.cacheReadTokens || 0) + cacheReadTokens; day.cost = (day.cost || 0) + cost; day.byProvider ||= {}; @@ -366,7 +369,8 @@ export async function getUsageStats(period = "all") { const stats = { totalRequests: 0, - totalPromptTokens: 0, totalCompletionTokens: 0, totalCost: 0, + totalPromptTokens: 0, totalCompletionTokens: 0, totalCacheReadTokens: 0, totalCost: 0, + cacheHitRatio: 0, byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {}, last10Minutes: [], pending: pendingRequests, @@ -427,13 +431,15 @@ export async function getUsageStats(period = "all") { const day = parseJson(dr.data, {}); stats.totalPromptTokens += day.promptTokens || 0; stats.totalCompletionTokens += day.completionTokens || 0; + stats.totalCacheReadTokens += day.cacheReadTokens || 0; stats.totalCost += day.cost || 0; for (const [prov, p] of Object.entries(day.byProvider || {})) { - if (!stats.byProvider[prov]) stats.byProvider[prov] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 }; + if (!stats.byProvider[prov]) stats.byProvider[prov] = { requests: 0, promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cost: 0 }; stats.byProvider[prov].requests += p.requests || 0; stats.byProvider[prov].promptTokens += p.promptTokens || 0; stats.byProvider[prov].completionTokens += p.completionTokens || 0; + stats.byProvider[prov].cacheReadTokens += p.cacheReadTokens || 0; stats.byProvider[prov].cost += p.cost || 0; } @@ -443,11 +449,12 @@ export async function getUsageStats(period = "all") { const statsKey = provider ? `${rawModel} (${provider})` : rawModel; const providerDisplayName = providerNodeNameMap[provider] || provider; if (!stats.byModel[statsKey]) { - stats.byModel[statsKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, lastUsed: dateKey }; + stats.byModel[statsKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cost: 0, rawModel, provider: providerDisplayName, lastUsed: dateKey }; } stats.byModel[statsKey].requests += m.requests || 0; stats.byModel[statsKey].promptTokens += m.promptTokens || 0; stats.byModel[statsKey].completionTokens += m.completionTokens || 0; + stats.byModel[statsKey].cacheReadTokens += m.cacheReadTokens || 0; stats.byModel[statsKey].cost += m.cost || 0; if (dateKey > (stats.byModel[statsKey].lastUsed || "")) stats.byModel[statsKey].lastUsed = dateKey; } @@ -459,11 +466,12 @@ export async function getUsageStats(period = "all") { const providerDisplayName = providerNodeNameMap[provider] || provider; const accountKey = `${rawModel} (${provider} - ${accountName})`; if (!stats.byAccount[accountKey]) { - stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, connectionId: connId, accountName, lastUsed: dateKey }; + stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cost: 0, rawModel, provider: providerDisplayName, connectionId: connId, accountName, lastUsed: dateKey }; } stats.byAccount[accountKey].requests += a.requests || 0; stats.byAccount[accountKey].promptTokens += a.promptTokens || 0; stats.byAccount[accountKey].completionTokens += a.completionTokens || 0; + stats.byAccount[accountKey].cacheReadTokens += a.cacheReadTokens || 0; stats.byAccount[accountKey].cost += a.cost || 0; if (dateKey > (stats.byAccount[accountKey].lastUsed || "")) stats.byAccount[accountKey].lastUsed = dateKey; } @@ -477,11 +485,12 @@ export async function getUsageStats(period = "all") { const keyName = keyInfo?.name || (apiKeyVal ? apiKeyVal.slice(0, 8) + "..." : "Local (No API Key)"); const apiKeyKey = apiKeyVal || "local-no-key"; if (!stats.byApiKey[akKey]) { - stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, apiKey: apiKeyVal, keyName, apiKeyKey, lastUsed: dateKey }; + stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cost: 0, rawModel, provider: providerDisplayName, apiKey: apiKeyVal, keyName, apiKeyKey, lastUsed: dateKey }; } stats.byApiKey[akKey].requests += ak.requests || 0; stats.byApiKey[akKey].promptTokens += ak.promptTokens || 0; stats.byApiKey[akKey].completionTokens += ak.completionTokens || 0; + stats.byApiKey[akKey].cacheReadTokens += ak.cacheReadTokens || 0; stats.byApiKey[akKey].cost += ak.cost || 0; if (dateKey > (stats.byApiKey[akKey].lastUsed || "")) stats.byApiKey[akKey].lastUsed = dateKey; } @@ -492,11 +501,12 @@ export async function getUsageStats(period = "all") { const provider = ep.provider || ""; const providerDisplayName = providerNodeNameMap[provider] || provider; if (!stats.byEndpoint[epKey]) { - stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, endpoint, rawModel, provider: providerDisplayName, lastUsed: dateKey }; + stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cost: 0, endpoint, rawModel, provider: providerDisplayName, lastUsed: dateKey }; } stats.byEndpoint[epKey].requests += ep.requests || 0; stats.byEndpoint[epKey].promptTokens += ep.promptTokens || 0; stats.byEndpoint[epKey].completionTokens += ep.completionTokens || 0; + stats.byEndpoint[epKey].cacheReadTokens += ep.cacheReadTokens || 0; stats.byEndpoint[epKey].cost += ep.cost || 0; if (dateKey > (stats.byEndpoint[epKey].lastUsed || "")) stats.byEndpoint[epKey].lastUsed = dateKey; } @@ -547,26 +557,30 @@ export async function getUsageStats(period = "all") { const tokens = parseJson(r.tokens, {}) || {}; const promptTokens = tokens.prompt_tokens || 0; const completionTokens = tokens.completion_tokens || 0; + const cacheReadTokens = tokens.cache_read_input_tokens || tokens.cached_tokens || 0; const entryCost = r.cost || 0; const providerDisplayName = providerNodeNameMap[r.provider] || r.provider; stats.totalPromptTokens += promptTokens; stats.totalCompletionTokens += completionTokens; + stats.totalCacheReadTokens += cacheReadTokens; stats.totalCost += entryCost; - if (!stats.byProvider[r.provider]) stats.byProvider[r.provider] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 }; + if (!stats.byProvider[r.provider]) stats.byProvider[r.provider] = { requests: 0, promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cost: 0 }; stats.byProvider[r.provider].requests++; stats.byProvider[r.provider].promptTokens += promptTokens; stats.byProvider[r.provider].completionTokens += completionTokens; + stats.byProvider[r.provider].cacheReadTokens += cacheReadTokens; stats.byProvider[r.provider].cost += entryCost; const modelKey = r.provider ? `${r.model} (${r.provider})` : r.model; if (!stats.byModel[modelKey]) { - stats.byModel[modelKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp }; + stats.byModel[modelKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp }; } stats.byModel[modelKey].requests++; stats.byModel[modelKey].promptTokens += promptTokens; stats.byModel[modelKey].completionTokens += completionTokens; + stats.byModel[modelKey].cacheReadTokens += cacheReadTokens; stats.byModel[modelKey].cost += entryCost; if (new Date(r.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) stats.byModel[modelKey].lastUsed = r.timestamp; @@ -574,11 +588,12 @@ export async function getUsageStats(period = "all") { const accountName = connectionMap[r.connectionId] || `Account ${r.connectionId.slice(0, 8)}...`; const accountKey = `${r.model} (${r.provider} - ${accountName})`; if (!stats.byAccount[accountKey]) { - stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, connectionId: r.connectionId, accountName, lastUsed: r.timestamp }; + stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, connectionId: r.connectionId, accountName, lastUsed: r.timestamp }; } stats.byAccount[accountKey].requests++; stats.byAccount[accountKey].promptTokens += promptTokens; stats.byAccount[accountKey].completionTokens += completionTokens; + stats.byAccount[accountKey].cacheReadTokens += cacheReadTokens; stats.byAccount[accountKey].cost += entryCost; if (new Date(r.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) stats.byAccount[accountKey].lastUsed = r.timestamp; } @@ -588,32 +603,33 @@ export async function getUsageStats(period = "all") { const keyName = keyInfo?.name || r.apiKey.slice(0, 8) + "..."; const akKey = `${r.apiKey}|${r.model}|${r.provider || "unknown"}`; if (!stats.byApiKey[akKey]) { - stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKey: r.apiKey, keyName, apiKeyKey: r.apiKey, lastUsed: r.timestamp }; + stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKey: r.apiKey, keyName, apiKeyKey: r.apiKey, lastUsed: r.timestamp }; } const ake = stats.byApiKey[akKey]; - ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cost += entryCost; + ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cacheReadTokens += cacheReadTokens; ake.cost += entryCost; if (new Date(r.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = r.timestamp; } else { if (!stats.byApiKey["local-no-key"]) { - stats.byApiKey["local-no-key"] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKey: null, keyName: "Local (No API Key)", apiKeyKey: "local-no-key", lastUsed: r.timestamp }; + stats.byApiKey["local-no-key"] = { requests: 0, promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKey: null, keyName: "Local (No API Key)", apiKeyKey: "local-no-key", lastUsed: r.timestamp }; } const ake = stats.byApiKey["local-no-key"]; - ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cost += entryCost; + ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cacheReadTokens += cacheReadTokens; ake.cost += entryCost; if (new Date(r.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = r.timestamp; } const endpoint = r.endpoint || "Unknown"; const epKey = `${endpoint}|${r.model}|${r.provider || "unknown"}`; if (!stats.byEndpoint[epKey]) { - stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, endpoint, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp }; + stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cost: 0, endpoint, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp }; } const epe = stats.byEndpoint[epKey]; - epe.requests++; epe.promptTokens += promptTokens; epe.completionTokens += completionTokens; epe.cost += entryCost; + epe.requests++; epe.promptTokens += promptTokens; epe.completionTokens += completionTokens; epe.cacheReadTokens += cacheReadTokens; epe.cost += entryCost; if (new Date(r.timestamp) > new Date(epe.lastUsed)) epe.lastUsed = r.timestamp; } } stats.totalRequests = Object.values(stats.byProvider).reduce((sum, p) => sum + (p.requests || 0), 0); + stats.cacheHitRatio = stats.totalPromptTokens > 0 ? stats.totalCacheReadTokens / stats.totalPromptTokens : 0; return stats; } @@ -629,10 +645,10 @@ export async function getChartData(period = "7d") { const startTime = startOfDay.getTime(); const endTime = startTime + bucketCount * bucketMs; const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); - const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0 })); + const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cachedTokens: 0, promptTokens: 0, cacheHitRatio: 0, cost: 0 })); const rows = db.all( - `SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ?`, + `SELECT timestamp, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`, [new Date(startTime).toISOString()] ); for (const r of rows) { @@ -641,9 +657,15 @@ export async function getChartData(period = "7d") { const idx = Math.floor((t - startTime) / bucketMs); if (idx >= 0 && idx < bucketCount) { buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0); + buckets[idx].promptTokens += (r.promptTokens || 0); + const parsedTokens = parseJson(r.tokens, {}) || {}; + buckets[idx].cachedTokens += parsedTokens.cache_read_input_tokens || parsedTokens.cached_tokens || 0; buckets[idx].cost += r.cost || 0; } } + for (const b of buckets) { + b.cacheHitRatio = b.promptTokens > 0 ? b.cachedTokens / b.promptTokens : 0; + } return buckets; } @@ -652,10 +674,10 @@ export async function getChartData(period = "7d") { const bucketMs = 3600000; const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); const startTime = now - bucketCount * bucketMs; - const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0 })); + const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cachedTokens: 0, promptTokens: 0, cacheHitRatio: 0, cost: 0 })); const rows = db.all( - `SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ?`, + `SELECT timestamp, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`, [new Date(startTime).toISOString()] ); for (const r of rows) { @@ -663,8 +685,14 @@ export async function getChartData(period = "7d") { if (t < startTime || t > now) continue; const idx = Math.min(Math.floor((t - startTime) / bucketMs), bucketCount - 1); buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0); + buckets[idx].promptTokens += (r.promptTokens || 0); + const parsedTokens = parseJson(r.tokens, {}) || {}; + buckets[idx].cachedTokens += parsedTokens.cache_read_input_tokens || parsedTokens.cached_tokens || 0; buckets[idx].cost += r.cost || 0; } + for (const b of buckets) { + b.cacheHitRatio = b.promptTokens > 0 ? b.cachedTokens / b.promptTokens : 0; + } return buckets; } @@ -682,9 +710,14 @@ export async function getChartData(period = "7d") { d.setDate(d.getDate() - (bucketCount - 1 - i)); const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; const dayData = dayMap[dateKey]; + const promptTokens = dayData ? (dayData.promptTokens || 0) : 0; + const cachedTokens = dayData ? (dayData.cacheReadTokens || 0) : 0; return { label: labelFn(d), tokens: dayData ? (dayData.promptTokens || 0) + (dayData.completionTokens || 0) : 0, + cachedTokens, + promptTokens, + cacheHitRatio: promptTokens > 0 ? cachedTokens / promptTokens : 0, cost: dayData ? (dayData.cost || 0) : 0, }; }); diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index 950a7af9d6..be8150a23d 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -91,7 +91,9 @@ function sortData(dataMap, pendingMap = {}, sortBy, sortOrder) { const totalCost = data.cost || 0; const inputCost = totalTokens > 0 ? (data.promptTokens || 0) * (totalCost / totalTokens) : 0; const outputCost = totalTokens > 0 ? (data.completionTokens || 0) * (totalCost / totalTokens) : 0; - return { ...data, key, totalTokens, totalCost, inputCost, outputCost, pending: pendingMap[key] || 0 }; + const cacheReadTokens = data.cacheReadTokens || 0; + const cacheHitRatio = (data.promptTokens || 0) > 0 ? cacheReadTokens / (data.promptTokens || 0) : 0; + return { ...data, key, totalTokens, totalCost, inputCost, outputCost, cacheReadTokens, cacheHitRatio, pending: pendingMap[key] || 0 }; }) .sort((a, b) => { let valA = a[sortBy]; @@ -122,7 +124,7 @@ function groupDataByKey(data, keyField) { if (!groups[gk]) { groups[gk] = { groupKey: gk, - summary: { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, cost: 0, inputCost: 0, outputCost: 0, lastUsed: null, pending: 0 }, + summary: { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, cacheReadTokens: 0, cacheHitRatio: 0, cost: 0, inputCost: 0, outputCost: 0, lastUsed: null, pending: 0 }, items: [], }; } @@ -131,6 +133,7 @@ function groupDataByKey(data, keyField) { s.promptTokens += item.promptTokens || 0; s.completionTokens += item.completionTokens || 0; s.totalTokens += item.totalTokens || 0; + s.cacheReadTokens += item.cacheReadTokens || 0; s.cost += item.cost || 0; s.inputCost += item.inputCost || 0; s.outputCost += item.outputCost || 0; @@ -140,6 +143,10 @@ function groupDataByKey(data, keyField) { } groups[gk].items.push(item); }); + // Calculate cacheHitRatio for each group + for (const group of Object.values(groups)) { + group.summary.cacheHitRatio = group.summary.promptTokens > 0 ? group.summary.cacheReadTokens / group.summary.promptTokens : 0; + } return Object.values(groups); } From 0897c4d34cb93ff2f4dfde440d1ea2818d0dbfdf Mon Sep 17 00:00:00 2001 From: Zireael <3856578+Zireael@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:59:02 +0200 Subject: [PATCH 2/4] fix(usage): eliminate zero-token duplicate entries in streaming usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleStreamingResponse and buildOnStreamComplete each generated independent streamDetailId values, causing two rows per streaming request in requestDetails — one always with 0 tokens. The ON CONFLICT clause never merged them because the IDs differed. Share a single streamDetailId from buildOnStreamComplete through to handleStreamingResponse so both saves target the same row. --- open-sse/handlers/chatCore.js | 4 ++-- open-sse/handlers/chatCore/streamingHandler.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index c1a026034a..f0d023a731 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -296,8 +296,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } // Streaming response - const { onStreamComplete } = buildOnStreamComplete({ ...sharedCtx }); - return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete }); + const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx }); + return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }); } export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index aa907cd5c7..dda31a0450 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -43,7 +43,7 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, /** * Handle streaming response — pipe provider SSE through transform stream to client. */ -export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) { +export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }) { if (onRequestSuccess) onRequestSuccess(); const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }); @@ -54,7 +54,7 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS; const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs); - const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + const detailId = streamDetailId || `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; saveRequestDetail(buildRequestDetail({ provider, model, connectionId, latency: { ttft: 0, total: Date.now() - requestStartTime }, @@ -64,7 +64,7 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou providerResponse: "[Streaming - raw response not captured]", response: { content: "[Streaming in progress...]", thinking: null, type: "streaming" }, status: "success" - }, { id: streamDetailId })).catch(err => { + }, { id: detailId })).catch(err => { console.error("[RequestDetail] Failed to save streaming request:", err.message); }); From 2065db4a1fad907fb7415220d2bb7d5af7f50e7b Mon Sep 17 00:00:00 2001 From: Zireael <3856578+Zireael@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:27:01 +0200 Subject: [PATCH 3/4] Revert "fix(usage): eliminate zero-token duplicate entries in streaming usage" This reverts commit 0897c4d34cb93ff2f4dfde440d1ea2818d0dbfdf. --- open-sse/handlers/chatCore.js | 4 ++-- open-sse/handlers/chatCore/streamingHandler.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index f0d023a731..c1a026034a 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -296,8 +296,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } // Streaming response - const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx }); - return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }); + const { onStreamComplete } = buildOnStreamComplete({ ...sharedCtx }); + return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete }); } export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index dda31a0450..aa907cd5c7 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -43,7 +43,7 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, /** * Handle streaming response — pipe provider SSE through transform stream to client. */ -export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }) { +export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) { if (onRequestSuccess) onRequestSuccess(); const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }); @@ -54,7 +54,7 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS; const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs); - const detailId = streamDetailId || `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; saveRequestDetail(buildRequestDetail({ provider, model, connectionId, latency: { ttft: 0, total: Date.now() - requestStartTime }, @@ -64,7 +64,7 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou providerResponse: "[Streaming - raw response not captured]", response: { content: "[Streaming in progress...]", thinking: null, type: "streaming" }, status: "success" - }, { id: detailId })).catch(err => { + }, { id: streamDetailId })).catch(err => { console.error("[RequestDetail] Failed to save streaming request:", err.message); }); From d1c96fa95a3fe54611258d956cb2b346b171b68e Mon Sep 17 00:00:00 2001 From: Zireael <3856578+Zireael@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:48:45 +0200 Subject: [PATCH 4/4] feat(usage): add chart filter by model/account/apiKey/endpoint - getChartData accepts filterBy param, returns grouped data when not 'all' - API route passes filterBy to getChartData - UsageChart renders multi-line chart with distinct colors per group - Chart filter dropdown now changes the displayed data --- .../dashboard/usage/components/UsageChart.js | 167 +++++++---------- src/app/api/usage/chart/route.js | 3 +- src/lib/db/repos/usageRepo.js | 173 +++++++++++++----- 3 files changed, 198 insertions(+), 145 deletions(-) diff --git a/src/app/(dashboard)/dashboard/usage/components/UsageChart.js b/src/app/(dashboard)/dashboard/usage/components/UsageChart.js index f43fbfec58..82fbcaea2c 100644 --- a/src/app/(dashboard)/dashboard/usage/components/UsageChart.js +++ b/src/app/(dashboard)/dashboard/usage/components/UsageChart.js @@ -22,6 +22,7 @@ const fmtTokens = (n) => { const fmtCost = (n) => `$${(n || 0).toFixed(4)}`; const fmtPct = (n) => `${(n * 100).toFixed(1)}%`; +const GROUP_COLORS = ["#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#06b6d4", "#f97316", "#84cc16"]; export default function UsageChart({ period = "7d" }) { const [data, setData] = useState([]); @@ -32,7 +33,7 @@ export default function UsageChart({ period = "7d" }) { const fetchData = useCallback(async () => { setLoading(true); try { - const res = await fetch(`/api/usage/chart?period=${period}`); + const res = await fetch(`/api/usage/chart?period=${period}&filterBy=${filterBy}`); if (res.ok) { const json = await res.json(); setData(json); @@ -42,13 +43,15 @@ export default function UsageChart({ period = "7d" }) { } finally { setLoading(false); } - }, [period]); + }, [period, filterBy]); useEffect(() => { fetchData(); }, [fetchData]); - const hasData = data.some((d) => d.tokens > 0 || d.cost > 0 || d.cachedTokens > 0); + const isGrouped = data && data.grouped; + const chartData = isGrouped ? data.groups : { all: data }; + const hasData = isGrouped ? Object.keys(data.groups || {}).length > 0 : data.some((d) => d.tokens > 0 || d.cost > 0 || d.cachedTokens > 0); return ( @@ -102,108 +105,72 @@ export default function UsageChart({ period = "7d" }) {
No data for this period
) : ( - - - - - - - - - - - - - - - - - - - { + {isGrouped ? ( + (() => { + const groupNames = Object.keys(chartData); + const merged = {}; + groupNames.forEach((name) => { + (chartData[name] || []).forEach((d) => { + if (!merged[d.label]) merged[d.label] = { label: d.label }; + merged[d.label][name] = viewMode === "tokens" ? d.tokens : viewMode === "cached" ? d.cachedTokens : viewMode === "cacheHit" ? d.cacheHitRatio : d.cost; + }); + }); + const mergedArr = Object.values(merged); + return ( + + + + + [viewMode === "cost" ? fmtCost(value) : viewMode === "cacheHit" ? fmtPct(value) : fmtTokens(value), name]} /> + {groupNames.map((name, i) => ( + + ))} + + ); + })() + ) : ( + + + + + + + + + + + + + + + + + + + { if (name === "tokens") return [fmtTokens(value), "Tokens"]; if (name === "cachedTokens") return [fmtTokens(value), "Cached Tokens"]; if (name === "cacheHitRatio") return [fmtPct(value), "Cache Hit %"]; if (name === "cost") return [fmtCost(value), "Cost"]; return [value, name]; - }} - /> - {viewMode === "tokens" && ( - - )} - {viewMode === "cached" && ( - <> - - - - )} - {viewMode === "cacheHit" && ( - - )} - {viewMode === "cost" && ( - - )} - + }} /> + {viewMode === "tokens" && ( + + )} + {viewMode === "cached" && ( + <> + + + + )} + {viewMode === "cacheHit" && ( + + )} + {viewMode === "cost" && ( + + )} + + )} )}
diff --git a/src/app/api/usage/chart/route.js b/src/app/api/usage/chart/route.js index 063cedd60e..89b57c8815 100644 --- a/src/app/api/usage/chart/route.js +++ b/src/app/api/usage/chart/route.js @@ -7,12 +7,13 @@ export async function GET(request) { try { const { searchParams } = new URL(request.url); const period = searchParams.get("period") || "7d"; + const filterBy = searchParams.get("filterBy") || "all"; if (!VALID_PERIODS.has(period)) { return NextResponse.json({ error: "Invalid period" }, { status: 400 }); } - const data = await getChartData(period); + const data = await getChartData(period, filterBy); return NextResponse.json(data); } catch (error) { console.error("[API] Failed to get chart data:", error); diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index cc0e5535a9..c6b9b4b3d4 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -633,9 +633,12 @@ export async function getUsageStats(period = "all") { return stats; } -export async function getChartData(period = "7d") { +export async function getChartData(period = "7d", filterBy = "all") { const db = await getAdapter(); const now = Date.now(); + const isGrouped = filterBy !== "all"; + const filterColMap = { model: "model", account: "connectionId", apiKey: "apiKey", endpoint: "endpoint" }; + const filterCol = filterColMap[filterBy]; if (period === "today") { const bucketCount = 24; @@ -645,28 +648,51 @@ export async function getChartData(period = "7d") { const startTime = startOfDay.getTime(); const endTime = startTime + bucketCount * bucketMs; const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); - const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cachedTokens: 0, promptTokens: 0, cacheHitRatio: 0, cost: 0 })); + const selectCols = isGrouped ? `timestamp, promptTokens, completionTokens, cost, tokens, ${filterCol}` : "timestamp, promptTokens, completionTokens, cost, tokens"; const rows = db.all( - `SELECT timestamp, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`, + `SELECT ${selectCols} FROM usageHistory WHERE timestamp >= ?`, [new Date(startTime).toISOString()] ); + + if (!isGrouped) { + const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cachedTokens: 0, promptTokens: 0, cacheHitRatio: 0, cost: 0 })); + for (const r of rows) { + const t = new Date(r.timestamp).getTime(); + if (t < startTime || t >= endTime) continue; + const idx = Math.floor((t - startTime) / bucketMs); + if (idx >= 0 && idx < bucketCount) { + buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0); + buckets[idx].promptTokens += (r.promptTokens || 0); + const parsedTokens = parseJson(r.tokens, {}) || {}; + buckets[idx].cachedTokens += parsedTokens.cache_read_input_tokens || parsedTokens.cached_tokens || 0; + buckets[idx].cost += r.cost || 0; + } + } + for (const b of buckets) b.cacheHitRatio = b.promptTokens > 0 ? b.cachedTokens / b.promptTokens : 0; + return buckets; + } + + // Grouped mode + const groups = {}; for (const r of rows) { const t = new Date(r.timestamp).getTime(); if (t < startTime || t >= endTime) continue; const idx = Math.floor((t - startTime) / bucketMs); - if (idx >= 0 && idx < bucketCount) { - buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0); - buckets[idx].promptTokens += (r.promptTokens || 0); - const parsedTokens = parseJson(r.tokens, {}) || {}; - buckets[idx].cachedTokens += parsedTokens.cache_read_input_tokens || parsedTokens.cached_tokens || 0; - buckets[idx].cost += r.cost || 0; - } + if (idx < 0 || idx >= bucketCount) continue; + const gk = r[filterCol] || "unknown"; + if (!groups[gk]) groups[gk] = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cachedTokens: 0, promptTokens: 0, cacheHitRatio: 0, cost: 0 })); + const g = groups[gk][idx]; + g.tokens += (r.promptTokens || 0) + (r.completionTokens || 0); + g.promptTokens += (r.promptTokens || 0); + const parsedTokens = parseJson(r.tokens, {}) || {}; + g.cachedTokens += parsedTokens.cache_read_input_tokens || parsedTokens.cached_tokens || 0; + g.cost += r.cost || 0; } - for (const b of buckets) { - b.cacheHitRatio = b.promptTokens > 0 ? b.cachedTokens / b.promptTokens : 0; + for (const group of Object.values(groups)) { + for (const b of group) b.cacheHitRatio = b.promptTokens > 0 ? b.cachedTokens / b.promptTokens : 0; } - return buckets; + return { grouped: true, filterBy, groups }; } if (period === "24h") { @@ -674,55 +700,114 @@ export async function getChartData(period = "7d") { const bucketMs = 3600000; const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); const startTime = now - bucketCount * bucketMs; - const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cachedTokens: 0, promptTokens: 0, cacheHitRatio: 0, cost: 0 })); + const selectCols = isGrouped ? `timestamp, promptTokens, completionTokens, cost, tokens, ${filterCol}` : "timestamp, promptTokens, completionTokens, cost, tokens"; const rows = db.all( - `SELECT timestamp, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`, + `SELECT ${selectCols} FROM usageHistory WHERE timestamp >= ?`, [new Date(startTime).toISOString()] ); + + if (!isGrouped) { + const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cachedTokens: 0, promptTokens: 0, cacheHitRatio: 0, cost: 0 })); + for (const r of rows) { + const t = new Date(r.timestamp).getTime(); + if (t < startTime || t > now) continue; + const idx = Math.min(Math.floor((t - startTime) / bucketMs), bucketCount - 1); + buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0); + buckets[idx].promptTokens += (r.promptTokens || 0); + const parsedTokens = parseJson(r.tokens, {}) || {}; + buckets[idx].cachedTokens += parsedTokens.cache_read_input_tokens || parsedTokens.cached_tokens || 0; + buckets[idx].cost += r.cost || 0; + } + for (const b of buckets) b.cacheHitRatio = b.promptTokens > 0 ? b.cachedTokens / b.promptTokens : 0; + return buckets; + } + + // Grouped mode + const groups = {}; for (const r of rows) { const t = new Date(r.timestamp).getTime(); if (t < startTime || t > now) continue; const idx = Math.min(Math.floor((t - startTime) / bucketMs), bucketCount - 1); - buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0); - buckets[idx].promptTokens += (r.promptTokens || 0); + const gk = r[filterCol] || "unknown"; + if (!groups[gk]) groups[gk] = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cachedTokens: 0, promptTokens: 0, cacheHitRatio: 0, cost: 0 })); + const g = groups[gk][idx]; + g.tokens += (r.promptTokens || 0) + (r.completionTokens || 0); + g.promptTokens += (r.promptTokens || 0); const parsedTokens = parseJson(r.tokens, {}) || {}; - buckets[idx].cachedTokens += parsedTokens.cache_read_input_tokens || parsedTokens.cached_tokens || 0; - buckets[idx].cost += r.cost || 0; + g.cachedTokens += parsedTokens.cache_read_input_tokens || parsedTokens.cached_tokens || 0; + g.cost += r.cost || 0; } - for (const b of buckets) { - b.cacheHitRatio = b.promptTokens > 0 ? b.cachedTokens / b.promptTokens : 0; + for (const group of Object.values(groups)) { + for (const b of group) b.cacheHitRatio = b.promptTokens > 0 ? b.cachedTokens / b.promptTokens : 0; } - return buckets; + return { grouped: true, filterBy, groups }; } + // 7d / 30d / 60d const bucketCount = period === "7d" ? 7 : period === "30d" ? 30 : 60; const today = new Date(); const labelFn = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - // Build map of dateKey → day data - const dayRows = loadDaysInRange(db, bucketCount); - const dayMap = {}; - for (const r of dayRows) dayMap[r.dateKey] = parseJson(r.data, {}); - - return Array.from({ length: bucketCount }, (_, i) => { - const d = new Date(today); - d.setDate(d.getDate() - (bucketCount - 1 - i)); - const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - const dayData = dayMap[dateKey]; - const promptTokens = dayData ? (dayData.promptTokens || 0) : 0; - const cachedTokens = dayData ? (dayData.cacheReadTokens || 0) : 0; - return { - label: labelFn(d), - tokens: dayData ? (dayData.promptTokens || 0) + (dayData.completionTokens || 0) : 0, - cachedTokens, - promptTokens, - cacheHitRatio: promptTokens > 0 ? cachedTokens / promptTokens : 0, - cost: dayData ? (dayData.cost || 0) : 0, - }; - }); -} + if (!isGrouped) { + const dayRows = loadDaysInRange(db, bucketCount); + const dayMap = {}; + for (const r of dayRows) dayMap[r.dateKey] = parseJson(r.data, {}); + + return Array.from({ length: bucketCount }, (_, i) => { + const d = new Date(today); + d.setDate(d.getDate() - (bucketCount - 1 - i)); + const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const dayData = dayMap[dateKey]; + const promptTokens = dayData ? (dayData.promptTokens || 0) : 0; + const cachedTokens = dayData ? (dayData.cacheReadTokens || 0) : 0; + return { + label: labelFn(d), + tokens: dayData ? (dayData.promptTokens || 0) + (dayData.completionTokens || 0) : 0, + cachedTokens, + promptTokens, + cacheHitRatio: promptTokens > 0 ? cachedTokens / promptTokens : 0, + cost: dayData ? (dayData.cost || 0) : 0, + }; + }); + } + + // Grouped mode for 7d/30d/60d: query usageHistory directly + const startTime2 = new Date(today); + startTime2.setDate(today.getDate() - (bucketCount - 1)); + startTime2.setHours(0, 0, 0, 0); + const bucketMs2 = 86400000; + + const selectCols2 = `timestamp, promptTokens, completionTokens, cost, tokens, ${filterCol}`; + const rows2 = db.all( + `SELECT ${selectCols2} FROM usageHistory WHERE timestamp >= ?`, + [startTime2.toISOString()] + ); + const groups2 = {}; + for (const r of rows2) { + const t = new Date(r.timestamp).getTime(); + if (t < startTime2.getTime()) continue; + const idx = Math.min(Math.floor((t - startTime2.getTime()) / bucketMs2), bucketCount - 1); + if (idx < 0) continue; + const gk = r[filterCol] || "unknown"; + if (!groups2[gk]) groups2[gk] = Array.from({ length: bucketCount }, (_, i) => { + const d = new Date(today); + d.setDate(d.getDate() - (bucketCount - 1 - i)); + return { label: labelFn(d), tokens: 0, cachedTokens: 0, promptTokens: 0, cacheHitRatio: 0, cost: 0 }; + }); + const g = groups2[gk][idx]; + g.tokens += (r.promptTokens || 0) + (r.completionTokens || 0); + g.promptTokens += (r.promptTokens || 0); + const parsedTokens = parseJson(r.tokens, {}) || {}; + g.cachedTokens += parsedTokens.cache_read_input_tokens || parsedTokens.cached_tokens || 0; + g.cost += r.cost || 0; + } + for (const group of Object.values(groups2)) { + for (const b of group) b.cacheHitRatio = b.promptTokens > 0 ? b.cachedTokens / b.promptTokens : 0; + } + return { grouped: true, filterBy, groups: groups2 }; +} function formatLogDate(date = new Date()) { const pad = (n) => String(n).padStart(2, "0"); return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;