@oh-my-pi/omp-stats 18.1.16 → 18.1.18

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/src/db.ts CHANGED
@@ -21,6 +21,7 @@ import type {
21
21
  DailyActivityPoint,
22
22
  FolderStats,
23
23
  MessageStats,
24
+ MessageStatsInput,
24
25
  ModelPerformancePoint,
25
26
  ModelStats,
26
27
  ModelTimeSeriesPoint,
@@ -48,13 +49,35 @@ const ZERO_USAGE_COST: UsageCost = {
48
49
  total: 0,
49
50
  };
50
51
 
51
- const UNPRICED_XAI_OAUTH_SQL =
52
- "CASE WHEN provider = 'xai-oauth' AND total_tokens > 0 AND cost_total = 0 THEN 1 ELSE 0 END";
52
+ /**
53
+ * Predicate counting one stored request as "unpriced" — the public rate card
54
+ * has no charge for it, so its zero is unknown spend rather than free usage.
55
+ * Two shapes store that zero:
56
+ * - `xai-oauth` bills through the SuperGrok subscription, so ingestion
57
+ * deliberately records no per-request price;
58
+ * - `cost_unpriced = 1`, set by `insertMessageStats` when `resolveStoredCost`
59
+ * refuses to price a scheduled (time-based) card whose entry carried no
60
+ * recoverable request timestamp — the epoch would silently become the
61
+ * tariff. Such a row keeps its real tokens and its zero until a re-parse
62
+ * recovers the time.
63
+ *
64
+ * Nothing else sets the marker: an explicit recorded zero, a free flat card,
65
+ * and a model with no catalog card at all keep `cost_unpriced = 0` even at the
66
+ * parser's timestamp sentinel, because their zero is a real price.
67
+ * `prefix` qualifies the columns for queries that alias `messages`.
68
+ */
69
+ function unpricedRequestSql(prefix = ""): string {
70
+ return `CASE WHEN ${prefix}total_tokens > 0 AND ${prefix}cost_total = 0
71
+ AND (${prefix}provider = 'xai-oauth' OR ${prefix}cost_unpriced = 1) THEN 1 ELSE 0 END`;
72
+ }
73
+
74
+ const UNPRICED_REQUEST_SQL = unpricedRequestSql();
53
75
 
54
76
  interface CostBackfillRow {
55
77
  id: number;
56
78
  provider: string;
57
79
  model: string;
80
+ timestamp: number;
58
81
  input_tokens: number;
59
82
  output_tokens: number;
60
83
  cache_read_tokens: number;
@@ -65,6 +88,7 @@ interface NoCacheInputCostBackfillRow {
65
88
  id: number;
66
89
  provider: string;
67
90
  model: string;
91
+ timestamp: number;
68
92
  input_tokens: number;
69
93
  cache_read_tokens: number;
70
94
  cache_write_tokens: number;
@@ -127,6 +151,11 @@ const TOOL_CALLS_BACKFILL_KEY = "tool_calls_v1";
127
151
  // reach the inclusive 200K tier. A one-time full re-parse repairs them through
128
152
  // the cost-refreshing UPSERT in `insertMessageStats`.
129
153
  const COST_REINGEST_BACKFILL_KEY = "messages_cost_reingest_v1";
154
+ // The absence-aware `resolveStoredCost` and the `cost_unpriced` marker only
155
+ // reach already-ingested rows through a re-parse, and the reingest sentinel
156
+ // above is already spent for them — without this one, every historical row
157
+ // keeps `cost_unpriced = 0` and unknown scheduled spend reports as free.
158
+ const COST_UNPRICED_BACKFILL_KEY = "messages_cost_unpriced_v1";
130
159
  function shouldResetBackfill(value: string | undefined): boolean {
131
160
  return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
132
161
  }
@@ -177,6 +206,7 @@ export async function initDb(): Promise<Database> {
177
206
  cost_cache_write REAL NOT NULL,
178
207
  cost_total REAL NOT NULL,
179
208
  cost_no_cache_input REAL,
209
+ cost_unpriced INTEGER NOT NULL DEFAULT 0,
180
210
  agent_type TEXT NOT NULL DEFAULT 'main',
181
211
  UNIQUE(session_file, entry_id)
182
212
  );
@@ -251,6 +281,11 @@ export async function initDb(): Promise<Database> {
251
281
  if (!messageColumns.some(column => column.name === "cost_no_cache_input")) {
252
282
  db.run("ALTER TABLE messages ADD COLUMN cost_no_cache_input REAL");
253
283
  }
284
+ // Rows ingested before this column existed default to 0 (not unpriced), so
285
+ // their epoch-sentinel zeros read as free until a re-parse rewrites them.
286
+ if (!messageColumns.some(column => column.name === "cost_unpriced")) {
287
+ db.run("ALTER TABLE messages ADD COLUMN cost_unpriced INTEGER NOT NULL DEFAULT 0");
288
+ }
254
289
  db.run("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
255
290
  // Token-usage-by-agent: each message is classified main / subagent / advisor
256
291
  // from its transcript path. A brand-new table gets the column from CREATE
@@ -330,6 +365,7 @@ export async function initDb(): Promise<Database> {
330
365
  backfillUserMessages(db);
331
366
  backfillToolCalls(db);
332
367
  backfillReingestCosts(db);
368
+ backfillUnpricedCosts(db);
333
369
  repairUserMessageLinks(db);
334
370
  backfillPriorityPremiumRequests(db);
335
371
  backfillAgentType(db);
@@ -365,7 +401,17 @@ function getCatalogCost(provider: string, modelId: string): ModelCost | null {
365
401
  return null;
366
402
  }
367
403
 
368
- function calculateCatalogCost(provider: string, modelId: string, tokens: CostTokens): UsageCost | null {
404
+ /** Whether the catalog prices this model on a time-based (scheduled) card. */
405
+ export function isScheduledCatalogModel(provider: string, modelId: string): boolean {
406
+ return getCatalogCost(provider, modelId)?.timeBased != null;
407
+ }
408
+
409
+ function calculateCatalogCost(
410
+ provider: string,
411
+ modelId: string,
412
+ tokens: CostTokens,
413
+ timestamp: number,
414
+ ): UsageCost | null {
369
415
  const cost = getCatalogCost(provider, modelId);
370
416
  if (!cost) return null;
371
417
 
@@ -382,10 +428,10 @@ function calculateCatalogCost(provider: string, modelId: string, tokens: CostTok
382
428
  (orchestration?.cacheRead ?? 0),
383
429
  cost: { ...ZERO_USAGE_COST },
384
430
  };
385
- return calculateUsageCost(cost, usage);
431
+ return calculateUsageCost(cost, usage, timestamp);
386
432
  }
387
433
 
388
- function normalizeUsageCost(cost: UsageCost): UsageCost {
434
+ function normalizeUsageCost(cost: Partial<UsageCost>): UsageCost {
389
435
  const input = cost.input ?? 0;
390
436
  const output = cost.output ?? 0;
391
437
  const cacheRead = cost.cacheRead ?? 0;
@@ -394,37 +440,80 @@ function normalizeUsageCost(cost: UsageCost): UsageCost {
394
440
  return { input, output, cacheRead, cacheWrite, total };
395
441
  }
396
442
 
397
- function resolveStoredCost(stats: MessageStats): UsageCost {
398
- // `usage.cost` was optional in older session files, and legacy payloads
399
- // can carry a partially-populated cost object (e.g. only `total`). The
400
- // messages table declares every cost_* column as REAL NOT NULL, so any
401
- // missing field must be normalised here before binding into SQLite.
402
- const raw: UsageCost | undefined = stats.usage.cost;
443
+ /**
444
+ * The parser records no timestamp for an entry that carries neither a numeric
445
+ * message timestamp nor a parseable entry timestamp, leaving this sentinel.
446
+ * Pricing such a request from the clock would bill it as a 1970 request.
447
+ */
448
+ function hasRequestTimestamp(timestamp: number): boolean {
449
+ return Number.isFinite(timestamp) && timestamp > 0;
450
+ }
451
+
452
+ interface ResolvedCost {
453
+ cost: UsageCost;
454
+ /**
455
+ * The stored zero is unknown spend rather than a price: a scheduled card
456
+ * with no recoverable request timestamp to select a tariff from. Persisted
457
+ * as `cost_unpriced` so the aggregates do not have to infer it.
458
+ */
459
+ unpriced: boolean;
460
+ }
461
+
462
+ function resolveStoredCost(stats: MessageStatsInput): ResolvedCost {
463
+ // `usage.cost` is absent when the session entry recorded no price at all, and
464
+ // legacy payloads can carry a partially-populated cost object (e.g. only
465
+ // `total`). The messages table declares every cost_* column as REAL NOT NULL,
466
+ // so any missing field must be normalised here before binding into SQLite.
467
+ const raw: Partial<UsageCost> | undefined = stats.usage.cost;
403
468
  const storedCost = raw ? normalizeUsageCost(raw) : undefined;
469
+ const catalogCost = getCatalogCost(stats.provider, stats.model);
404
470
 
405
- // A missing total is derived from the stored components. An explicit zero
406
- // remains the sentinel for catalog-based correction.
407
- if (storedCost && (raw?.total ?? storedCost.total) !== 0) return storedCost;
471
+ // Scheduled prices are frozen per request, including explicitly free usage.
472
+ // Preserve legacy zero-cost subscription correction for unscheduled models.
473
+ if (storedCost && Number.isFinite(storedCost.total) && (storedCost.total !== 0 || catalogCost?.timeBased)) {
474
+ return { cost: storedCost, unpriced: false };
475
+ }
408
476
 
409
- return calculateCatalogCost(stats.provider, stats.model, stats.usage) ?? storedCost ?? ZERO_USAGE_COST;
477
+ // Without a request timestamp a scheduled card has no tariff to select, and
478
+ // the epoch would silently become one. Leave the request unpriced; a re-parse
479
+ // of the session file repairs it once the timestamp is recoverable.
480
+ if (!hasRequestTimestamp(stats.timestamp) && catalogCost?.timeBased) {
481
+ return { cost: storedCost ?? ZERO_USAGE_COST, unpriced: true };
482
+ }
483
+
484
+ return {
485
+ cost:
486
+ calculateCatalogCost(stats.provider, stats.model, stats.usage, stats.timestamp) ??
487
+ storedCost ??
488
+ ZERO_USAGE_COST,
489
+ unpriced: false,
490
+ };
410
491
  }
411
492
 
412
- function calculateNoCacheInputCost(provider: string, modelId: string, tokens: CostTokens): number | null {
493
+ function calculateNoCacheInputCost(
494
+ provider: string,
495
+ modelId: string,
496
+ tokens: CostTokens,
497
+ timestamp: number,
498
+ ): number | null {
413
499
  const cost = getCatalogCost(provider, modelId);
414
500
  if (!cost) return null;
501
+ // Mirrors `resolveStoredCost`: an unpriced scheduled request must not report
502
+ // its whole prompt as cache savings just because the clock could be read.
503
+ if (!hasRequestTimestamp(timestamp) && cost.timeBased) return null;
415
504
  const promptInputTokens =
416
505
  tokens.input +
417
506
  tokens.cacheRead +
418
507
  tokens.cacheWrite +
419
508
  (tokens.orchestration?.input ?? 0) +
420
509
  (tokens.orchestration?.cacheRead ?? 0);
421
- return calculateUncachedInputCost(cost, promptInputTokens);
510
+ return calculateUncachedInputCost(cost, promptInputTokens, timestamp);
422
511
  }
423
512
 
424
513
  function backfillMissingCatalogCosts(database: Database): void {
425
514
  const rows = database
426
515
  .prepare(`
427
- SELECT id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
516
+ SELECT id, provider, model, timestamp, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
428
517
  FROM messages
429
518
  WHERE cost_total = 0 AND total_tokens > 0
430
519
  `)
@@ -440,12 +529,24 @@ function backfillMissingCatalogCosts(database: Database): void {
440
529
 
441
530
  const applyBackfill = database.transaction(() => {
442
531
  for (const row of rows) {
443
- const cost = calculateCatalogCost(row.provider, row.model, {
444
- input: row.input_tokens,
445
- output: row.output_tokens,
446
- cacheRead: row.cache_read_tokens,
447
- cacheWrite: row.cache_write_tokens,
448
- });
532
+ // A stored zero cannot distinguish missing historical prices from an
533
+ // explicitly free request. Never reprice recorded scheduled usage: a
534
+ // zero ingested for a scheduled card is permanent, because this
535
+ // backfill skips those rows. Re-parsing the session file is the only
536
+ // repair, and it works only once the entry's absent `cost` reaches
537
+ // `resolveStoredCost` as absence rather than as a synthesized zero.
538
+ if (getCatalogCost(row.provider, row.model)?.timeBased) continue;
539
+ const cost = calculateCatalogCost(
540
+ row.provider,
541
+ row.model,
542
+ {
543
+ input: row.input_tokens,
544
+ output: row.output_tokens,
545
+ cacheRead: row.cache_read_tokens,
546
+ cacheWrite: row.cache_write_tokens,
547
+ },
548
+ row.timestamp,
549
+ );
449
550
 
450
551
  if (!cost || cost.total === 0) continue;
451
552
 
@@ -459,7 +560,7 @@ function backfillMissingCatalogCosts(database: Database): void {
459
560
  function backfillNoCacheInputCosts(database: Database): void {
460
561
  const rows = database
461
562
  .prepare(`
462
- SELECT id, provider, model, input_tokens, cache_read_tokens, cache_write_tokens
563
+ SELECT id, provider, model, timestamp, input_tokens, cache_read_tokens, cache_write_tokens
463
564
  FROM messages
464
565
  WHERE cost_no_cache_input IS NULL
465
566
  `)
@@ -469,12 +570,17 @@ function backfillNoCacheInputCosts(database: Database): void {
469
570
  const update = database.prepare("UPDATE messages SET cost_no_cache_input = ? WHERE id = ?");
470
571
  const applyBackfill = database.transaction(() => {
471
572
  for (const row of rows) {
472
- const cost = calculateNoCacheInputCost(row.provider, row.model, {
473
- input: row.input_tokens,
474
- output: 0,
475
- cacheRead: row.cache_read_tokens,
476
- cacheWrite: row.cache_write_tokens,
477
- });
573
+ const cost = calculateNoCacheInputCost(
574
+ row.provider,
575
+ row.model,
576
+ {
577
+ input: row.input_tokens,
578
+ output: 0,
579
+ cacheRead: row.cache_read_tokens,
580
+ cacheWrite: row.cache_write_tokens,
581
+ },
582
+ row.timestamp,
583
+ );
478
584
  update.run(cost ?? 0, row.id);
479
585
  }
480
586
  });
@@ -522,7 +628,7 @@ export function setFileOffset(sessionFile: string, offset: number, lastModified:
522
628
  * stored cost (orchestration-aware) and keeps `premium_requests` monotonic, so
523
629
  * a forced re-parse repairs historical `premium_requests` and cost fix-ups.
524
630
  */
525
- export function insertMessageStats(stats: MessageStats[]): number {
631
+ export function insertMessageStats(stats: MessageStatsInput[]): number {
526
632
  if (!db || stats.length === 0) return 0;
527
633
 
528
634
  const stmt = db.prepare(`
@@ -530,9 +636,10 @@ export function insertMessageStats(stats: MessageStats[]): number {
530
636
  session_file, entry_id, folder, model, provider, api, timestamp,
531
637
  duration, ttft, stop_reason, error_message,
532
638
  input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
533
- cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, cost_no_cache_input, agent_type
639
+ cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, cost_no_cache_input,
640
+ cost_unpriced, agent_type
534
641
  )
535
- SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
642
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
536
643
  WHERE NOT EXISTS (
537
644
  SELECT 1 FROM messages
538
645
  WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
@@ -544,14 +651,15 @@ export function insertMessageStats(stats: MessageStats[]): number {
544
651
  cost_cache_read = excluded.cost_cache_read,
545
652
  cost_cache_write = excluded.cost_cache_write,
546
653
  cost_total = excluded.cost_total,
547
- cost_no_cache_input = excluded.cost_no_cache_input
654
+ cost_no_cache_input = excluded.cost_no_cache_input,
655
+ cost_unpriced = excluded.cost_unpriced
548
656
  `);
549
657
 
550
658
  let inserted = 0;
551
659
  const insert = db.transaction(() => {
552
660
  for (const s of stats) {
553
- const cost = resolveStoredCost(s);
554
- const noCacheInputCost = calculateNoCacheInputCost(s.provider, s.model, s.usage) ?? 0;
661
+ const { cost, unpriced } = resolveStoredCost(s);
662
+ const noCacheInputCost = calculateNoCacheInputCost(s.provider, s.model, s.usage, s.timestamp) ?? 0;
555
663
  const result = stmt.run(
556
664
  s.sessionFile,
557
665
  s.entryId,
@@ -576,6 +684,7 @@ export function insertMessageStats(stats: MessageStats[]): number {
576
684
  cost.cacheWrite,
577
685
  cost.total,
578
686
  noCacheInputCost,
687
+ unpriced ? 1 : 0,
579
688
  s.agentType,
580
689
  // `WHERE NOT EXISTS` binds: skip when a different session_file
581
690
  // already holds this (entry_id, timestamp).
@@ -670,7 +779,7 @@ export function getOverallStats(cutoff?: number): AggregatedStats {
670
779
  SUM(cache_write_tokens) as total_cache_write_tokens,
671
780
  SUM(premium_requests) as total_premium_requests,
672
781
  SUM(cost_total) as total_cost,
673
- SUM(${UNPRICED_XAI_OAUTH_SQL}) as unpriced_requests,
782
+ SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
674
783
  SUM(CASE WHEN cost_no_cache_input > 0
675
784
  THEN cost_input + cost_cache_read + cost_cache_write
676
785
  ELSE 0 END) as total_cached_prompt_cost,
@@ -706,7 +815,7 @@ export function getStatsByModel(cutoff?: number): ModelStats[] {
706
815
  SUM(cache_write_tokens) as total_cache_write_tokens,
707
816
  SUM(premium_requests) as total_premium_requests,
708
817
  SUM(cost_total) as total_cost,
709
- SUM(${UNPRICED_XAI_OAUTH_SQL}) as unpriced_requests,
818
+ SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
710
819
  SUM(CASE WHEN cost_no_cache_input > 0
711
820
  THEN cost_input + cost_cache_read + cost_cache_write
712
821
  ELSE 0 END) as total_cached_prompt_cost,
@@ -748,7 +857,7 @@ export function getStatsByFolder(cutoff?: number): FolderStats[] {
748
857
  SUM(cache_write_tokens) as total_cache_write_tokens,
749
858
  SUM(premium_requests) as total_premium_requests,
750
859
  SUM(cost_total) as total_cost,
751
- SUM(${UNPRICED_XAI_OAUTH_SQL}) as unpriced_requests,
860
+ SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
752
861
  SUM(CASE WHEN cost_no_cache_input > 0
753
862
  THEN cost_input + cost_cache_read + cost_cache_write
754
863
  ELSE 0 END) as total_cached_prompt_cost,
@@ -897,7 +1006,7 @@ export function getStatsByProvider(cutoff?: number | null): ProviderAggregate[]
897
1006
  SUM(cache_write_tokens) as total_cache_write_tokens,
898
1007
  SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
899
1008
  SUM(cost_total) as total_cost,
900
- SUM(${UNPRICED_XAI_OAUTH_SQL}) as unpriced_requests,
1009
+ SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
901
1010
  SUM(premium_requests) as total_premium_requests,
902
1011
  AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
903
1012
  FROM messages
@@ -995,7 +1104,7 @@ export function getProviderTimeSeries(
995
1104
  provider,
996
1105
  SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
997
1106
  SUM(cost_total) as cost,
998
- SUM(${UNPRICED_XAI_OAUTH_SQL}) as unpriced_requests,
1107
+ SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
999
1108
  COUNT(*) as requests
1000
1109
  FROM messages
1001
1110
  ${hasCutoff ? "WHERE timestamp >= ?" : ""}
@@ -1118,6 +1227,7 @@ function rowToMessageStats(row: any): MessageStats {
1118
1227
  },
1119
1228
  },
1120
1229
  agentType: (row.agent_type as AgentType) ?? "main",
1230
+ costUnpriced: row.cost_unpriced === 1,
1121
1231
  };
1122
1232
  }
1123
1233
 
@@ -1159,6 +1269,7 @@ export interface SessionRollupRow {
1159
1269
  endedAt: number;
1160
1270
  totalTokens: number;
1161
1271
  costTotal: number;
1272
+ unpricedRequests: number;
1162
1273
  /** Comma-joined DISTINCT models. */
1163
1274
  models: string;
1164
1275
  }
@@ -1173,6 +1284,7 @@ export function getSessionRollups(): SessionRollupRow[] {
1173
1284
  MAX(timestamp + COALESCE(duration, 0)) AS endedAt,
1174
1285
  SUM(total_tokens) AS totalTokens,
1175
1286
  SUM(cost_total) AS costTotal,
1287
+ SUM(${UNPRICED_REQUEST_SQL}) AS unpricedRequests,
1176
1288
  GROUP_CONCAT(DISTINCT model) AS models
1177
1289
  FROM messages
1178
1290
  GROUP BY session_file
@@ -1208,7 +1320,7 @@ export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSe
1208
1320
  model,
1209
1321
  provider,
1210
1322
  SUM(cost_total) as cost,
1211
- SUM(${UNPRICED_XAI_OAUTH_SQL}) as unpriced_requests,
1323
+ SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
1212
1324
  SUM(cost_input) as cost_input,
1213
1325
  SUM(cost_output) as cost_output,
1214
1326
  SUM(cost_cache_read) as cost_cache_read,
@@ -1366,6 +1478,27 @@ function backfillReingestCosts(database: Database): void {
1366
1478
  .run(COST_REINGEST_BACKFILL_KEY, BACKFILL_PENDING);
1367
1479
  }
1368
1480
 
1481
+ /**
1482
+ * One-shot `file_offsets` wipe so the next sync re-parses every session and
1483
+ * re-derives `cost_unpriced` from `resolveStoredCost`. Rows ingested before the
1484
+ * marker existed defaulted to 0, and `INSERT ... ON CONFLICT DO UPDATE` only
1485
+ * refreshes them when the session is re-parsed — which the spent reingest
1486
+ * sentinel above will never do again. The re-parse also re-prices the
1487
+ * previously absent legacy `cost` charges, since `resolveStoredCost` now reads
1488
+ * absence as absence. Same sentinel protocol as {@link backfillReingestCosts}.
1489
+ */
1490
+ function backfillUnpricedCosts(database: Database): void {
1491
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(COST_UNPRICED_BACKFILL_KEY) as
1492
+ | { value: string }
1493
+ | undefined;
1494
+ if (!shouldResetBackfill(row?.value)) return;
1495
+
1496
+ database.run("DELETE FROM file_offsets");
1497
+ database
1498
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1499
+ .run(COST_UNPRICED_BACKFILL_KEY, BACKFILL_PENDING);
1500
+ }
1501
+
1369
1502
  /**
1370
1503
  * Reclassify pre-existing `messages` rows by agent type once, after the
1371
1504
  * `agent_type` column is added to an older database (every prior row defaulted
@@ -1492,6 +1625,7 @@ export function markSessionBackfillsComplete(): void {
1492
1625
  USER_MESSAGE_LINKS_REPAIR_KEY,
1493
1626
  PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY,
1494
1627
  COST_REINGEST_BACKFILL_KEY,
1628
+ COST_UNPRICED_BACKFILL_KEY,
1495
1629
  ]) {
1496
1630
  markComplete.run(key, BACKFILL_COMPLETE);
1497
1631
  }
@@ -1841,7 +1975,9 @@ export function updateToolResults(links: ToolResultLink[]): number {
1841
1975
  /**
1842
1976
  * Shared SELECT list for tool aggregates. Real provider usage comes from the
1843
1977
  * invoking assistant turn (`messages` join) divided by `calls_in_turn`, so
1844
- * per-tool token/cost shares stay additive across tools.
1978
+ * per-tool token/cost shares stay additive across tools. The unpriced share
1979
+ * reuses the request predicate against the joined message, whose stored cost
1980
+ * it attributes.
1845
1981
  */
1846
1982
  const TOOL_AGGREGATE_COLUMNS = `
1847
1983
  COUNT(*) as calls,
@@ -1851,8 +1987,7 @@ const TOOL_AGGREGATE_COLUMNS = `
1851
1987
  SUM(COALESCE(m.total_tokens, 0) * 1.0 / t.calls_in_turn) as total_tokens_share,
1852
1988
  SUM(COALESCE(m.output_tokens, 0) * 1.0 / t.calls_in_turn) as output_tokens_share,
1853
1989
  SUM(COALESCE(m.cost_total, 0) / t.calls_in_turn) as cost_share,
1854
- SUM(CASE WHEN t.provider = 'xai-oauth' AND COALESCE(m.total_tokens, 0) > 0 AND COALESCE(m.cost_total, 0) = 0
1855
- THEN 1.0 / t.calls_in_turn ELSE 0 END) as unpriced_requests_share,
1990
+ SUM(${unpricedRequestSql("m.")} * 1.0 / t.calls_in_turn) as unpriced_requests_share,
1856
1991
  MAX(t.timestamp) as last_used
1857
1992
  `;
1858
1993
 
package/src/parser.ts CHANGED
@@ -14,7 +14,7 @@ import { classifyModel } from "@oh-my-pi/pi-catalog/compat/taxonomy";
14
14
  import { getSessionsDir, isEnoent, readLines } from "@oh-my-pi/pi-utils";
15
15
  import type {
16
16
  AgentType,
17
- MessageStats,
17
+ MessageStatsInput,
18
18
  SessionEntry,
19
19
  SessionMessageEntry,
20
20
  SessionModelUsageEntry,
@@ -150,6 +150,61 @@ function extractUserStats(sessionFile: string, folder: string, entry: SessionMes
150
150
  };
151
151
  }
152
152
 
153
+ /**
154
+ * Session JSONL is written by older versions and foreign producers, so a token
155
+ * counter is whatever was persisted, not what `Usage` declares. A non-numeric
156
+ * bucket (`input: "10"`) must never be parsed and must never be summed: `+`
157
+ * would concatenate it into the derived total and SQLite would coerce the
158
+ * resulting string to a different, far larger number. A non-finite one
159
+ * (`input: 1e999` is legal JSON) must not reach a NOT NULL column either.
160
+ * Malformed input counts as absent.
161
+ */
162
+ function isFiniteCount(value: unknown): value is number {
163
+ return typeof value === "number" && Number.isFinite(value);
164
+ }
165
+
166
+ function finiteTokenCount(value: unknown): number {
167
+ return isFiniteCount(value) ? value : 0;
168
+ }
169
+
170
+ /**
171
+ * Token-bucket view for total derivation. Persisted session payloads are
172
+ * outside-controlled (old versions, foreign producers), so every counter is
173
+ * `unknown` and validated at read time.
174
+ */
175
+ export interface UsageBucketView {
176
+ totalTokens?: unknown;
177
+ input?: unknown;
178
+ output?: unknown;
179
+ cacheRead?: unknown;
180
+ cacheWrite?: unknown;
181
+ orchestration?: { input?: unknown; output?: unknown; cacheRead?: unknown } | null;
182
+ }
183
+
184
+ /**
185
+ * Total tokens for one usage payload, per the documented contract: the
186
+ * conversation buckets plus provider-reported orchestration tokens. A present
187
+ * finite provider total stays authoritative; a missing or malformed one
188
+ * (absent, string, NaN) is derived from the buckets, which would otherwise
189
+ * persist a zero total next to real token counts. Shared by ingest and the
190
+ * trace builder so stored and displayed totals cannot disagree.
191
+ */
192
+ export function resolveUsageTotal(usage: UsageBucketView | null | undefined): number {
193
+ if (typeof usage?.totalTokens === "number" && Number.isFinite(usage.totalTokens)) return usage.totalTokens;
194
+ if (!usage || typeof usage !== "object") return 0;
195
+ const orchestration =
196
+ usage.orchestration && typeof usage.orchestration === "object" ? usage.orchestration : undefined;
197
+ return (
198
+ finiteTokenCount(usage.input) +
199
+ finiteTokenCount(usage.output) +
200
+ finiteTokenCount(usage.cacheRead) +
201
+ finiteTokenCount(usage.cacheWrite) +
202
+ finiteTokenCount(orchestration?.input) +
203
+ finiteTokenCount(orchestration?.output) +
204
+ finiteTokenCount(orchestration?.cacheRead)
205
+ );
206
+ }
207
+
153
208
  /**
154
209
  * Extract stats from an assistant message entry.
155
210
  *
@@ -167,7 +222,7 @@ function extractStats(
167
222
  entry: SessionMessageEntry,
168
223
  currentServiceTier: ServiceTierByFamily | undefined,
169
224
  agentType: AgentType,
170
- ): MessageStats | null {
225
+ ): MessageStatsInput | null {
171
226
  const msg = entry.message as AssistantMessage;
172
227
  if (msg?.role !== "assistant") return null;
173
228
  if (typeof msg.model !== "string" || typeof msg.provider !== "string" || typeof msg.api !== "string") return null;
@@ -189,22 +244,27 @@ function extractStats(
189
244
  const tier = resolveModelServiceTier(currentServiceTier, model);
190
245
  const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(tier, model);
191
246
  const wellFormed =
192
- typeof rawUsage.input === "number" &&
193
- typeof rawUsage.output === "number" &&
194
- typeof rawUsage.cacheRead === "number" &&
195
- typeof rawUsage.cacheWrite === "number" &&
196
- typeof rawUsage.totalTokens === "number";
197
- const usage: Usage =
247
+ isFiniteCount(rawUsage.input) &&
248
+ isFiniteCount(rawUsage.output) &&
249
+ isFiniteCount(rawUsage.cacheRead) &&
250
+ isFiniteCount(rawUsage.cacheWrite) &&
251
+ isFiniteCount(rawUsage.totalTokens);
252
+ const usage: MessageStatsInput["usage"] =
198
253
  wellFormed && derived === recorded
199
254
  ? (rawUsage as Usage)
200
255
  : {
201
256
  ...rawUsage,
202
- input: rawUsage.input ?? 0,
203
- output: rawUsage.output ?? 0,
204
- cacheRead: rawUsage.cacheRead ?? 0,
205
- cacheWrite: rawUsage.cacheWrite ?? 0,
206
- totalTokens: rawUsage.totalTokens ?? 0,
207
- cost: rawUsage.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
257
+ input: finiteTokenCount(rawUsage.input),
258
+ output: finiteTokenCount(rawUsage.output),
259
+ cacheRead: finiteTokenCount(rawUsage.cacheRead),
260
+ cacheWrite: finiteTokenCount(rawUsage.cacheWrite),
261
+ // A present finite provider total stays authoritative; a missing
262
+ // or malformed one (absent, string, NaN) is derived from the buckets.
263
+ totalTokens: resolveUsageTotal(rawUsage),
264
+ // An omitted `cost` must stay omitted: `resolveStoredCost` reads
265
+ // absence as "no recorded price" and estimates the request, while
266
+ // a zero would read as an explicitly free request.
267
+ cost: rawUsage.cost,
208
268
  premiumRequests: derived,
209
269
  };
210
270
 
@@ -232,7 +292,7 @@ function extractModelUsageStats(
232
292
  folder: string,
233
293
  entry: SessionModelUsageEntry,
234
294
  agentType: AgentType,
235
- ): MessageStats | null {
295
+ ): MessageStatsInput | null {
236
296
  const timestamp = Date.parse(entry.timestamp);
237
297
  return extractStats(
238
298
  sessionFile,
@@ -261,7 +321,9 @@ function extractModelUsageStats(
261
321
 
262
322
  /** Message timestamp, falling back to the entry's ISO timestamp, then 0. */
263
323
  function coerceEntryTimestamp(timestamp: number | undefined, entry: SessionMessageEntry): number {
264
- if (typeof timestamp === "number" && Number.isFinite(timestamp)) return timestamp;
324
+ // A stored zero is the "no timestamp" sentinel, not 1970: fall through to
325
+ // the entry envelope so a recoverable ISO time still selects its tariff.
326
+ if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) return timestamp;
265
327
  const ts = Date.parse(entry.timestamp);
266
328
  return Number.isFinite(ts) ? ts : 0;
267
329
  }
@@ -409,7 +471,7 @@ function scanLastServiceTier(bytes: Uint8Array): ServiceTierByFamily | undefined
409
471
  * entries, preserving offset-based memory behavior for large sessions.
410
472
  */
411
473
  export interface ParseSessionResult {
412
- stats: MessageStats[];
474
+ stats: MessageStatsInput[];
413
475
  userStats: UserMessageStats[];
414
476
  userLinks: UserMessageLink[];
415
477
  toolCalls: ToolCallStats[];
@@ -428,7 +490,7 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
428
490
 
429
491
  const folder = extractFolderFromPath(sessionPath);
430
492
  const agentType = classifyAgentType(sessionPath);
431
- const stats: MessageStats[] = [];
493
+ const stats: MessageStatsInput[] = [];
432
494
  const userStats: UserMessageStats[] = [];
433
495
  const userLinks: UserMessageLink[] = [];
434
496
  const toolCalls: ToolCallStats[] = [];
@@ -484,6 +484,8 @@ export interface SessionSummary {
484
484
  subagents: number;
485
485
  totalTokens: number;
486
486
  costTotal: number;
487
+ /** Requests whose zero cost is unknown spend (scheduled card, no timestamp). */
488
+ unpricedRequests: number;
487
489
  models: string[];
488
490
  }
489
491
 
@@ -569,6 +571,8 @@ export interface TraceSummary {
569
571
  subagents: number;
570
572
  totalTokens: number;
571
573
  costTotal: number;
574
+ /** Model requests whose zero cost is unknown spend, not free usage. */
575
+ unpricedRequests: number;
572
576
  /** Sorted totalMs desc. */
573
577
  toolStats: TraceToolStat[];
574
578
  }