@oh-my-pi/omp-stats 17.1.1 → 17.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,492 @@
1
+ import { useMemo, useState } from "react";
2
+ import { Bar } from "react-chartjs-2";
3
+ import { getProviderDashboardStats } from "../api";
4
+ import {
5
+ barDatasetStyle,
6
+ buildSharedPlugins,
7
+ buildSharedScales,
8
+ buildTopNByModelSeries,
9
+ CHART_THEMES,
10
+ MODEL_COLORS,
11
+ styleDatasets,
12
+ } from "../components/chart-shared";
13
+ import {
14
+ formatCompact,
15
+ formatCost,
16
+ formatInteger,
17
+ formatPercent,
18
+ formatRelativeTime,
19
+ formatTokensPerSecond,
20
+ } from "../data/formatters";
21
+ import { useResource } from "../data/useResource";
22
+ import type {
23
+ ProviderAggregate,
24
+ ProviderDashboardStats,
25
+ ProviderHourlyPoint,
26
+ ProviderWindowInsight,
27
+ TimeRange,
28
+ UsageWindowSeries,
29
+ } from "../types";
30
+ import { AsyncBoundary, DataTable, type DataTableColumn, EmptyState, Panel, SegmentedControl } from "../ui";
31
+ import { useSystemTheme } from "../useSystemTheme";
32
+
33
+ export interface ProvidersRouteProps {
34
+ active: boolean;
35
+ range: TimeRange;
36
+ refreshTrigger: number;
37
+ }
38
+
39
+ export function ProvidersRoute({ active, range, refreshTrigger }: ProvidersRouteProps) {
40
+ const {
41
+ data: stats,
42
+ error,
43
+ loading,
44
+ } = useResource(["providers", range, refreshTrigger], signal => getProviderDashboardStats(range, signal), {
45
+ pollMs: 30000,
46
+ enabled: active,
47
+ });
48
+
49
+ return (
50
+ <div className="stats-route-container space-y-6">
51
+ <AsyncBoundary loading={loading} error={error} data={stats}>
52
+ {stats && (
53
+ <>
54
+ <ProviderTotalsPanel providers={stats.providers} />
55
+ <ProviderTrendPanel stats={stats} />
56
+ <PeakHoursPanel hourly={stats.hourly} providers={stats.providers} />
57
+ <WindowInsightsPanel insights={stats.windowInsights} />
58
+ <WindowUtilizationPanel usageSeries={stats.usageSeries} />
59
+ </>
60
+ )}
61
+ </AsyncBoundary>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Provider totals
68
+ // ---------------------------------------------------------------------------
69
+
70
+ function ProviderTotalsPanel({ providers }: { providers: ProviderAggregate[] }) {
71
+ const grandTotal = useMemo(() => providers.reduce((sum, p) => sum + p.totalTokens, 0), [providers]);
72
+
73
+ const columns: DataTableColumn<ProviderAggregate>[] = [
74
+ { key: "provider", header: "Provider", render: p => <span className="font-medium">{p.provider}</span> },
75
+ { key: "requests", header: "Requests", numeric: true, render: p => formatInteger(p.totalRequests) },
76
+ {
77
+ key: "errors",
78
+ header: "Error Rate",
79
+ numeric: true,
80
+ render: p => formatPercent(p.totalRequests > 0 ? p.failedRequests / p.totalRequests : 0),
81
+ },
82
+ { key: "models", header: "Models", numeric: true, render: p => formatInteger(p.models) },
83
+ {
84
+ key: "tokens",
85
+ header: "Tokens",
86
+ numeric: true,
87
+ render: p => (
88
+ <span
89
+ title={`Input ${formatCompact(p.totalInputTokens)} · Output ${formatCompact(p.totalOutputTokens)} · Cache read ${formatCompact(p.totalCacheReadTokens)} · Cache write ${formatCompact(p.totalCacheWriteTokens)}`}
90
+ >
91
+ {formatCompact(p.totalTokens)}
92
+ </span>
93
+ ),
94
+ },
95
+ {
96
+ key: "share",
97
+ header: "Share",
98
+ numeric: true,
99
+ render: p => formatPercent(grandTotal > 0 ? p.totalTokens / grandTotal : 0),
100
+ },
101
+ { key: "cost", header: "Cost", numeric: true, render: p => formatCost(p.totalCost) },
102
+ { key: "tps", header: "Tok/s", numeric: true, render: p => formatTokensPerSecond(p.avgTokensPerSecond) },
103
+ ];
104
+
105
+ return (
106
+ <Panel title="Provider Totals" subtitle="Token, request, and cost totals per provider over the active range">
107
+ <DataTable
108
+ columns={columns}
109
+ data={providers}
110
+ keyExtractor={p => p.provider}
111
+ emptyText="No requests recorded in this range"
112
+ />
113
+ </Panel>
114
+ );
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Token / cost trend by provider
119
+ // ---------------------------------------------------------------------------
120
+
121
+ function ProviderTrendPanel({ stats }: { stats: ProviderDashboardStats }) {
122
+ const [metric, setMetric] = useState<"tokens" | "cost">("tokens");
123
+ const theme = useSystemTheme();
124
+ const chartTheme = CHART_THEMES[theme];
125
+
126
+ // buildTopNByModelSeries keys on `model`; feed it the provider name so we
127
+ // get the same top-N + "Other" rollup without a parallel implementation.
128
+ const chartData = useMemo(() => {
129
+ const points = stats.series.map(p => ({ ...p, model: p.provider }));
130
+ return buildTopNByModelSeries<(typeof points)[number], { total: number }>(points, {
131
+ topN: 6,
132
+ rankWeight: p => (metric === "tokens" ? p.totalTokens : p.cost),
133
+ initBucket: () => ({ total: 0 }),
134
+ accumulate: (bucket, p) => {
135
+ bucket.total += metric === "tokens" ? p.totalTokens : p.cost;
136
+ },
137
+ bucketToValue: bucket => bucket.total,
138
+ });
139
+ }, [stats.series, metric]);
140
+
141
+ const formatValue = metric === "tokens" ? formatCompact : (v: number) => formatCost(v);
142
+ const options = useMemo(() => {
143
+ const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatValue });
144
+ return {
145
+ responsive: true,
146
+ maintainAspectRatio: false,
147
+ interaction: { mode: "index" as const, intersect: false },
148
+ plugins: buildSharedPlugins({
149
+ chartTheme,
150
+ showLegend: true,
151
+ defaultLabel: metric === "tokens" ? "Tokens" : "Cost",
152
+ formatValue,
153
+ footer: items => {
154
+ if (items.length < 2) return undefined;
155
+ const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
156
+ return `Total: ${formatValue(total)}`;
157
+ },
158
+ }),
159
+ scales: {
160
+ x: { ...sharedScaleBase, stacked: true },
161
+ y: { ...yScale, stacked: true },
162
+ },
163
+ };
164
+ }, [chartTheme, metric, formatValue]);
165
+
166
+ const data = useMemo(
167
+ () => ({
168
+ labels: chartData.labels,
169
+ datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
170
+ }),
171
+ [chartData],
172
+ );
173
+
174
+ return (
175
+ <Panel
176
+ title="Burn by Provider"
177
+ subtitle="Stacked token/cost burn per provider over time"
178
+ actions={
179
+ <SegmentedControl
180
+ options={[
181
+ { value: "tokens" as const, label: "Tokens" },
182
+ { value: "cost" as const, label: "Cost" },
183
+ ]}
184
+ value={metric}
185
+ onChange={setMetric}
186
+ />
187
+ }
188
+ >
189
+ <div className="h-[300px]">
190
+ {chartData.labels.length === 0 ? (
191
+ <EmptyState message="No provider activity in this range" />
192
+ ) : (
193
+ <Bar data={data} options={options} />
194
+ )}
195
+ </div>
196
+ </Panel>
197
+ );
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Peak burn hours
202
+ // ---------------------------------------------------------------------------
203
+
204
+ const ALL_PROVIDERS = "__all__";
205
+
206
+ function PeakHoursPanel({ hourly, providers }: { hourly: ProviderHourlyPoint[]; providers: ProviderAggregate[] }) {
207
+ const [provider, setProvider] = useState(ALL_PROVIDERS);
208
+ const theme = useSystemTheme();
209
+ const chartTheme = CHART_THEMES[theme];
210
+
211
+ const { tokensByHour, peakHour } = useMemo(() => {
212
+ const tokens = new Array<number>(24).fill(0);
213
+ for (const point of hourly) {
214
+ if (provider !== ALL_PROVIDERS && point.provider !== provider) continue;
215
+ tokens[point.hour] += point.totalTokens;
216
+ }
217
+ let peak = 0;
218
+ for (let hour = 1; hour < 24; hour++) {
219
+ if (tokens[hour] > tokens[peak]) peak = hour;
220
+ }
221
+ return { tokensByHour: tokens, peakHour: peak };
222
+ }, [hourly, provider]);
223
+
224
+ const hasData = tokensByHour.some(v => v > 0);
225
+
226
+ const data = useMemo(
227
+ () => ({
228
+ labels: Array.from({ length: 24 }, (_, hour) => `${String(hour).padStart(2, "0")}:00`),
229
+ datasets: [
230
+ {
231
+ label: "Tokens",
232
+ data: tokensByHour,
233
+ ...barDatasetStyle(MODEL_COLORS[2]),
234
+ // Highlight the peak hour in the brand accent color.
235
+ backgroundColor: tokensByHour.map((_, hour) => (hour === peakHour ? MODEL_COLORS[0] : MODEL_COLORS[2])),
236
+ },
237
+ ],
238
+ }),
239
+ [tokensByHour, peakHour],
240
+ );
241
+
242
+ const options = useMemo(() => {
243
+ const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatCompact });
244
+ return {
245
+ responsive: true,
246
+ maintainAspectRatio: false,
247
+ plugins: buildSharedPlugins({
248
+ chartTheme,
249
+ showLegend: false,
250
+ defaultLabel: "Tokens",
251
+ formatValue: formatCompact,
252
+ }),
253
+ scales: { x: sharedScaleBase, y: yScale },
254
+ };
255
+ }, [chartTheme]);
256
+
257
+ return (
258
+ <Panel
259
+ title="Peak Burn Hours"
260
+ subtitle={
261
+ hasData
262
+ ? `Token burn by local hour of day — peak at ${String(peakHour).padStart(2, "0")}:00`
263
+ : "Token burn by local hour of day"
264
+ }
265
+ actions={
266
+ <select
267
+ className="stats-select"
268
+ value={provider}
269
+ onChange={e => setProvider(e.target.value)}
270
+ aria-label="Provider"
271
+ >
272
+ <option value={ALL_PROVIDERS}>All providers</option>
273
+ {providers.map(p => (
274
+ <option key={p.provider} value={p.provider}>
275
+ {p.provider}
276
+ </option>
277
+ ))}
278
+ </select>
279
+ }
280
+ >
281
+ <div className="h-[260px]">
282
+ {hasData ? <Bar data={data} options={options} /> : <EmptyState message="No activity in this range" />}
283
+ </div>
284
+ </Panel>
285
+ );
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Subscription window insights
290
+ // ---------------------------------------------------------------------------
291
+
292
+ function WindowInsightsPanel({ insights }: { insights: ProviderWindowInsight[] }) {
293
+ const columns: DataTableColumn<ProviderWindowInsight>[] = [
294
+ { key: "provider", header: "Provider", render: i => <span className="font-medium">{i.provider}</span> },
295
+ { key: "window", header: "Window", render: i => i.windowLabel },
296
+ { key: "accounts", header: "Accounts", numeric: true, render: i => formatInteger(i.accounts) },
297
+ {
298
+ key: "consumed",
299
+ header: "Windows Burned",
300
+ numeric: true,
301
+ render: i => (
302
+ <span title="Subscription-window equivalents consumed in range (sum of used-fraction increases across accounts)">
303
+ {i.fractionConsumed.toFixed(2)}
304
+ </span>
305
+ ),
306
+ },
307
+ {
308
+ key: "capacity",
309
+ header: "Est. Tokens / Window",
310
+ numeric: true,
311
+ render: i => (
312
+ <span title="Provider tokens burned in range ÷ windows burned — what one full window is worth">
313
+ {i.estTokensPerWindow !== null ? formatCompact(i.estTokensPerWindow) : "—"}
314
+ </span>
315
+ ),
316
+ },
317
+ {
318
+ key: "peak",
319
+ header: "Peak Utilization",
320
+ numeric: true,
321
+ render: i => (
322
+ <span title="Peak of summed used fraction across accounts at any sampled instant">
323
+ {formatPercent(i.peakConcurrentFraction)}
324
+ </span>
325
+ ),
326
+ },
327
+ {
328
+ key: "ideal",
329
+ header: "Ideal Accounts",
330
+ numeric: true,
331
+ render: i => (
332
+ <span
333
+ title="Accounts needed to keep peak demand under 90% of fleet capacity"
334
+ className={i.idealAccounts > i.accounts ? "stats-text-warning font-semibold" : undefined}
335
+ >
336
+ {formatInteger(i.idealAccounts)}
337
+ {i.idealAccounts > i.accounts ? ` (have ${i.accounts})` : ""}
338
+ </span>
339
+ ),
340
+ },
341
+ {
342
+ key: "exhausted",
343
+ header: "Exhaustions",
344
+ numeric: true,
345
+ render: i => (
346
+ <span className={i.exhaustedEvents > 0 ? "stats-text-warning" : undefined}>
347
+ {formatInteger(i.exhaustedEvents)}
348
+ </span>
349
+ ),
350
+ },
351
+ ];
352
+
353
+ return (
354
+ <Panel
355
+ title="Subscription Windows"
356
+ subtitle="What each usage window buys you, and how many accounts peak demand needs"
357
+ >
358
+ <DataTable
359
+ columns={columns}
360
+ data={insights}
361
+ keyExtractor={i => `${i.provider}::${i.windowKey}`}
362
+ emptyText="No usage snapshots recorded yet — they accumulate whenever usage is fetched (TUI footer, /usage, omp usage)"
363
+ />
364
+ </Panel>
365
+ );
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Window utilization
370
+ // ---------------------------------------------------------------------------
371
+
372
+ const UTILIZATION_COLORS = {
373
+ ok: "#62d394",
374
+ warning: "#f5c14b",
375
+ exhausted: "#ff6b7d",
376
+ } as const;
377
+
378
+ function WindowUtilizationPanel({ usageSeries }: { usageSeries: UsageWindowSeries[] }) {
379
+ const providers = useMemo(() => [...new Set(usageSeries.map(s => s.provider))], [usageSeries]);
380
+ const [selected, setSelected] = useState<string | null>(null);
381
+ const provider = selected !== null && providers.includes(selected) ? selected : (providers[0] ?? null);
382
+ const theme = useSystemTheme();
383
+ const chartTheme = CHART_THEMES[theme];
384
+
385
+ // One row per (window, account): the latest recorded fraction. Snapshot
386
+ // history is bursty (rows appear whenever usage is fetched), so a "how full
387
+ // is each window right now" bar reads far better than a time axis.
388
+ const rows = useMemo(() => {
389
+ return usageSeries
390
+ .filter(s => s.provider === provider)
391
+ .map(s => {
392
+ const latest = [...s.points].reverse().find(p => p.usedFraction !== null);
393
+ return latest
394
+ ? {
395
+ label: `${s.windowLabel} · ${s.accountLabel}`,
396
+ fraction: latest.usedFraction ?? 0,
397
+ exhausted: latest.exhausted,
398
+ recordedAt: latest.timestamp,
399
+ }
400
+ : null;
401
+ })
402
+ .filter(row => row !== null)
403
+ .sort((a, b) => b.fraction - a.fraction);
404
+ }, [usageSeries, provider]);
405
+
406
+ const data = useMemo(
407
+ () => ({
408
+ labels: rows.map(r => r.label),
409
+ datasets: [
410
+ {
411
+ label: "Used",
412
+ data: rows.map(r => r.fraction * 100),
413
+ backgroundColor: rows.map(r =>
414
+ r.exhausted
415
+ ? UTILIZATION_COLORS.exhausted
416
+ : r.fraction >= 0.8
417
+ ? UTILIZATION_COLORS.warning
418
+ : UTILIZATION_COLORS.ok,
419
+ ),
420
+ borderWidth: 0,
421
+ borderRadius: 4,
422
+ barThickness: 18,
423
+ },
424
+ ],
425
+ }),
426
+ [rows],
427
+ );
428
+
429
+ const options = useMemo(() => {
430
+ const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: v => `${Math.round(v)}%` });
431
+ const xMax = Math.max(100, ...rows.map(r => r.fraction * 100));
432
+ const shared = buildSharedPlugins({
433
+ chartTheme,
434
+ showLegend: false,
435
+ defaultLabel: "Used",
436
+ formatValue: v => `${v.toFixed(1)}%`,
437
+ });
438
+ return {
439
+ indexAxis: "y" as const,
440
+ responsive: true,
441
+ maintainAspectRatio: false,
442
+ plugins: {
443
+ ...shared,
444
+ tooltip: {
445
+ ...shared.tooltip,
446
+ callbacks: {
447
+ label: (ctx: { dataIndex: number; parsed: { x: number | null } }) => {
448
+ const row = rows[ctx.dataIndex];
449
+ const used = `${(ctx.parsed.x ?? 0).toFixed(1)}% used`;
450
+ return row ? `${used} · recorded ${formatRelativeTime(row.recordedAt)}` : used;
451
+ },
452
+ },
453
+ },
454
+ },
455
+ scales: {
456
+ x: { ...yScale, max: xMax },
457
+ y: { ...sharedScaleBase, grid: { display: false } },
458
+ },
459
+ };
460
+ }, [chartTheme, rows]);
461
+
462
+ return (
463
+ <Panel
464
+ title="Window Utilization"
465
+ subtitle="Latest recorded limit utilization per account and window — red bars are exhausted, amber above 80%"
466
+ actions={
467
+ providers.length > 1 ? (
468
+ <select
469
+ className="stats-select"
470
+ value={provider ?? ""}
471
+ onChange={e => setSelected(e.target.value)}
472
+ aria-label="Provider"
473
+ >
474
+ {providers.map(p => (
475
+ <option key={p} value={p}>
476
+ {p}
477
+ </option>
478
+ ))}
479
+ </select>
480
+ ) : undefined
481
+ }
482
+ >
483
+ <div style={{ height: Math.max(160, rows.length * 34 + 60) }}>
484
+ {rows.length === 0 ? (
485
+ <EmptyState message="No usage snapshots recorded yet — they accumulate whenever usage is fetched" />
486
+ ) : (
487
+ <Bar data={data} options={options} />
488
+ )}
489
+ </div>
490
+ </Panel>
491
+ );
492
+ }
@@ -5,5 +5,6 @@ export * from "./GainRoute";
5
5
  export * from "./ModelsRoute";
6
6
  export * from "./OverviewRoute";
7
7
  export * from "./ProjectsRoute";
8
+ export * from "./ProvidersRoute";
8
9
  export * from "./RequestsRoute";
9
10
  export * from "./ToolsRoute";
@@ -1172,6 +1172,7 @@
1172
1172
  .stats-text-primary { color: var(--text); }
1173
1173
  .stats-text-secondary { color: var(--muted); }
1174
1174
  .stats-text-muted { color: var(--dim); }
1175
+ .stats-text-warning { color: var(--warning); }
1175
1176
  .stats-text-xs { font-size: 12px; }
1176
1177
  .stats-font-medium { font-weight: 500; }
1177
1178
  .stats-font-semibold { font-weight: 600; }
package/src/db.ts CHANGED
@@ -18,6 +18,9 @@ import type {
18
18
  ModelPerformancePoint,
19
19
  ModelStats,
20
20
  ModelTimeSeriesPoint,
21
+ ProviderAggregate,
22
+ ProviderHourlyPoint,
23
+ ProviderTimeSeriesPoint,
21
24
  TimeSeriesPoint,
22
25
  ToolCallStats,
23
26
  ToolModelStats,
@@ -723,6 +726,144 @@ export function getModelTimeSeries(
723
726
  }));
724
727
  }
725
728
 
729
+ /**
730
+ * Get request/token/cost totals grouped by provider.
731
+ */
732
+ export function getStatsByProvider(cutoff?: number | null): ProviderAggregate[] {
733
+ if (!db) return [];
734
+
735
+ const hasCutoff = cutoff !== undefined && cutoff !== null && cutoff > 0;
736
+ const stmt = db.prepare(`
737
+ SELECT
738
+ provider,
739
+ COUNT(*) as total_requests,
740
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
741
+ COUNT(DISTINCT model) as models,
742
+ SUM(input_tokens) as total_input_tokens,
743
+ SUM(output_tokens) as total_output_tokens,
744
+ SUM(cache_read_tokens) as total_cache_read_tokens,
745
+ SUM(cache_write_tokens) as total_cache_write_tokens,
746
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
747
+ SUM(cost_total) as total_cost,
748
+ SUM(premium_requests) as total_premium_requests,
749
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
750
+ FROM messages
751
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
752
+ GROUP BY provider
753
+ ORDER BY total_tokens DESC
754
+ `);
755
+
756
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as Array<{
757
+ provider: string;
758
+ total_requests: number;
759
+ failed_requests: number;
760
+ models: number;
761
+ total_input_tokens: number | null;
762
+ total_output_tokens: number | null;
763
+ total_cache_read_tokens: number | null;
764
+ total_cache_write_tokens: number | null;
765
+ total_tokens: number | null;
766
+ total_cost: number | null;
767
+ total_premium_requests: number | null;
768
+ avg_tokens_per_second: number | null;
769
+ }>;
770
+ return rows.map(row => ({
771
+ provider: row.provider,
772
+ totalRequests: row.total_requests,
773
+ failedRequests: row.failed_requests,
774
+ models: row.models,
775
+ totalInputTokens: row.total_input_tokens ?? 0,
776
+ totalOutputTokens: row.total_output_tokens ?? 0,
777
+ totalCacheReadTokens: row.total_cache_read_tokens ?? 0,
778
+ totalCacheWriteTokens: row.total_cache_write_tokens ?? 0,
779
+ totalTokens: row.total_tokens ?? 0,
780
+ totalCost: row.total_cost ?? 0,
781
+ totalPremiumRequests: row.total_premium_requests ?? 0,
782
+ avgTokensPerSecond: row.avg_tokens_per_second,
783
+ }));
784
+ }
785
+
786
+ /**
787
+ * Get token burn grouped by provider and local hour of day (0-23).
788
+ * Hours use the server's timezone — the dashboard is a localhost tool, so
789
+ * server-local and viewer-local time coincide.
790
+ */
791
+ export function getProviderHourlyBurn(cutoff?: number | null): ProviderHourlyPoint[] {
792
+ if (!db) return [];
793
+
794
+ const hasCutoff = cutoff !== undefined && cutoff !== null && cutoff > 0;
795
+ const stmt = db.prepare(`
796
+ SELECT
797
+ provider,
798
+ CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER) as hour,
799
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
800
+ SUM(output_tokens) as output_tokens,
801
+ COUNT(*) as requests
802
+ FROM messages
803
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
804
+ GROUP BY provider, hour
805
+ ORDER BY provider, hour
806
+ `);
807
+
808
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as Array<{
809
+ provider: string;
810
+ hour: number;
811
+ total_tokens: number | null;
812
+ output_tokens: number | null;
813
+ requests: number;
814
+ }>;
815
+ return rows.map(row => ({
816
+ provider: row.provider,
817
+ hour: row.hour,
818
+ totalTokens: row.total_tokens ?? 0,
819
+ outputTokens: row.output_tokens ?? 0,
820
+ requests: row.requests,
821
+ }));
822
+ }
823
+
824
+ /**
825
+ * Get token/cost time series grouped by provider (bucketed like the model series).
826
+ */
827
+ export function getProviderTimeSeries(
828
+ days = 14,
829
+ cutoff?: number | null,
830
+ bucketMs = 24 * 60 * 60 * 1000,
831
+ ): ProviderTimeSeriesPoint[] {
832
+ if (!db) return [];
833
+
834
+ const hasCutoff = cutoff !== null;
835
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
836
+
837
+ const stmt = db.prepare(`
838
+ SELECT
839
+ (timestamp / ?) * ? as bucket,
840
+ provider,
841
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
842
+ SUM(cost_total) as cost,
843
+ COUNT(*) as requests
844
+ FROM messages
845
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
846
+ GROUP BY bucket, provider
847
+ ORDER BY bucket ASC
848
+ `);
849
+
850
+ const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
851
+ const rows = rowsRaw as Array<{
852
+ bucket: number;
853
+ provider: string;
854
+ total_tokens: number | null;
855
+ cost: number | null;
856
+ requests: number;
857
+ }>;
858
+ return rows.map(row => ({
859
+ timestamp: row.bucket,
860
+ provider: row.provider,
861
+ totalTokens: row.total_tokens ?? 0,
862
+ cost: row.cost ?? 0,
863
+ requests: row.requests,
864
+ }));
865
+ }
866
+
726
867
  /**
727
868
  * Get daily model performance time series data for the last N days.
728
869
  */
package/src/server.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  getDashboardStats,
11
11
  getModelDashboardStats,
12
12
  getOverviewStats,
13
+ getProviderDashboardStats,
13
14
  getRecentErrors,
14
15
  getRecentRequests,
15
16
  getRequestDetails,
@@ -222,6 +223,11 @@ export async function handleApi(req: Request): Promise<Response> {
222
223
  return Response.json(stats);
223
224
  }
224
225
 
226
+ if (path === "/api/stats/providers") {
227
+ const stats = await getProviderDashboardStats(range);
228
+ return Response.json(stats);
229
+ }
230
+
225
231
  if (path === "/api/stats/recent") {
226
232
  const limit = url.searchParams.get("limit");
227
233
  const stats = await getRecentRequests(limit ? parseInt(limit, 10) : undefined);