@linxiraos/pi-stats 1.1.13 → 1.1.15

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +10 -35
  2. package/dist/client/index.css +1 -1
  3. package/dist/client/index.js +56 -56
  4. package/dist/client/styles.css +46 -46
  5. package/dist/types/client/components/chart-shared.d.ts +21 -21
  6. package/dist/types/client/components/models-table-shared.d.ts +14 -14
  7. package/dist/types/client/data/formatters.d.ts +7 -2
  8. package/dist/types/client/traces/trace-colors.d.ts +1 -1
  9. package/dist/types/client/types.d.ts +2 -0
  10. package/dist/types/db.d.ts +24 -2
  11. package/dist/types/parser.d.ts +45 -2
  12. package/dist/types/shared-types.d.ts +4 -0
  13. package/dist/types/trace.d.ts +1 -1
  14. package/dist/types/types.d.ts +21 -0
  15. package/package.json +4 -4
  16. package/src/aggregator.ts +4 -0
  17. package/src/client/app/AppLayout.tsx +1 -1
  18. package/src/client/app/NavRail.tsx +1 -1
  19. package/src/client/components/AgentTokenShare.tsx +6 -6
  20. package/src/client/components/chart-shared.tsx +22 -22
  21. package/src/client/data/formatters.ts +15 -3
  22. package/src/client/routes/BehaviorRoute.tsx +10 -10
  23. package/src/client/routes/ModelsRoute.tsx +1 -1
  24. package/src/client/routes/OverviewRoute.tsx +4 -4
  25. package/src/client/routes/ProvidersRoute.tsx +3 -3
  26. package/src/client/routes/TracesRoute.tsx +10 -3
  27. package/src/client/styles.css +54 -52
  28. package/src/client/traces/SummaryStrip.tsx +2 -2
  29. package/src/client/traces/trace-colors.ts +13 -13
  30. package/src/client/types.ts +2 -0
  31. package/src/db.ts +242 -46
  32. package/src/parser.ts +131 -18
  33. package/src/server.ts +1 -1
  34. package/src/shared-types.ts +4 -0
  35. package/src/trace.ts +52 -8
  36. package/src/types.ts +20 -0
package/src/parser.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import {
4
5
  type AssistantMessage,
@@ -14,7 +15,7 @@ import { classifyModel } from "@linxiraos/pi-catalog/compat/taxonomy";
14
15
  import { getSessionsDir, isEnoent, readLines } from "@linxiraos/pi-utils";
15
16
  import type {
16
17
  AgentType,
17
- MessageStats,
18
+ MessageStatsInput,
18
19
  SessionEntry,
19
20
  SessionMessageEntry,
20
21
  SessionModelUsageEntry,
@@ -62,6 +63,53 @@ export function extractFolderFromPath(sessionPath: string): string {
62
63
  return projectDir.replace(/^--/, "/").replace(/--/g, "/");
63
64
  }
64
65
 
66
+ /**
67
+ * Decode a session project folder name back to its source cwd, mirroring the
68
+ * encoder in the coding agent's session-paths module: home-relative cwds
69
+ * encode as `-<segments>`, temp-relative ones as `-tmp<segments>`, and legacy
70
+ * absolute ones as `--<absolute>--`, with `/`, `\`, and `:` collapsed to `-`.
71
+ * Returns null when the name carries no decodable cwd.
72
+ */
73
+ export function decodeProjectFolderCwd(projectDir: string): string | null {
74
+ if (projectDir === "-tmp" || projectDir.startsWith("-tmp-")) return os.tmpdir();
75
+ if (projectDir.startsWith("--")) {
76
+ const inner = projectDir.replace(/^--/, "").replace(/--$/, "");
77
+ if (!inner) return path.resolve("/");
78
+ // POSIX legacy names had their leading `/` stripped, so restore it;
79
+ // Windows names collapsed a drive letter's `:` into a dash too
80
+ // (`C--Users` decodes to `C:\Users`), so re-attach the colon.
81
+ if (path.sep === "/") return path.resolve(`/${inner.replaceAll("-", "/")}`);
82
+ return path.resolve(inner.replaceAll("-", "\\").replace(/^([A-Za-z])\\/, "$1:\\"));
83
+ }
84
+ if (projectDir.startsWith("-")) {
85
+ const inner = projectDir.replace(/^-/, "");
86
+ return path.resolve(os.homedir(), inner.replaceAll("-", path.sep));
87
+ }
88
+ // Folder names without the leading marker predate the encoder; no cwd is
89
+ // recoverable, so they are never treated as temp.
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Whether a session project folder decodes to a cwd inside the OS temp dir.
95
+ * Test suites spawn agents under `os.tmpdir()` and re-create their transcripts
96
+ * on every run; those sessions are throwaway usage that must not reach the
97
+ * stats DB. The `-` collapse in the encoder is lossy (a literal `-` inside a
98
+ * path segment is indistinguishable from a separator), which can only produce
99
+ * false positives here — a skipped ingestion, never a misattributed row.
100
+ */
101
+ export function isTempProjectFolder(projectDir: string): boolean {
102
+ const decoded = decodeProjectFolderCwd(projectDir);
103
+ if (!decoded) return false;
104
+ const tmp = path.resolve(os.tmpdir());
105
+ const candidate = path.resolve(decoded);
106
+ // NTFS comparisons are case-insensitive; resolve() normalizes separators.
107
+ const caseFold = process.platform === "win32";
108
+ const normalizedTmp = caseFold ? tmp.toLowerCase() : tmp;
109
+ const normalizedCandidate = caseFold ? candidate.toLowerCase() : candidate;
110
+ return normalizedCandidate === normalizedTmp || normalizedCandidate.startsWith(normalizedTmp + path.sep);
111
+ }
112
+
65
113
  /**
66
114
  * Check if an entry is an assistant message.
67
115
  */
@@ -150,6 +198,61 @@ function extractUserStats(sessionFile: string, folder: string, entry: SessionMes
150
198
  };
151
199
  }
152
200
 
201
+ /**
202
+ * Session JSONL is written by older versions and foreign producers, so a token
203
+ * counter is whatever was persisted, not what `Usage` declares. A non-numeric
204
+ * bucket (`input: "10"`) must never be parsed and must never be summed: `+`
205
+ * would concatenate it into the derived total and SQLite would coerce the
206
+ * resulting string to a different, far larger number. A non-finite one
207
+ * (`input: 1e999` is legal JSON) must not reach a NOT NULL column either.
208
+ * Malformed input counts as absent.
209
+ */
210
+ function isFiniteCount(value: unknown): value is number {
211
+ return typeof value === "number" && Number.isFinite(value);
212
+ }
213
+
214
+ function finiteTokenCount(value: unknown): number {
215
+ return isFiniteCount(value) ? value : 0;
216
+ }
217
+
218
+ /**
219
+ * Token-bucket view for total derivation. Persisted session payloads are
220
+ * outside-controlled (old versions, foreign producers), so every counter is
221
+ * `unknown` and validated at read time.
222
+ */
223
+ export interface UsageBucketView {
224
+ totalTokens?: unknown;
225
+ input?: unknown;
226
+ output?: unknown;
227
+ cacheRead?: unknown;
228
+ cacheWrite?: unknown;
229
+ orchestration?: { input?: unknown; output?: unknown; cacheRead?: unknown } | null;
230
+ }
231
+
232
+ /**
233
+ * Total tokens for one usage payload, per the documented contract: the
234
+ * conversation buckets plus provider-reported orchestration tokens. A present
235
+ * finite provider total stays authoritative; a missing or malformed one
236
+ * (absent, string, NaN) is derived from the buckets, which would otherwise
237
+ * persist a zero total next to real token counts. Shared by ingest and the
238
+ * trace builder so stored and displayed totals cannot disagree.
239
+ */
240
+ export function resolveUsageTotal(usage: UsageBucketView | null | undefined): number {
241
+ if (typeof usage?.totalTokens === "number" && Number.isFinite(usage.totalTokens)) return usage.totalTokens;
242
+ if (!usage || typeof usage !== "object") return 0;
243
+ const orchestration =
244
+ usage.orchestration && typeof usage.orchestration === "object" ? usage.orchestration : undefined;
245
+ return (
246
+ finiteTokenCount(usage.input) +
247
+ finiteTokenCount(usage.output) +
248
+ finiteTokenCount(usage.cacheRead) +
249
+ finiteTokenCount(usage.cacheWrite) +
250
+ finiteTokenCount(orchestration?.input) +
251
+ finiteTokenCount(orchestration?.output) +
252
+ finiteTokenCount(orchestration?.cacheRead)
253
+ );
254
+ }
255
+
153
256
  /**
154
257
  * Extract stats from an assistant message entry.
155
258
  *
@@ -167,7 +270,7 @@ function extractStats(
167
270
  entry: SessionMessageEntry,
168
271
  currentServiceTier: ServiceTierByFamily | undefined,
169
272
  agentType: AgentType,
170
- ): MessageStats | null {
273
+ ): MessageStatsInput | null {
171
274
  const msg = entry.message as AssistantMessage;
172
275
  if (msg?.role !== "assistant") return null;
173
276
  if (typeof msg.model !== "string" || typeof msg.provider !== "string" || typeof msg.api !== "string") return null;
@@ -189,22 +292,27 @@ function extractStats(
189
292
  const tier = resolveModelServiceTier(currentServiceTier, model);
190
293
  const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(tier, model);
191
294
  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 =
295
+ isFiniteCount(rawUsage.input) &&
296
+ isFiniteCount(rawUsage.output) &&
297
+ isFiniteCount(rawUsage.cacheRead) &&
298
+ isFiniteCount(rawUsage.cacheWrite) &&
299
+ isFiniteCount(rawUsage.totalTokens);
300
+ const usage: MessageStatsInput["usage"] =
198
301
  wellFormed && derived === recorded
199
302
  ? (rawUsage as Usage)
200
303
  : {
201
304
  ...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 },
305
+ input: finiteTokenCount(rawUsage.input),
306
+ output: finiteTokenCount(rawUsage.output),
307
+ cacheRead: finiteTokenCount(rawUsage.cacheRead),
308
+ cacheWrite: finiteTokenCount(rawUsage.cacheWrite),
309
+ // A present finite provider total stays authoritative; a missing
310
+ // or malformed one (absent, string, NaN) is derived from the buckets.
311
+ totalTokens: resolveUsageTotal(rawUsage),
312
+ // An omitted `cost` must stay omitted: `resolveStoredCost` reads
313
+ // absence as "no recorded price" and estimates the request, while
314
+ // a zero would read as an explicitly free request.
315
+ cost: rawUsage.cost,
208
316
  premiumRequests: derived,
209
317
  };
210
318
 
@@ -232,7 +340,7 @@ function extractModelUsageStats(
232
340
  folder: string,
233
341
  entry: SessionModelUsageEntry,
234
342
  agentType: AgentType,
235
- ): MessageStats | null {
343
+ ): MessageStatsInput | null {
236
344
  const timestamp = Date.parse(entry.timestamp);
237
345
  return extractStats(
238
346
  sessionFile,
@@ -261,7 +369,9 @@ function extractModelUsageStats(
261
369
 
262
370
  /** Message timestamp, falling back to the entry's ISO timestamp, then 0. */
263
371
  function coerceEntryTimestamp(timestamp: number | undefined, entry: SessionMessageEntry): number {
264
- if (typeof timestamp === "number" && Number.isFinite(timestamp)) return timestamp;
372
+ // A stored zero is the "no timestamp" sentinel, not 1970: fall through to
373
+ // the entry envelope so a recoverable ISO time still selects its tariff.
374
+ if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) return timestamp;
265
375
  const ts = Date.parse(entry.timestamp);
266
376
  return Number.isFinite(ts) ? ts : 0;
267
377
  }
@@ -409,7 +519,7 @@ function scanLastServiceTier(bytes: Uint8Array): ServiceTierByFamily | undefined
409
519
  * entries, preserving offset-based memory behavior for large sessions.
410
520
  */
411
521
  export interface ParseSessionResult {
412
- stats: MessageStats[];
522
+ stats: MessageStatsInput[];
413
523
  userStats: UserMessageStats[];
414
524
  userLinks: UserMessageLink[];
415
525
  toolCalls: ToolCallStats[];
@@ -428,7 +538,7 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
428
538
 
429
539
  const folder = extractFolderFromPath(sessionPath);
430
540
  const agentType = classifyAgentType(sessionPath);
431
- const stats: MessageStats[] = [];
541
+ const stats: MessageStatsInput[] = [];
432
542
  const userStats: UserMessageStats[] = [];
433
543
  const userLinks: UserMessageLink[] = [];
434
544
  const toolCalls: ToolCallStats[] = [];
@@ -526,6 +636,9 @@ export async function listAllSessionFiles(): Promise<string[]> {
526
636
  const allFiles: string[] = [];
527
637
 
528
638
  for (const folder of folders) {
639
+ // Skip at enumeration: temp-cwd project folders re-create their
640
+ // transcripts on every test run and must never reach the parser or DB.
641
+ if (isTempProjectFolder(path.basename(folder))) continue;
529
642
  const files = await listSessionFiles(folder);
530
643
  allFiles.push(...files);
531
644
  }
package/src/server.ts CHANGED
@@ -180,7 +180,7 @@ const ensureClientBuild = async () => {
180
180
  <head>
181
181
  <meta charset="UTF-8">
182
182
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
183
- <title>AI Usage Statistics</title>
183
+ <title>Zeta Stats</title>
184
184
  <link rel="stylesheet" href="styles.css">
185
185
  </head>
186
186
  <body>
@@ -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
  }
package/src/trace.ts CHANGED
@@ -11,9 +11,10 @@
11
11
 
12
12
  import * as fs from "node:fs/promises";
13
13
  import * as path from "node:path";
14
+ import { getBundledModel, type GeneratedProvider } from "@linxiraos/pi-catalog/models";
14
15
  import { getSessionsDir, isEnoent } from "@linxiraos/pi-utils";
15
- import { getSessionRollups, getToolCallCountsBySession } from "./db";
16
- import { extractFolderFromPath, parseAllSessionEntries } from "./parser";
16
+ import { getSessionRollups, getToolCallCountsBySession, isScheduledCatalogModel } from "./db";
17
+ import { extractFolderFromPath, parseAllSessionEntries, resolveUsageTotal } from "./parser";
17
18
  import type {
18
19
  SessionEntry,
19
20
  SessionSummary,
@@ -32,7 +33,7 @@ export class TracePathError extends Error {}
32
33
  * span assembly changes so browsers don't revalidate stale cached bodies
33
34
  * against an unchanged transcript mtime.
34
35
  */
35
- export const TRACE_ETAG_VERSION = 2;
36
+ export const TRACE_ETAG_VERSION = 3;
36
37
 
37
38
  const MAX_TRACK_DEPTH = 6;
38
39
  const LABEL_MAX = 80;
@@ -84,7 +85,15 @@ interface MessageView {
84
85
  completedAt?: number;
85
86
  duration?: number;
86
87
  ttft?: number;
87
- usage?: { totalTokens?: number; cost?: { total?: number } };
88
+ usage?: {
89
+ input?: unknown;
90
+ output?: unknown;
91
+ cacheRead?: unknown;
92
+ cacheWrite?: unknown;
93
+ totalTokens?: unknown;
94
+ orchestration?: { input?: unknown; output?: unknown; cacheRead?: unknown };
95
+ cost?: { total?: unknown };
96
+ };
88
97
  model?: string;
89
98
  provider?: string;
90
99
  stopReason?: string;
@@ -115,13 +124,14 @@ interface TaskResultFact {
115
124
  resultEntryId: string;
116
125
  toolSpanEnd: number;
117
126
  }
118
-
119
127
  /** Everything one transcript contributes before cross-track assembly. */
120
128
  interface TrackScan {
121
129
  track: TraceTrack;
122
130
  taskResults: TaskResultFact[];
123
131
  requests: number;
124
132
  toolCalls: number;
133
+ /** Model requests whose zero cost is unknown spend (mirrors db's unpriced predicate). */
134
+ unpricedRequests: number;
125
135
  cwd: string | null;
126
136
  title: string | null;
127
137
  /** Session-header envelope timestamp (ms), for spanless traces. */
@@ -284,6 +294,7 @@ function scanTranscript(
284
294
  let initModel: string | null = null;
285
295
  let firstAssistantModel: string | null = null;
286
296
  let requests = 0;
297
+ let unpricedRequests = 0;
287
298
  let lastChainTs = 0;
288
299
 
289
300
  // Title slot and session header may sit outside the id chain — scan all entries.
@@ -358,6 +369,32 @@ function scanTranscript(
358
369
 
359
370
  if (msg.role === "assistant") {
360
371
  requests++;
372
+ // Mirror the db's unpriced predicate so the trace headline agrees
373
+ // with Costs/Recent Requests: a zero that is unknown spend, not free.
374
+ // Counted before the span-timing `continue` below so a fully
375
+ // dateless entry still surfaces as N/A rather than vanishing.
376
+ // Derive a missing total from the buckets via the shared ingest
377
+ // helper, so a legacy entry with tokens but no total still
378
+ // classifies as unpriced.
379
+ const totalTokens = resolveUsageTotal(msg.usage);
380
+ const costObj = msg.usage?.cost;
381
+ const costTotal =
382
+ typeof costObj?.total === "number" && Number.isFinite(costObj.total) ? costObj.total : undefined;
383
+ if (totalTokens > 0 && (costTotal === undefined || costTotal === 0)) {
384
+ const provider = typeof msg.provider === "string" ? msg.provider : "";
385
+ const model = typeof msg.model === "string" ? msg.model : "";
386
+ if (provider === "xai-oauth" && model) {
387
+ const ref = getBundledModel("xai" as GeneratedProvider, model)?.cost;
388
+ if (!(ref && (ref.input !== 0 || ref.output !== 0 || ref.cacheRead !== 0 || ref.cacheWrite !== 0))) {
389
+ unpricedRequests++;
390
+ }
391
+ } else if (costObj == null && provider && model) {
392
+ const hasTimestamp =
393
+ (typeof msg.timestamp === "number" && Number.isFinite(msg.timestamp) && msg.timestamp > 0) ||
394
+ envelopeTs !== undefined;
395
+ if (!hasTimestamp && isScheduledCatalogModel(provider, model)) unpricedRequests++;
396
+ }
397
+ }
361
398
  let start = typeof msg.timestamp === "number" && Number.isFinite(msg.timestamp) ? msg.timestamp : undefined;
362
399
  let end =
363
400
  typeof msg.completedAt === "number" && Number.isFinite(msg.completedAt)
@@ -386,12 +423,12 @@ function scanTranscript(
386
423
  if (detail) span.detail = detail;
387
424
  if (entry.id) span.entryId = entry.id;
388
425
  if (typeof msg.model === "string") span.model = msg.model;
389
- if (typeof msg.usage?.totalTokens === "number") span.tokens = msg.usage.totalTokens;
390
- if (typeof msg.usage?.cost?.total === "number") span.cost = msg.usage.cost.total;
426
+ if (totalTokens > 0) span.tokens = totalTokens;
427
+
428
+ if (costTotal !== undefined) span.cost = costTotal;
391
429
  if (typeof msg.ttft === "number") span.ttft = msg.ttft;
392
430
  if (msg.stopReason === "error" || msg.errorMessage) span.isError = true;
393
431
  spans.push(span);
394
-
395
432
  if (Array.isArray(msg.content)) {
396
433
  for (const block of msg.content) {
397
434
  if (!block || typeof block !== "object" || !("type" in block) || block.type !== "toolCall") continue;
@@ -572,6 +609,7 @@ function scanTranscript(
572
609
  taskResults,
573
610
  requests,
574
611
  toolCalls: pendingTools.length,
612
+ unpricedRequests,
575
613
  cwd,
576
614
  title: slotTitle ?? headerTitle,
577
615
  headerTs,
@@ -722,11 +760,13 @@ export async function buildSessionTrace(fileParam: string): Promise<SessionTrace
722
760
  let requests = 0;
723
761
  let totalTokens = 0;
724
762
  let costTotal = 0;
763
+ let unpricedRequests = 0;
725
764
  const toolStats = new Map<string, TraceToolStat>();
726
765
 
727
766
  for (const scan of scans) {
728
767
  requests += scan.requests;
729
768
  toolCalls += scan.toolCalls;
769
+ unpricedRequests += scan.unpricedRequests;
730
770
  for (const span of scan.track.spans) {
731
771
  intervals.push([span.start, span.end]);
732
772
  if (startedAt === undefined || span.start < startedAt) startedAt = span.start;
@@ -779,6 +819,7 @@ export async function buildSessionTrace(fileParam: string): Promise<SessionTrace
779
819
  subagents: tracks.length - 1,
780
820
  totalTokens,
781
821
  costTotal,
822
+ unpricedRequests,
782
823
  toolStats: [...toolStats.values()].sort((a, b) => b.totalMs - a.totalMs),
783
824
  };
784
825
 
@@ -936,6 +977,7 @@ export async function listSessionSummaries(limit = 100, q?: string): Promise<Ses
936
977
  subagents: 0,
937
978
  totalTokens: 0,
938
979
  costTotal: 0,
980
+ unpricedRequests: 0,
939
981
  models: [],
940
982
  modelSet: new Set(),
941
983
  };
@@ -948,6 +990,7 @@ export async function listSessionSummaries(limit = 100, q?: string): Promise<Ses
948
990
  if (row.endedAt > fold.endedAt) fold.endedAt = row.endedAt;
949
991
  fold.totalTokens += row.totalTokens ?? 0;
950
992
  fold.costTotal += row.costTotal ?? 0;
993
+ fold.unpricedRequests += row.unpricedRequests ?? 0;
951
994
  if (row.models) {
952
995
  for (const model of row.models.split(",")) {
953
996
  if (model) fold.modelSet.add(model);
@@ -975,6 +1018,7 @@ export async function listSessionSummaries(limit = 100, q?: string): Promise<Ses
975
1018
  subagents: 0,
976
1019
  totalTokens: 0,
977
1020
  costTotal: 0,
1021
+ unpricedRequests: 0,
978
1022
  models: [],
979
1023
  modelSet: new Set(),
980
1024
  });
package/src/types.ts CHANGED
@@ -35,6 +35,26 @@ export interface MessageStats {
35
35
  usage: Usage;
36
36
  /** Which agent produced this message (main agent, task subagent, advisor) */
37
37
  agentType: AgentType;
38
+ /**
39
+ * Ingest refused to price this request: a scheduled (time-based) card with no
40
+ * recoverable request timestamp, so `usage.cost.total` of 0 is unknown spend
41
+ * rather than a free request. Always written by `rowToMessageStats`; optional
42
+ * only because session fixtures that stand in for ingest input are typed as
43
+ * `MessageStats` too.
44
+ */
45
+ costUnpriced?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Session-recorded token usage before pricing: counters are coerced to numbers
50
+ * because their columns are NOT NULL, but `cost` is only present when the
51
+ * session entry actually recorded one. Absence is meaningful — `resolveStoredCost`
52
+ * estimates a request with no recorded price, whereas a recorded zero is a real
53
+ * charge that scheduled cards freeze. `costUnpriced` is omitted because it is a
54
+ * decision of the ingest path, not something the parser or a caller supplies.
55
+ */
56
+ export interface MessageStatsInput extends Omit<MessageStats, "usage" | "costUnpriced"> {
57
+ usage: Omit<Usage, "cost"> & { cost?: Partial<Usage["cost"]> };
38
58
  }
39
59
 
40
60
  /**