@oh-my-pi/omp-stats 17.2.14 → 17.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.
@@ -436,9 +436,6 @@
436
436
  .whitespace-nowrap {
437
437
  white-space: nowrap;
438
438
  }
439
- .text-\[var\(--accent-cyan\)\] {
440
- color: var(--accent-cyan);
441
- }
442
439
  .text-\[var\(--text-muted\)\] {
443
440
  color: var(--text-muted);
444
441
  }
@@ -947,7 +944,7 @@
947
944
  }
948
945
  .stats-metric-primary-grid {
949
946
  display: grid;
950
- grid-template-columns: repeat(4, 1fr);
947
+ grid-template-columns: repeat(5, minmax(0, 1fr));
951
948
  gap: 16px;
952
949
  }
953
950
  @media (max-width: 1023px) {
@@ -24,8 +24,13 @@ export interface AggregatedStats {
24
24
  totalCacheReadTokens: number;
25
25
  /** Total cache write tokens */
26
26
  totalCacheWriteTokens: number;
27
- /** Cache hit rate (0-1) */
27
+ /** Percentage of prompt input tokens served from cache (0-1). */
28
28
  cacheRate: number;
29
+ /**
30
+ * Prompt-input cost saved relative to billing the same tokens uncached
31
+ * (0-1; negative when cache writes cost more than reads save).
32
+ */
33
+ cacheSavings: number;
29
34
  /** Total cost */
30
35
  totalCost: number;
31
36
  /** Total premium requests */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "17.2.14",
4
+ "version": "17.3.0",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -39,9 +39,9 @@
39
39
  "fmt": "biome format --write ."
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "17.2.14",
43
- "@oh-my-pi/pi-catalog": "17.2.14",
44
- "@oh-my-pi/pi-utils": "17.2.14",
42
+ "@oh-my-pi/pi-ai": "17.3.0",
43
+ "@oh-my-pi/pi-catalog": "17.3.0",
44
+ "@oh-my-pi/pi-utils": "17.3.0",
45
45
  "@tailwindcss/node": "^4.3.2",
46
46
  "chart.js": "^4.5.1",
47
47
  "lucide-react": "^1.24.0",
@@ -322,7 +322,7 @@ function ModelsTable({
322
322
  <div className="grid gap-4" style={{ gridTemplateColumns: "200px 1fr" }}>
323
323
  <div className="space-y-4 text-sm">
324
324
  <div>
325
- <div className="text-[var(--text-primary)] font-medium mb-2">Quality</div>
325
+ <div className="text-[var(--text-primary)] font-medium mb-2">Efficiency</div>
326
326
  <div className="space-y-1 text-[var(--text-secondary)]">
327
327
  <div className="flex items-center justify-between">
328
328
  <span>Error rate</span>
@@ -336,8 +336,18 @@ function ModelsTable({
336
336
  </div>
337
337
  <div className="flex items-center justify-between">
338
338
  <span>Cache rate</span>
339
- <span className="text-[var(--accent-cyan)]">
340
- {(model.cacheRate * 100).toFixed(1)}%
339
+ <span className="font-mono">{(model.cacheRate * 100).toFixed(1)}%</span>
340
+ </div>
341
+ <div className="flex items-center justify-between">
342
+ <span>Cache savings</span>
343
+ <span
344
+ className={
345
+ model.cacheSavings < 0
346
+ ? "text-[var(--accent-red)]"
347
+ : "text-[var(--accent-green)]"
348
+ }
349
+ >
350
+ {(model.cacheSavings * 100).toFixed(1)}%
341
351
  </span>
342
352
  </div>
343
353
  </div>
@@ -87,8 +87,16 @@ export function ProjectsRoute({ active, range, refreshTrigger }: ProjectsRoutePr
87
87
  key: "cacheRate",
88
88
  header: "Cache Rate",
89
89
  numeric: true,
90
+ render: (item: FolderRowView) => <span className="font-mono">{formatPercent(item.cacheRate)}</span>,
91
+ },
92
+ {
93
+ key: "cacheSavings",
94
+ header: "Cache Savings",
95
+ numeric: true,
90
96
  render: (item: FolderRowView) => (
91
- <span className="stats-text-success font-medium">{formatPercent(item.cacheRate)}</span>
97
+ <span className={`${item.cacheSavings < 0 ? "stats-text-danger" : "stats-text-success"} font-medium`}>
98
+ {formatPercent(item.cacheSavings)}
99
+ </span>
92
100
  ),
93
101
  },
94
102
  {
@@ -129,9 +137,13 @@ export function ProjectsRoute({ active, range, refreshTrigger }: ProjectsRoutePr
129
137
  <div className="stats-mobile-card-value font-mono">{formatCost(item.totalCost)}</div>
130
138
  </div>
131
139
  <div>
132
- <div className="stats-mobile-card-label">Cache</div>
140
+ <div className="stats-mobile-card-label">Cache Rate</div>
133
141
  <div className="stats-mobile-card-value">{formatPercent(item.cacheRate)}</div>
134
142
  </div>
143
+ <div>
144
+ <div className="stats-mobile-card-label">Cache Savings</div>
145
+ <div className="stats-mobile-card-value">{formatPercent(item.cacheSavings)}</div>
146
+ </div>
135
147
  <div>
136
148
  <div className="stats-mobile-card-label">Duration</div>
137
149
  <div className="stats-mobile-card-value">{formatDurationMs(item.avgDuration)}</div>
@@ -563,7 +563,7 @@
563
563
 
564
564
  .stats-metric-primary-grid {
565
565
  display: grid;
566
- grid-template-columns: repeat(4, 1fr);
566
+ grid-template-columns: repeat(5, minmax(0, 1fr));
567
567
  gap: 16px;
568
568
  }
569
569
 
@@ -29,7 +29,17 @@ export function MetricCluster({ stats }: MetricClusterProps) {
29
29
  <div className="stats-metric-label">Requests</div>
30
30
  <div className="stats-metric-value">{formatInteger(stats.totalRequests)}</div>
31
31
  </div>
32
- <div className="stats-metric-card primary">
32
+ <div
33
+ className="stats-metric-card primary"
34
+ title="Prompt-input cost saved versus billing the same tokens uncached; cache writes can make this negative"
35
+ >
36
+ <div className="stats-metric-label">Cache Savings</div>
37
+ <div className="stats-metric-value">{formatPercent(stats.cacheSavings)}</div>
38
+ </div>
39
+ <div
40
+ className="stats-metric-card primary"
41
+ title="Prompt input served from cache: cache reads / (uncached input + cache reads)"
42
+ >
33
43
  <div className="stats-metric-label">Cache Rate</div>
34
44
  <div className="stats-metric-value">{formatPercent(stats.cacheRate)}</div>
35
45
  </div>
package/src/db.ts CHANGED
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import * as fs from "node:fs/promises";
3
3
  import type { Usage } from "@oh-my-pi/pi-ai";
4
4
  import type { GeneratedProvider } from "@oh-my-pi/pi-catalog/models";
5
- import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
5
+ import { calculateUncachedInputCost, getBundledModel } from "@oh-my-pi/pi-catalog/models";
6
6
  import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
7
7
  import { classifyAgentType } from "./parser";
8
8
  import type {
@@ -33,7 +33,7 @@ import type {
33
33
 
34
34
  type ModelCost = { input: number; output: number; cacheRead: number; cacheWrite: number };
35
35
  type UsageCost = Usage["cost"];
36
- type CostTokens = Pick<Usage, "input" | "output" | "cacheRead" | "cacheWrite">;
36
+ type CostTokens = Pick<Usage, "input" | "output" | "cacheRead" | "cacheWrite" | "orchestration">;
37
37
 
38
38
  const ZERO_USAGE_COST: UsageCost = {
39
39
  input: 0,
@@ -53,6 +53,42 @@ interface CostBackfillRow {
53
53
  cache_write_tokens: number;
54
54
  }
55
55
 
56
+ interface NoCacheInputCostBackfillRow {
57
+ id: number;
58
+ provider: string;
59
+ model: string;
60
+ input_tokens: number;
61
+ cache_read_tokens: number;
62
+ cache_write_tokens: number;
63
+ }
64
+
65
+ interface AggregatedStatsRow {
66
+ total_requests: number;
67
+ failed_requests: number | null;
68
+ total_input_tokens: number | null;
69
+ total_output_tokens: number | null;
70
+ total_cache_read_tokens: number | null;
71
+ total_cache_write_tokens: number | null;
72
+ total_premium_requests: number | null;
73
+ total_cost: number | null;
74
+ total_cached_prompt_cost: number | null;
75
+ total_no_cache_input_cost: number | null;
76
+ avg_duration: number | null;
77
+ avg_ttft: number | null;
78
+ avg_tokens_per_second: number | null;
79
+ first_timestamp: number | null;
80
+ last_timestamp: number | null;
81
+ }
82
+
83
+ interface ModelStatsRow extends AggregatedStatsRow {
84
+ model: string;
85
+ provider: string;
86
+ }
87
+
88
+ interface FolderStatsRow extends AggregatedStatsRow {
89
+ folder: string;
90
+ }
91
+
56
92
  let db: Database | null = null;
57
93
 
58
94
  const BACKFILL_COMPLETE = "complete";
@@ -112,6 +148,7 @@ export async function initDb(): Promise<Database> {
112
148
  cost_cache_read REAL NOT NULL,
113
149
  cost_cache_write REAL NOT NULL,
114
150
  cost_total REAL NOT NULL,
151
+ cost_no_cache_input REAL,
115
152
  agent_type TEXT NOT NULL DEFAULT 'main',
116
153
  UNIQUE(session_file, entry_id)
117
154
  );
@@ -183,6 +220,9 @@ export async function initDb(): Promise<Database> {
183
220
  if (!messageColumns.some(column => column.name === "premium_requests")) {
184
221
  db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
185
222
  }
223
+ if (!messageColumns.some(column => column.name === "cost_no_cache_input")) {
224
+ db.run("ALTER TABLE messages ADD COLUMN cost_no_cache_input REAL");
225
+ }
186
226
  db.run("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
187
227
  // Token-usage-by-agent: each message is classified main / subagent / advisor
188
228
  // from its transcript path. A brand-new table gets the column from CREATE
@@ -265,6 +305,7 @@ export async function initDb(): Promise<Database> {
265
305
  backfillPriorityPremiumRequests(db);
266
306
  backfillAgentType(db);
267
307
  backfillMissingCatalogCosts(db);
308
+ backfillNoCacheInputCosts(db);
268
309
  backfillForkDuplicates(db);
269
310
  return db;
270
311
  }
@@ -323,6 +364,18 @@ function resolveStoredCost(stats: MessageStats): UsageCost {
323
364
  return calculateCatalogCost(stats.provider, stats.model, stats.usage) ?? storedCost ?? ZERO_USAGE_COST;
324
365
  }
325
366
 
367
+ function calculateNoCacheInputCost(provider: string, modelId: string, tokens: CostTokens): number | null {
368
+ const cost = getCatalogCost(provider, modelId);
369
+ if (!cost) return null;
370
+ const promptInputTokens =
371
+ tokens.input +
372
+ tokens.cacheRead +
373
+ tokens.cacheWrite +
374
+ (tokens.orchestration?.input ?? 0) +
375
+ (tokens.orchestration?.cacheRead ?? 0);
376
+ return calculateUncachedInputCost(cost, promptInputTokens);
377
+ }
378
+
326
379
  function backfillMissingCatalogCosts(database: Database): void {
327
380
  const rows = database
328
381
  .prepare(`
@@ -358,6 +411,31 @@ function backfillMissingCatalogCosts(database: Database): void {
358
411
  applyBackfill();
359
412
  }
360
413
 
414
+ function backfillNoCacheInputCosts(database: Database): void {
415
+ const rows = database
416
+ .prepare(`
417
+ SELECT id, provider, model, input_tokens, cache_read_tokens, cache_write_tokens
418
+ FROM messages
419
+ WHERE cost_no_cache_input IS NULL
420
+ `)
421
+ .all() as NoCacheInputCostBackfillRow[];
422
+ if (rows.length === 0) return;
423
+
424
+ const update = database.prepare("UPDATE messages SET cost_no_cache_input = ? WHERE id = ?");
425
+ const applyBackfill = database.transaction(() => {
426
+ for (const row of rows) {
427
+ const cost = calculateNoCacheInputCost(row.provider, row.model, {
428
+ input: row.input_tokens,
429
+ output: 0,
430
+ cacheRead: row.cache_read_tokens,
431
+ cacheWrite: row.cache_write_tokens,
432
+ });
433
+ update.run(cost ?? 0, row.id);
434
+ }
435
+ });
436
+ applyBackfill();
437
+ }
438
+
361
439
  /**
362
440
  * Get the stored offset for a session file.
363
441
  */
@@ -406,9 +484,9 @@ export function insertMessageStats(stats: MessageStats[]): number {
406
484
  session_file, entry_id, folder, model, provider, api, timestamp,
407
485
  duration, ttft, stop_reason, error_message,
408
486
  input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
409
- cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, agent_type
487
+ cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, cost_no_cache_input, agent_type
410
488
  )
411
- SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
489
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
412
490
  WHERE NOT EXISTS (
413
491
  SELECT 1 FROM messages
414
492
  WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
@@ -422,6 +500,7 @@ export function insertMessageStats(stats: MessageStats[]): number {
422
500
  const insert = db.transaction(() => {
423
501
  for (const s of stats) {
424
502
  const cost = resolveStoredCost(s);
503
+ const noCacheInputCost = calculateNoCacheInputCost(s.provider, s.model, s.usage) ?? 0;
425
504
  const result = stmt.run(
426
505
  s.sessionFile,
427
506
  s.entryId,
@@ -445,6 +524,7 @@ export function insertMessageStats(stats: MessageStats[]): number {
445
524
  cost.cacheRead,
446
525
  cost.cacheWrite,
447
526
  cost.total,
527
+ noCacheInputCost,
448
528
  s.agentType,
449
529
  // `WHERE NOT EXISTS` binds: skip when a different session_file
450
530
  // already holds this (entry_id, timestamp).
@@ -463,7 +543,7 @@ export function insertMessageStats(stats: MessageStats[]): number {
463
543
  /**
464
544
  * Build aggregated stats from query results.
465
545
  */
466
- function buildAggregatedStats(rows: any[]): AggregatedStats {
546
+ function buildAggregatedStats(rows: AggregatedStatsRow[]): AggregatedStats {
467
547
  if (rows.length === 0) {
468
548
  return {
469
549
  totalRequests: 0,
@@ -475,6 +555,7 @@ function buildAggregatedStats(rows: any[]): AggregatedStats {
475
555
  totalCacheReadTokens: 0,
476
556
  totalCacheWriteTokens: 0,
477
557
  cacheRate: 0,
558
+ cacheSavings: 0,
478
559
  totalCost: 0,
479
560
  totalPremiumRequests: 0,
480
561
  avgDuration: null,
@@ -492,6 +573,8 @@ function buildAggregatedStats(rows: any[]): AggregatedStats {
492
573
  const totalInputTokens = row.total_input_tokens || 0;
493
574
  const totalCacheReadTokens = row.total_cache_read_tokens || 0;
494
575
  const totalPremiumRequests = row.total_premium_requests || 0;
576
+ const noCacheInputCost = row.total_no_cache_input_cost || 0;
577
+ const cachedPromptCost = row.total_cached_prompt_cost || 0;
495
578
 
496
579
  return {
497
580
  totalRequests,
@@ -506,6 +589,7 @@ function buildAggregatedStats(rows: any[]): AggregatedStats {
506
589
  totalInputTokens + totalCacheReadTokens > 0
507
590
  ? totalCacheReadTokens / (totalInputTokens + totalCacheReadTokens)
508
591
  : 0,
592
+ cacheSavings: noCacheInputCost > 0 ? (noCacheInputCost - cachedPromptCost) / noCacheInputCost : 0,
509
593
  totalCost: row.total_cost || 0,
510
594
  totalPremiumRequests,
511
595
  avgDuration: row.avg_duration,
@@ -533,6 +617,10 @@ export function getOverallStats(cutoff?: number): AggregatedStats {
533
617
  SUM(cache_write_tokens) as total_cache_write_tokens,
534
618
  SUM(premium_requests) as total_premium_requests,
535
619
  SUM(cost_total) as total_cost,
620
+ SUM(CASE WHEN cost_no_cache_input > 0
621
+ THEN cost_input + cost_cache_read + cost_cache_write
622
+ ELSE 0 END) as total_cached_prompt_cost,
623
+ SUM(cost_no_cache_input) as total_no_cache_input_cost,
536
624
  AVG(duration) as avg_duration,
537
625
  AVG(ttft) as avg_ttft,
538
626
  AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
@@ -543,7 +631,7 @@ export function getOverallStats(cutoff?: number): AggregatedStats {
543
631
  `);
544
632
 
545
633
  const rows = hasCutoff ? stmt.all(cutoff) : stmt.all();
546
- return buildAggregatedStats(rows as any[]);
634
+ return buildAggregatedStats(rows as AggregatedStatsRow[]);
547
635
  }
548
636
  /**
549
637
  * Get stats grouped by model.
@@ -564,6 +652,10 @@ export function getStatsByModel(cutoff?: number): ModelStats[] {
564
652
  SUM(cache_write_tokens) as total_cache_write_tokens,
565
653
  SUM(premium_requests) as total_premium_requests,
566
654
  SUM(cost_total) as total_cost,
655
+ SUM(CASE WHEN cost_no_cache_input > 0
656
+ THEN cost_input + cost_cache_read + cost_cache_write
657
+ ELSE 0 END) as total_cached_prompt_cost,
658
+ SUM(cost_no_cache_input) as total_no_cache_input_cost,
567
659
  AVG(duration) as avg_duration,
568
660
  AVG(ttft) as avg_ttft,
569
661
  AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
@@ -575,7 +667,7 @@ export function getStatsByModel(cutoff?: number): ModelStats[] {
575
667
  ORDER BY total_requests DESC
576
668
  `);
577
669
 
578
- const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
670
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as ModelStatsRow[];
579
671
  return rows.map(row => ({
580
672
  model: row.model,
581
673
  provider: row.provider,
@@ -601,6 +693,10 @@ export function getStatsByFolder(cutoff?: number): FolderStats[] {
601
693
  SUM(cache_write_tokens) as total_cache_write_tokens,
602
694
  SUM(premium_requests) as total_premium_requests,
603
695
  SUM(cost_total) as total_cost,
696
+ SUM(CASE WHEN cost_no_cache_input > 0
697
+ THEN cost_input + cost_cache_read + cost_cache_write
698
+ ELSE 0 END) as total_cached_prompt_cost,
699
+ SUM(cost_no_cache_input) as total_no_cache_input_cost,
604
700
  AVG(duration) as avg_duration,
605
701
  AVG(ttft) as avg_ttft,
606
702
  AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
@@ -612,7 +708,7 @@ export function getStatsByFolder(cutoff?: number): FolderStats[] {
612
708
  ORDER BY total_requests DESC
613
709
  `);
614
710
 
615
- const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
711
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as FolderStatsRow[];
616
712
  return rows.map(row => ({
617
713
  folder: row.folder,
618
714
  ...buildAggregatedStats([row]),
package/src/index.ts CHANGED
@@ -68,6 +68,7 @@ async function printStats(): Promise<void> {
68
68
  console.log(` Input Tokens: ${formatNumber(overall.totalInputTokens)}`);
69
69
  console.log(` Output Tokens: ${formatNumber(overall.totalOutputTokens)}`);
70
70
  console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`);
71
+ console.log(` Cache Savings: ${formatPercent(overall.cacheSavings)}`);
71
72
  console.log(` Total Cost: ${formatCost(overall.totalCost)}`);
72
73
  console.log(` Premium Requests: ${formatNumber(normalizePremiumRequests(overall.totalPremiumRequests ?? 0))}`);
73
74
  console.log(` Avg Duration: ${overall.avgDuration !== null ? formatDuration(overall.avgDuration) : "-"}`);
@@ -80,7 +81,7 @@ async function printStats(): Promise<void> {
80
81
  console.log("\nBy Model:");
81
82
  for (const m of byModel.slice(0, 10)) {
82
83
  console.log(
83
- ` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache`,
84
+ ` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache rate, ${formatPercent(m.cacheSavings)} cache savings`,
84
85
  );
85
86
  }
86
87
  }
@@ -25,8 +25,13 @@ export interface AggregatedStats {
25
25
  totalCacheReadTokens: number;
26
26
  /** Total cache write tokens */
27
27
  totalCacheWriteTokens: number;
28
- /** Cache hit rate (0-1) */
28
+ /** Percentage of prompt input tokens served from cache (0-1). */
29
29
  cacheRate: number;
30
+ /**
31
+ * Prompt-input cost saved relative to billing the same tokens uncached
32
+ * (0-1; negative when cache writes cost more than reads save).
33
+ */
34
+ cacheSavings: number;
30
35
  /** Total cost */
31
36
  totalCost: number;
32
37
  /** Total premium requests */