@oh-my-pi/omp-stats 14.2.0 → 14.3.0

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "14.2.0",
4
+ "version": "14.3.0",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -37,22 +37,22 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-ai": "14.2.0",
41
- "@oh-my-pi/pi-utils": "14.2.0",
42
- "@tailwindcss/node": "^4.2",
43
- "chart.js": "^4.5",
44
- "date-fns": "^4.1",
45
- "lucide-react": "^0.576",
46
- "react": "^19.2",
47
- "react-chartjs-2": "^5.3",
48
- "react-dom": "^19.2"
40
+ "@oh-my-pi/pi-ai": "14.3.0",
41
+ "@oh-my-pi/pi-utils": "14.3.0",
42
+ "@tailwindcss/node": "^4.2.4",
43
+ "chart.js": "^4.5.1",
44
+ "date-fns": "^4.1.0",
45
+ "lucide-react": "^1.11.0",
46
+ "react": "^19.2.5",
47
+ "react-chartjs-2": "^5.3.1",
48
+ "react-dom": "^19.2.5"
49
49
  },
50
50
  "devDependencies": {
51
- "@types/bun": "^1.3",
52
- "@types/react": "^19.2",
53
- "@types/react-dom": "^19.2",
54
- "postcss": "^8.5",
55
- "tailwindcss": "^4.2"
51
+ "@types/bun": "^1.3.13",
52
+ "@types/react": "^19.2.14",
53
+ "@types/react-dom": "^19.2.3",
54
+ "postcss": "^8.5.10",
55
+ "tailwindcss": "^4.2.4"
56
56
  },
57
57
  "engines": {
58
58
  "bun": ">=1.3.7"
package/src/aggregator.ts CHANGED
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import {
3
3
  getRecentErrors as dbGetRecentErrors,
4
4
  getRecentRequests as dbGetRecentRequests,
5
+ getCostTimeSeries,
5
6
  getFileOffset,
6
7
  getMessageById,
7
8
  getMessageCount,
@@ -88,9 +89,9 @@ export async function getDashboardStats(): Promise<DashboardStats> {
88
89
  timeSeries: getTimeSeries(24),
89
90
  modelSeries: getModelTimeSeries(14),
90
91
  modelPerformanceSeries: getModelPerformanceSeries(14),
92
+ costSeries: getCostTimeSeries(90),
91
93
  };
92
94
  }
93
-
94
95
  export async function getRecentRequests(limit?: number): Promise<MessageStats[]> {
95
96
  await initDb();
96
97
  return dbGetRecentRequests(limit);
@@ -1,6 +1,8 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { getRecentErrors, getRecentRequests, getStats, sync } from "./api";
3
3
  import { ChartsContainer } from "./components/ChartsContainer";
4
+ import { CostChart } from "./components/CostChart";
5
+ import { CostSummary } from "./components/CostSummary";
4
6
  import { Header } from "./components/Header";
5
7
  import { ModelsTable } from "./components/ModelsTable";
6
8
  import { RequestDetail } from "./components/RequestDetail";
@@ -8,7 +10,7 @@ import { RequestList } from "./components/RequestList";
8
10
  import { StatsGrid } from "./components/StatsGrid";
9
11
  import type { DashboardStats, MessageStats } from "./types";
10
12
 
11
- type Tab = "overview" | "requests" | "errors" | "models";
13
+ type Tab = "overview" | "requests" | "errors" | "models" | "costs";
12
14
 
13
15
  export default function App() {
14
16
  const [stats, setStats] = useState<DashboardStats | null>(null);
@@ -107,6 +109,13 @@ export default function App() {
107
109
  </div>
108
110
  )}
109
111
 
112
+ {activeTab === "costs" && (
113
+ <div className="space-y-6 animate-fade-in">
114
+ <CostSummary costSeries={stats.costSeries} />
115
+ <CostChart costSeries={stats.costSeries} />
116
+ </div>
117
+ )}
118
+
110
119
  {selectedRequest !== null && (
111
120
  <RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />
112
121
  )}
@@ -0,0 +1,365 @@
1
+ import {
2
+ BarElement,
3
+ CategoryScale,
4
+ Chart as ChartJS,
5
+ type ChartOptions,
6
+ Filler,
7
+ Legend,
8
+ LinearScale,
9
+ LineElement,
10
+ type Plugin,
11
+ PointElement,
12
+ Title,
13
+ Tooltip,
14
+ } from "chart.js";
15
+ import { format } from "date-fns";
16
+ import { useMemo, useState } from "react";
17
+ import { Bar, Line } from "react-chartjs-2";
18
+ import type { CostTimeSeriesPoint } from "../types";
19
+ import { useSystemTheme } from "../useSystemTheme";
20
+
21
+ ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler);
22
+
23
+ const MODEL_COLORS = [
24
+ "#a78bfa", // violet
25
+ "#22d3ee", // cyan
26
+ "#ec4899", // pink
27
+ "#4ade80", // green
28
+ "#fbbf24", // amber
29
+ "#f87171", // red
30
+ "#60a5fa", // blue
31
+ ];
32
+
33
+ const CHART_THEMES = {
34
+ dark: {
35
+ legendLabel: "#94a3b8",
36
+ tooltipBackground: "#16161e",
37
+ tooltipTitle: "#f8fafc",
38
+ tooltipBody: "#94a3b8",
39
+ tooltipBorder: "rgba(255, 255, 255, 0.1)",
40
+ grid: "rgba(255, 255, 255, 0.06)",
41
+ tick: "#64748b",
42
+ barLabel: "rgba(248, 250, 252, 0.7)",
43
+ },
44
+ light: {
45
+ legendLabel: "#475569",
46
+ tooltipBackground: "#ffffff",
47
+ tooltipTitle: "#0f172a",
48
+ tooltipBody: "#334155",
49
+ tooltipBorder: "rgba(15, 23, 42, 0.18)",
50
+ grid: "rgba(15, 23, 42, 0.08)",
51
+ tick: "#64748b",
52
+ barLabel: "rgba(15, 23, 42, 0.6)",
53
+ },
54
+ } as const;
55
+
56
+ const RANGE_OPTIONS = [14, 30, 90] as const;
57
+ type RangeDays = (typeof RANGE_OPTIONS)[number];
58
+
59
+ interface CostChartProps {
60
+ costSeries: CostTimeSeriesPoint[];
61
+ }
62
+
63
+ /** Inline Chart.js plugin — draws cost value centered above each bar. */
64
+ function makeBarLabelPlugin(color: string): Plugin<"bar"> {
65
+ return {
66
+ id: "costBarLabels",
67
+ afterDatasetsDraw(chart) {
68
+ const { ctx } = chart;
69
+ const dataset = chart.data.datasets[0];
70
+ if (!dataset) return;
71
+ const meta = chart.getDatasetMeta(0);
72
+ ctx.save();
73
+ ctx.font = "11px system-ui, sans-serif";
74
+ ctx.fillStyle = color;
75
+ ctx.textAlign = "center";
76
+ ctx.textBaseline = "bottom";
77
+ for (const bar of meta.data) {
78
+ const value = (bar as unknown as { $context: { parsed: { y: number } } }).$context.parsed.y;
79
+ if (!value) continue;
80
+ const label = `$${Math.round(value)}`;
81
+ const { x, y } = bar.getProps(["x", "y"], true) as { x: number; y: number };
82
+ ctx.fillText(label, x, y - 3);
83
+ }
84
+ ctx.restore();
85
+ },
86
+ };
87
+ }
88
+
89
+ export function CostChart({ costSeries }: CostChartProps) {
90
+ const [byModel, setByModel] = useState(false);
91
+ const [days, setDays] = useState<RangeDays>(30);
92
+ const theme = useSystemTheme();
93
+ const chartTheme = CHART_THEMES[theme];
94
+
95
+ const cutoff = Date.now() - days * 86400000;
96
+ const filtered = useMemo(() => costSeries.filter(p => p.timestamp >= cutoff), [costSeries, cutoff]);
97
+
98
+ const chartData = useMemo(
99
+ () => (byModel ? buildByModelSeries(filtered) : buildAggregateSeries(filtered)),
100
+ [filtered, byModel],
101
+ );
102
+
103
+ const sharedPlugins = {
104
+ legend: {
105
+ display: byModel,
106
+ position: "top" as const,
107
+ align: "start" as const,
108
+ labels: {
109
+ color: chartTheme.legendLabel,
110
+ usePointStyle: true,
111
+ padding: 16,
112
+ font: { size: 12 },
113
+ boxWidth: 8,
114
+ },
115
+ },
116
+ tooltip: {
117
+ backgroundColor: chartTheme.tooltipBackground,
118
+ titleColor: chartTheme.tooltipTitle,
119
+ bodyColor: chartTheme.tooltipBody,
120
+ borderColor: chartTheme.tooltipBorder,
121
+ borderWidth: 1,
122
+ padding: 12,
123
+ cornerRadius: 8,
124
+ callbacks: {
125
+ label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => {
126
+ const label = context.dataset.label ?? "Cost";
127
+ const value = context.parsed.y ?? 0;
128
+ return `${label}: $${Math.round(value)}`;
129
+ },
130
+ footer: (items: { parsed: { y: number | null } }[]) => {
131
+ if (!byModel || items.length < 2) return undefined;
132
+ const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
133
+ return `Total: $${Math.round(total)}`;
134
+ },
135
+ },
136
+ },
137
+ };
138
+
139
+ const sharedScaleBase = {
140
+ grid: { color: chartTheme.grid, drawBorder: false },
141
+ ticks: { color: chartTheme.tick, font: { size: 11 } },
142
+ };
143
+
144
+ const yScale = {
145
+ ...sharedScaleBase,
146
+ ticks: {
147
+ ...sharedScaleBase.ticks,
148
+ callback: (value: number | string) => `$${Math.round(Number(value))}`,
149
+ },
150
+ min: 0,
151
+ };
152
+
153
+ if (byModel) {
154
+ const lineData = {
155
+ labels: chartData.labels,
156
+ datasets: chartData.datasets.map((ds, index) => ({
157
+ label: ds.label,
158
+ data: ds.data,
159
+ borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
160
+ backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`,
161
+ fill: true,
162
+ tension: 0,
163
+ pointRadius: 3,
164
+ pointHoverRadius: 4,
165
+ borderWidth: 2,
166
+ })),
167
+ };
168
+
169
+ const lineOptions: ChartOptions<"line"> = {
170
+ responsive: true,
171
+ maintainAspectRatio: false,
172
+ interaction: { mode: "index", intersect: false },
173
+ plugins: sharedPlugins,
174
+ scales: { x: sharedScaleBase, y: yScale },
175
+ };
176
+
177
+ return (
178
+ <ChartWrapper
179
+ byModel={byModel}
180
+ days={days}
181
+ onByModelChange={setByModel}
182
+ onDaysChange={setDays}
183
+ empty={chartData.labels.length === 0}
184
+ >
185
+ <Line data={lineData} options={lineOptions} />
186
+ </ChartWrapper>
187
+ );
188
+ }
189
+
190
+ const barData = {
191
+ labels: chartData.labels,
192
+ datasets: chartData.datasets.map((ds, index) => ({
193
+ label: ds.label,
194
+ data: ds.data,
195
+ backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length],
196
+ borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
197
+ borderWidth: 0,
198
+ borderRadius: 3,
199
+ })),
200
+ };
201
+
202
+ const barLabelPlugin = makeBarLabelPlugin(chartTheme.barLabel);
203
+
204
+ const barOptions: ChartOptions<"bar"> = {
205
+ responsive: true,
206
+ maintainAspectRatio: false,
207
+ interaction: { mode: "index", intersect: false },
208
+ plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"],
209
+ scales: {
210
+ x: { ...sharedScaleBase, stacked: true },
211
+ y: { ...yScale, stacked: true },
212
+ },
213
+ layout: { padding: { top: 24 } },
214
+ };
215
+
216
+ return (
217
+ <ChartWrapper
218
+ byModel={byModel}
219
+ days={days}
220
+ onByModelChange={setByModel}
221
+ onDaysChange={setDays}
222
+ empty={chartData.labels.length === 0}
223
+ >
224
+ <Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />
225
+ </ChartWrapper>
226
+ );
227
+ }
228
+
229
+ interface ChartWrapperProps {
230
+ byModel: boolean;
231
+ days: RangeDays;
232
+ onByModelChange: (v: boolean) => void;
233
+ onDaysChange: (v: RangeDays) => void;
234
+ empty: boolean;
235
+ children: React.ReactNode;
236
+ }
237
+
238
+ function ChartWrapper({ byModel, days, onByModelChange, onDaysChange, empty, children }: ChartWrapperProps) {
239
+ return (
240
+ <div className="surface overflow-hidden">
241
+ <div className="px-5 py-4 border-b border-[var(--border-subtle)] flex items-center justify-between gap-4 flex-wrap">
242
+ <div>
243
+ <h3 className="text-sm font-semibold text-[var(--text-primary)]">Daily Cost</h3>
244
+ <p className="text-xs text-[var(--text-muted)] mt-1">API spending over time</p>
245
+ </div>
246
+ <div className="flex items-center gap-2">
247
+ <div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
248
+ <button
249
+ type="button"
250
+ onClick={() => onByModelChange(false)}
251
+ className={`tab-btn text-xs ${!byModel ? "active" : ""}`}
252
+ >
253
+ All Models
254
+ </button>
255
+ <button
256
+ type="button"
257
+ onClick={() => onByModelChange(true)}
258
+ className={`tab-btn text-xs ${byModel ? "active" : ""}`}
259
+ >
260
+ By Model
261
+ </button>
262
+ </div>
263
+ <div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--radius-sm)] border-[var(--border-subtle)]">
264
+ {RANGE_OPTIONS.map(d => (
265
+ <button
266
+ key={d}
267
+ type="button"
268
+ onClick={() => onDaysChange(d)}
269
+ className={`tab-btn text-xs ${days === d ? "active" : ""}`}
270
+ >
271
+ {d}d
272
+ </button>
273
+ ))}
274
+ </div>
275
+ </div>
276
+ </div>
277
+ <div className="p-5 min-h-[320px]">
278
+ {empty ? (
279
+ <div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
280
+ No cost data available
281
+ </div>
282
+ ) : (
283
+ <div className="h-[280px]">{children}</div>
284
+ )}
285
+ </div>
286
+ </div>
287
+ );
288
+ }
289
+
290
+ interface ChartSeries {
291
+ labels: string[];
292
+ datasets: Array<{ label: string; data: number[] }>;
293
+ }
294
+
295
+ function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries {
296
+ if (points.length === 0) return { labels: [], datasets: [] };
297
+
298
+ const byDay = new Map<number, number>();
299
+ for (const point of points) {
300
+ byDay.set(point.timestamp, (byDay.get(point.timestamp) ?? 0) + point.cost);
301
+ }
302
+
303
+ const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]);
304
+ return {
305
+ labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")),
306
+ datasets: [{ label: "Cost", data: sorted.map(([, cost]) => cost) }],
307
+ };
308
+ }
309
+
310
+ function buildByModelSeries(points: CostTimeSeriesPoint[], topN = 5): ChartSeries {
311
+ if (points.length === 0) return { labels: [], datasets: [] };
312
+
313
+ // Rank models by total cost
314
+ const totals = new Map<string, { model: string; provider: string; total: number }>();
315
+ for (const point of points) {
316
+ const key = `${point.model}::${point.provider}`;
317
+ const existing = totals.get(key);
318
+ if (existing) {
319
+ existing.total += point.cost;
320
+ } else {
321
+ totals.set(key, { model: point.model, provider: point.provider, total: point.cost });
322
+ }
323
+ }
324
+
325
+ const sorted = [...totals.entries()].sort((a, b) => b[1].total - a[1].total);
326
+ const topEntries = sorted.slice(0, topN);
327
+ const topKeys = new Set(topEntries.map(([key]) => key));
328
+
329
+ // Disambiguate model labels when same model name appears from multiple providers
330
+ const modelCount = new Map<string, number>();
331
+ for (const [, { model }] of topEntries) {
332
+ modelCount.set(model, (modelCount.get(model) ?? 0) + 1);
333
+ }
334
+ const labelByKey = new Map<string, string>();
335
+ for (const [key, { model, provider }] of topEntries) {
336
+ labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model);
337
+ }
338
+
339
+ // Collect all day buckets
340
+ const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
341
+
342
+ // Build per-day, per-series totals
343
+ const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key);
344
+ const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`));
345
+ if (hasOther) seriesNames.push("Other");
346
+
347
+ const dayMap = new Map<number, Record<string, number>>();
348
+ for (const day of allDays) {
349
+ dayMap.set(day, {});
350
+ }
351
+ for (const point of points) {
352
+ const key = `${point.model}::${point.provider}`;
353
+ const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other";
354
+ const row = dayMap.get(point.timestamp)!;
355
+ row[label] = (row[label] ?? 0) + point.cost;
356
+ }
357
+
358
+ return {
359
+ labels: allDays.map(ts => format(new Date(ts), "MMM d")),
360
+ datasets: seriesNames.map(name => ({
361
+ label: name,
362
+ data: allDays.map(day => dayMap.get(day)?.[name] ?? 0),
363
+ })),
364
+ };
365
+ }
@@ -0,0 +1,92 @@
1
+ import { useMemo } from "react";
2
+ import type { CostTimeSeriesPoint } from "../types";
3
+
4
+ interface CostSummaryProps {
5
+ costSeries: CostTimeSeriesPoint[];
6
+ }
7
+
8
+ const SUMMARY_DAYS = 30;
9
+
10
+ function formatCost(value: number): string {
11
+ return `$${Math.round(value)}`;
12
+ }
13
+
14
+ export function CostSummary({ costSeries }: CostSummaryProps) {
15
+ const cutoff = Date.now() - SUMMARY_DAYS * 86400000;
16
+ const prevCutoff = cutoff - SUMMARY_DAYS * 86400000;
17
+
18
+ const current = useMemo(() => costSeries.filter(p => p.timestamp >= cutoff), [costSeries, cutoff]);
19
+ const previous = useMemo(
20
+ () => costSeries.filter(p => p.timestamp >= prevCutoff && p.timestamp < cutoff),
21
+ [costSeries, prevCutoff, cutoff],
22
+ );
23
+
24
+ const totalCost = current.reduce((sum, p) => sum + p.cost, 0);
25
+ const prevTotalCost = previous.reduce((sum, p) => sum + p.cost, 0);
26
+
27
+ const dayBuckets = new Set(current.map(p => p.timestamp)).size;
28
+ const avgDaily = dayBuckets > 0 ? totalCost / dayBuckets : 0;
29
+
30
+ // Most expensive model over current period
31
+ const modelTotals = new Map<string, number>();
32
+ for (const point of current) {
33
+ modelTotals.set(point.model, (modelTotals.get(point.model) ?? 0) + point.cost);
34
+ }
35
+ let topModel = "";
36
+ let topModelCost = 0;
37
+ for (const [model, cost] of modelTotals) {
38
+ if (cost > topModelCost) {
39
+ topModel = model;
40
+ topModelCost = cost;
41
+ }
42
+ }
43
+
44
+ const trend = prevTotalCost > 0 ? ((totalCost - prevTotalCost) / prevTotalCost) * 100 : null;
45
+
46
+ const cards = [
47
+ {
48
+ label: "Total (30d)",
49
+ value: formatCost(totalCost),
50
+ positive: null as boolean | null,
51
+ },
52
+ {
53
+ label: "Avg / day",
54
+ value: formatCost(avgDaily),
55
+ positive: null as boolean | null,
56
+ },
57
+ {
58
+ label: "Top model",
59
+ value: topModel || "—",
60
+ sub: topModel ? formatCost(topModelCost) : undefined,
61
+ positive: null as boolean | null,
62
+ },
63
+ {
64
+ label: "vs prev 30d",
65
+ value: trend !== null ? `${trend >= 0 ? "+" : ""}${Math.round(trend)}%` : "—",
66
+ sub: undefined as string | undefined,
67
+ positive: trend !== null ? trend <= 0 : null,
68
+ },
69
+ ];
70
+
71
+ return (
72
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
73
+ {cards.map(card => (
74
+ <div key={card.label} className="surface px-4 py-3">
75
+ <p className="text-xs text-[var(--text-muted)] mb-1">{card.label}</p>
76
+ <p
77
+ className={`text-lg font-semibold ${
78
+ card.positive === true
79
+ ? "text-[var(--accent-green,#4ade80)]"
80
+ : card.positive === false
81
+ ? "text-[var(--accent-pink)]"
82
+ : "text-[var(--text-primary)]"
83
+ }`}
84
+ >
85
+ {card.value}
86
+ </p>
87
+ {card.sub && <p className="text-xs text-[var(--text-muted)] mt-0.5">{card.sub}</p>}
88
+ </div>
89
+ ))}
90
+ </div>
91
+ );
92
+ }
@@ -1,6 +1,6 @@
1
1
  import { Activity, RefreshCw } from "lucide-react";
2
2
 
3
- type Tab = "overview" | "requests" | "errors" | "models";
3
+ type Tab = "overview" | "requests" | "errors" | "models" | "costs";
4
4
 
5
5
  interface HeaderProps {
6
6
  activeTab: Tab;
@@ -9,7 +9,7 @@ interface HeaderProps {
9
9
  syncing: boolean;
10
10
  }
11
11
 
12
- const tabs: Tab[] = ["overview", "requests", "errors", "models"];
12
+ const tabs: Tab[] = ["overview", "requests", "errors", "models", "costs"];
13
13
 
14
14
  export function Header({ activeTab, onTabChange, onSync, syncing }: HeaderProps) {
15
15
  return (
@@ -92,6 +92,18 @@ export interface ModelPerformancePoint {
92
92
  avgTokensPerSecond: number | null;
93
93
  }
94
94
 
95
+ export interface CostTimeSeriesPoint {
96
+ timestamp: number;
97
+ model: string;
98
+ provider: string;
99
+ cost: number;
100
+ costInput: number;
101
+ costOutput: number;
102
+ costCacheRead: number;
103
+ costCacheWrite: number;
104
+ requests: number;
105
+ }
106
+
95
107
  export interface DashboardStats {
96
108
  overall: AggregatedStats;
97
109
  byModel: ModelStats[];
@@ -99,4 +111,5 @@ export interface DashboardStats {
99
111
  timeSeries: TimeSeriesPoint[];
100
112
  modelSeries: ModelTimeSeriesPoint[];
101
113
  modelPerformanceSeries: ModelPerformancePoint[];
114
+ costSeries: CostTimeSeriesPoint[];
102
115
  }
package/src/db.ts CHANGED
@@ -3,6 +3,7 @@ import * as fs from "node:fs/promises";
3
3
  import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
4
4
  import type {
5
5
  AggregatedStats,
6
+ CostTimeSeriesPoint,
6
7
  FolderStats,
7
8
  MessageStats,
8
9
  ModelPerformancePoint,
@@ -480,3 +481,42 @@ export function getMessageById(id: number): MessageStats | null {
480
481
  const row = stmt.get(id);
481
482
  return row ? rowToMessageStats(row) : null;
482
483
  }
484
+
485
+ /**
486
+ * Get daily cost time series data for the last N days, broken down by model.
487
+ */
488
+ export function getCostTimeSeries(days = 90): CostTimeSeriesPoint[] {
489
+ if (!db) return [];
490
+
491
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
492
+
493
+ const stmt = db.prepare(`
494
+ SELECT
495
+ (timestamp / 86400000) * 86400000 as bucket,
496
+ model,
497
+ provider,
498
+ SUM(cost_total) as cost,
499
+ SUM(cost_input) as cost_input,
500
+ SUM(cost_output) as cost_output,
501
+ SUM(cost_cache_read) as cost_cache_read,
502
+ SUM(cost_cache_write) as cost_cache_write,
503
+ COUNT(*) as requests
504
+ FROM messages
505
+ WHERE timestamp >= ?
506
+ GROUP BY bucket, model, provider
507
+ ORDER BY bucket ASC
508
+ `);
509
+
510
+ const rows = stmt.all(cutoff) as any[];
511
+ return rows.map(row => ({
512
+ timestamp: row.bucket,
513
+ model: row.model,
514
+ provider: row.provider,
515
+ cost: row.cost,
516
+ costInput: row.cost_input,
517
+ costOutput: row.cost_output,
518
+ costCacheRead: row.cost_cache_read,
519
+ costCacheWrite: row.cost_cache_write,
520
+ requests: row.requests,
521
+ }));
522
+ }
package/src/types.ts CHANGED
@@ -140,6 +140,27 @@ export interface ModelPerformancePoint {
140
140
  avgTokensPerSecond: number | null;
141
141
  }
142
142
 
143
+ /**
144
+ * Cost time series data point (daily buckets).
145
+ */
146
+ export interface CostTimeSeriesPoint {
147
+ /** Bucket timestamp (start of day) */
148
+ timestamp: number;
149
+ /** Model name */
150
+ model: string;
151
+ /** Provider name */
152
+ provider: string;
153
+ /** Total cost for this bucket */
154
+ cost: number;
155
+ /** Cost breakdown */
156
+ costInput: number;
157
+ costOutput: number;
158
+ costCacheRead: number;
159
+ costCacheWrite: number;
160
+ /** Request count */
161
+ requests: number;
162
+ }
163
+
143
164
  /**
144
165
  * Overall dashboard stats.
145
166
  */
@@ -150,6 +171,7 @@ export interface DashboardStats {
150
171
  timeSeries: TimeSeriesPoint[];
151
172
  modelSeries: ModelTimeSeriesPoint[];
152
173
  modelPerformanceSeries: ModelPerformancePoint[];
174
+ costSeries: CostTimeSeriesPoint[];
153
175
  }
154
176
 
155
177
  /**