@trazum/core 1.9.0 → 1.25.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.
Files changed (108) hide show
  1. package/README.md +12 -5
  2. package/dist/advisories.d.ts.map +1 -1
  3. package/dist/advisories.js +94 -4
  4. package/dist/advisories.js.map +1 -1
  5. package/dist/against.d.ts +32 -0
  6. package/dist/against.d.ts.map +1 -0
  7. package/dist/against.js +34 -0
  8. package/dist/against.js.map +1 -0
  9. package/dist/config-schema.d.ts +42 -1
  10. package/dist/config-schema.d.ts.map +1 -1
  11. package/dist/config-schema.js +61 -0
  12. package/dist/config-schema.js.map +1 -1
  13. package/dist/conversation.d.ts +121 -0
  14. package/dist/conversation.d.ts.map +1 -0
  15. package/dist/conversation.js +157 -0
  16. package/dist/conversation.js.map +1 -0
  17. package/dist/csv.d.ts +61 -0
  18. package/dist/csv.d.ts.map +1 -0
  19. package/dist/csv.js +149 -0
  20. package/dist/csv.js.map +1 -0
  21. package/dist/evaluate.d.ts +24 -0
  22. package/dist/evaluate.d.ts.map +1 -1
  23. package/dist/evaluate.js +5 -2
  24. package/dist/evaluate.js.map +1 -1
  25. package/dist/i18n/en.d.ts.map +1 -1
  26. package/dist/i18n/en.js +18 -7
  27. package/dist/i18n/en.js.map +1 -1
  28. package/dist/i18n/es.d.ts.map +1 -1
  29. package/dist/i18n/es.js +18 -7
  30. package/dist/i18n/es.js.map +1 -1
  31. package/dist/i18n/types.d.ts +28 -1
  32. package/dist/i18n/types.d.ts.map +1 -1
  33. package/dist/index.d.ts +25 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +28 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/input-shape.d.ts +104 -0
  38. package/dist/input-shape.d.ts.map +1 -0
  39. package/dist/input-shape.js +132 -0
  40. package/dist/input-shape.js.map +1 -0
  41. package/dist/levers.d.ts +151 -0
  42. package/dist/levers.d.ts.map +1 -0
  43. package/dist/levers.js +160 -0
  44. package/dist/levers.js.map +1 -0
  45. package/dist/node.d.ts +1 -1
  46. package/dist/node.d.ts.map +1 -1
  47. package/dist/output-shape.d.ts +96 -0
  48. package/dist/output-shape.d.ts.map +1 -0
  49. package/dist/output-shape.js +145 -0
  50. package/dist/output-shape.js.map +1 -0
  51. package/dist/pricing-overlay.d.ts +1 -1
  52. package/dist/pricing-overlay.d.ts.map +1 -1
  53. package/dist/pricing-overlay.js +46 -0
  54. package/dist/pricing-overlay.js.map +1 -1
  55. package/dist/repeats.d.ts +75 -0
  56. package/dist/repeats.d.ts.map +1 -0
  57. package/dist/repeats.js +82 -0
  58. package/dist/repeats.js.map +1 -0
  59. package/dist/reprice.d.ts +143 -0
  60. package/dist/reprice.d.ts.map +1 -0
  61. package/dist/reprice.js +82 -0
  62. package/dist/reprice.js.map +1 -0
  63. package/dist/session-cost.d.ts +70 -0
  64. package/dist/session-cost.d.ts.map +1 -0
  65. package/dist/session-cost.js +90 -0
  66. package/dist/session-cost.js.map +1 -0
  67. package/dist/session-ledger.d.ts +77 -0
  68. package/dist/session-ledger.d.ts.map +1 -0
  69. package/dist/session-ledger.js +99 -0
  70. package/dist/session-ledger.js.map +1 -0
  71. package/dist/tokenizer.d.ts +33 -26
  72. package/dist/tokenizer.d.ts.map +1 -1
  73. package/dist/tokenizer.js +95 -34
  74. package/dist/tokenizer.js.map +1 -1
  75. package/dist/ttl-fit.d.ts +103 -0
  76. package/dist/ttl-fit.d.ts.map +1 -0
  77. package/dist/ttl-fit.js +184 -0
  78. package/dist/ttl-fit.js.map +1 -0
  79. package/dist/types.d.ts +1 -1
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/usage.d.ts +637 -0
  82. package/dist/usage.d.ts.map +1 -0
  83. package/dist/usage.js +634 -0
  84. package/dist/usage.js.map +1 -0
  85. package/package.json +1 -1
  86. package/src/advisories.ts +96 -4
  87. package/src/against.ts +48 -0
  88. package/src/config-schema.ts +106 -0
  89. package/src/conversation.ts +305 -0
  90. package/src/csv.ts +184 -0
  91. package/src/evaluate.ts +33 -3
  92. package/src/i18n/en.ts +19 -6
  93. package/src/i18n/es.ts +19 -6
  94. package/src/i18n/types.ts +29 -1
  95. package/src/index.ts +65 -1
  96. package/src/input-shape.ts +259 -0
  97. package/src/levers.ts +331 -0
  98. package/src/node.ts +1 -1
  99. package/src/output-shape.ts +254 -0
  100. package/src/pricing-overlay.ts +52 -1
  101. package/src/repeats.ts +166 -0
  102. package/src/reprice.ts +227 -0
  103. package/src/session-cost.ts +170 -0
  104. package/src/session-ledger.ts +189 -0
  105. package/src/tokenizer.ts +93 -30
  106. package/src/ttl-fit.ts +251 -0
  107. package/src/types.ts +1 -0
  108. package/src/usage.ts +1267 -0
package/src/usage.ts ADDED
@@ -0,0 +1,1267 @@
1
+ import { effectivePricing, multipliersFor } from './pricing.js';
2
+ import { createConversationTracker } from './conversation.js';
3
+ import { createOutputShapeTracker } from './output-shape.js';
4
+ import { createInputShapeTracker } from './input-shape.js';
5
+ import { createRepeatsTracker } from './repeats.js';
6
+ import { createTtlFitTracker } from './ttl-fit.js';
7
+ import { createSessionLedgerTracker } from './session-ledger.js';
8
+ import { createSessionCostTracker } from './session-cost.js';
9
+ import type { SessionCostShape } from './session-cost.js';
10
+ import type { CacheTtlFit } from './ttl-fit.js';
11
+ import type { SingleTurnCacheWrites } from './session-ledger.js';
12
+ import type { ConversationGrowth } from './conversation.js';
13
+ import type { OutputShape } from './output-shape.js';
14
+ import type { InputShape } from './input-shape.js';
15
+ import type { RepeatedTurns } from './repeats.js';
16
+ import type { PricingCatalogue } from './pricing.js';
17
+
18
+ /**
19
+ * Where the money actually went, from calls that actually happened.
20
+ *
21
+ * ## Why this exists
22
+ *
23
+ * Everything else in this package reads a **prompt file** and reasons about what
24
+ * it would cost. That is the smallest line item on most bills, and the gap is not
25
+ * small enough to argue about: measured on an ordinary support prompt, the
26
+ * deterministic rules recover about **1%** of the monthly figure, while output
27
+ * tokens alone were **87%** of it. A tool that reads `prompts/*.txt` cannot see
28
+ * retrieved context, conversation history, tool results or answers, and on a RAG
29
+ * or agent workload those are nearly the whole invoice.
30
+ *
31
+ * So this reads the other direction: **what the provider actually charged**, per
32
+ * call, and says where it went. The sentence it is built to produce is "63% of
33
+ * your bill is retrieved context and nothing is watching it", which is a fact
34
+ * about a system rather than an estimate about a file.
35
+ *
36
+ * ## It reads a file, and that is the design
37
+ *
38
+ * Not a proxy, not an SDK wrapper, not a callback. Trazum's whole security
39
+ * position is that prompts do not leave the machine they are on — asserted by
40
+ * tests, not promised — and a tool that sits in the request path trades that away
41
+ * for convenience. A JSON Lines file is something you already have or can produce
42
+ * in three lines, and it keeps the guarantee intact.
43
+ *
44
+ * ## The format is the one the API already gives you
45
+ *
46
+ * Nothing is invented here. Every Anthropic response carries a `usage` object
47
+ * with exactly these fields, so recording a call is:
48
+ *
49
+ * ```ts
50
+ * appendFileSync('usage.jsonl', JSON.stringify({
51
+ * model: response.model,
52
+ * ...response.usage,
53
+ * }) + '\n');
54
+ * ```
55
+ *
56
+ * OpenAI's `usage` maps onto the same shape with different names, and
57
+ * `parseUsageLine` accepts both. Asking somebody to transform their logs into a
58
+ * bespoke schema before a tool will read them is how a tool goes unused.
59
+ *
60
+ * ## What it refuses to do
61
+ *
62
+ * **It does not read prompt text and there is nowhere to put it.** The record
63
+ * shape has no field for content, so a usage log handed to Trazum cannot contain
64
+ * a prompt even by accident. That is a stronger promise than "we do not look at
65
+ * it", and it is the reason this takes counts rather than calls.
66
+ *
67
+ * **It reports no saving.** Attributing "you could have saved X" to a call that
68
+ * already happened means guessing what the call should have been, and this module
69
+ * exists precisely because guessing is what the rest of the package has to do.
70
+ * It reports what was spent, split by where it went. What to do about it is a
71
+ * different question and belongs to the advisories.
72
+ *
73
+ * `cacheEconomics` is the one counterfactual here, and it is not an exception to
74
+ * that rule — it is the line the rule draws. A saving requires imagining a prompt
75
+ * nobody wrote; this requires imagining the **same tokens at a different rate**,
76
+ * which is arithmetic. Caching does not change what is sent, only the multiplier
77
+ * it is billed at, so "these tokens cost 1.25x instead of 1x" is as measured as
78
+ * the total itself. Anything that would need a guess about content stays out.
79
+ */
80
+
81
+ /** One recorded call, after parsing. All counts, no content. */
82
+ export interface UsageRecord {
83
+ /** Model id as the provider reported it. */
84
+ model: string;
85
+ /** Uncached input tokens billed at the full rate. */
86
+ inputTokens: number;
87
+ /** Tokens billed at the cache-read rate. Zero when nothing was cached. */
88
+ cacheReadTokens: number;
89
+ /** Cache writes at the 5-minute rate — 1.25x input on Anthropic. */
90
+ cacheWrite5mTokens: number;
91
+ /** Cache writes at the 1-hour rate, which is **2x** input, not 1.25x. */
92
+ cacheWrite1hTokens: number;
93
+ /**
94
+ * Whether the log said which TTL those writes used.
95
+ *
96
+ * `false` when only the flat `cache_creation_input_tokens` was present and it
97
+ * was non-zero: the writes are then priced at the cheaper 5-minute rate because
98
+ * one of the two has to be assumed, and the report says so. Choosing the cheaper
99
+ * rate silently understates a 1-hour workload by 37.5% on its largest line.
100
+ */
101
+ writeTtlKnown: boolean;
102
+ outputTokens: number;
103
+ /**
104
+ * Optional label for grouping — an endpoint, a feature, a prompt name.
105
+ *
106
+ * The whole value of a profile is answering "which part of the product costs
107
+ * this", and without a label every call looks alike. Unlabelled records are
108
+ * grouped under a single bucket rather than dropped, because a profile that
109
+ * refuses to read a log until it is annotated is a profile nobody runs.
110
+ */
111
+ label: string | null;
112
+ /**
113
+ * Optional conversation identifier, for measuring what re-sent history costs.
114
+ *
115
+ * On a chat or agent workload the input grows with every turn, because the whole
116
+ * conversation goes back up on each call. That is frequently the largest line on
117
+ * the bill and nothing watches it — a prompt file cannot show it, and a total
118
+ * cannot either.
119
+ *
120
+ * **Trazum never prints this value.** A session key is somebody's conversation
121
+ * and could easily be an account id or an email; it is used to group calls and
122
+ * to count turns, and every figure derived from it is reported per *label*. That
123
+ * keeps the guarantee this module is built on: a usage log handed to Trazum
124
+ * carries no content, and nothing identifying comes back out of it either.
125
+ */
126
+ session: string | null;
127
+ /**
128
+ * When the call happened, as epoch milliseconds, or `null` when the log does
129
+ * not say.
130
+ *
131
+ * Read from `ts`, `timestamp`, `created_at` or OpenAI's `created`; ISO 8601
132
+ * strings and epoch numbers both work, with seconds told from milliseconds by
133
+ * magnitude. The clock unlocks the two findings counts alone cannot make:
134
+ * what period this log actually covers, and whether the cache TTL fits how
135
+ * fast the turns arrive — the single most common reason a cache loses money.
136
+ */
137
+ ts: number | null;
138
+ /**
139
+ * Whether the answer hit the output ceiling, when the log says.
140
+ *
141
+ * `true` for Anthropic's `stop_reason: "max_tokens"` and OpenAI's
142
+ * `finish_reason: "length"`; `false` for any other recorded reason; `null`
143
+ * when the log does not carry the field. Three states, because "no truncation
144
+ * recorded" and "no truncation happened" are different answers — the report
145
+ * must not congratulate a log that never measured.
146
+ */
147
+ truncated: boolean | null;
148
+ }
149
+
150
+ /** What a set of calls cost, split by where the money went. */
151
+ export interface UsageBreakdown {
152
+ calls: number;
153
+ inputTokens: number;
154
+ cacheReadTokens: number;
155
+ cacheWriteTokens: number;
156
+ /**
157
+ * The two write TTLs kept apart, because they are billed at different rates
158
+ * — 1.25x input for a 5-minute entry and **2x** for a 1-hour one.
159
+ *
160
+ * `cacheWriteTokens` is their sum and stays the figure to read for volume.
161
+ * These exist so the same tokens can be priced again at another model's
162
+ * rates without the ratio between the two being invented: it is not a
163
+ * constant across providers, so a total that has lost the split cannot be
164
+ * repriced, only guessed at.
165
+ *
166
+ * Writes whose TTL the log did not state are in the 5-minute bucket, the
167
+ * same assumption `cacheWriteUsdIfAssumed1h` measures the cost of.
168
+ */
169
+ cacheWrite5mTokens: number;
170
+ cacheWrite1hTokens: number;
171
+ outputTokens: number;
172
+ /**
173
+ * Calls whose cache-write TTL the log did not state, so the cheaper rate was
174
+ * assumed. Non-zero means this total is a floor on those calls, not a figure.
175
+ */
176
+ assumedWriteTtlCalls: number;
177
+ /**
178
+ * The largest single call's input, cache reads and writes included — the one
179
+ * number that says whether these calls would fit somewhere else.
180
+ *
181
+ * A cheaper model with a smaller context window does not make this traffic
182
+ * cheaper; it makes some of it impossible, and a price comparison that only
183
+ * multiplies rates would call that a saving. The maximum is the right
184
+ * statistic rather than the mean: one call over the ceiling is a failed
185
+ * call, and an average hides it.
186
+ */
187
+ maxCallInputTokens: number;
188
+ inputUsd: number;
189
+ cacheReadUsd: number;
190
+ cacheWriteUsd: number;
191
+ outputUsd: number;
192
+ totalUsd: number;
193
+ /**
194
+ * What the cache-touched tokens would have cost as ordinary input.
195
+ *
196
+ * Reads plus writes, at each model's own full input rate, accumulated per call
197
+ * because the rate is per model and a total loses that. Not part of `totalUsd`
198
+ * and not a bill — it is the other half of `cacheEconomics`, kept here because
199
+ * it can only be computed while the model is still in hand.
200
+ */
201
+ cachedTokensAtInputRateUsd: number;
202
+ /**
203
+ * `cacheWriteUsd` with every **unstated-TTL** write priced at the 1-hour rate.
204
+ *
205
+ * Equal to `cacheWriteUsd` when the log recorded which TTL each write used.
206
+ * When it did not, the cheaper 5-minute rate is assumed for the headline figure
207
+ * — and this is what the same calls cost if that assumption is wrong.
208
+ *
209
+ * It exists because the assumption reaches further than the total. It moves the
210
+ * *verdict*: a workload reading back between 0.28 and 1.11 tokens per token
211
+ * written is reported as paying for itself at 1.25x and as losing money at 2x,
212
+ * and the log is silent about which. A verdict that cannot see the assumption
213
+ * behind it states the flattering half as a fact.
214
+ */
215
+ cacheWriteUsdIfAssumed1h: number;
216
+ /**
217
+ * Calls whose answer hit the output ceiling, and what their output cost.
218
+ *
219
+ * The one category of a bill that is waste without a counterpart: an answer
220
+ * cut off mid-generation was paid for in full, is frequently retried — billed
221
+ * again — and the truncated attempt bought nothing. Output is the largest
222
+ * line on most bills, and this is the slice of it nobody sees.
223
+ */
224
+ truncatedCalls: number;
225
+ truncatedOutputUsd: number;
226
+ /** Calls that recorded a stop reason at all, truncated or not. */
227
+ stopReasonCalls: number;
228
+ }
229
+
230
+ export interface UsageProfileReport {
231
+ /** Everything, combined. */
232
+ total: UsageBreakdown;
233
+ /** Per `label`, largest bill first — the order somebody would act in. */
234
+ byLabel: Array<{ label: string; breakdown: UsageBreakdown }>;
235
+ /** Per model, largest bill first. */
236
+ byModel: Array<{ model: string; breakdown: UsageBreakdown }>;
237
+ /**
238
+ * Per label **and** model, largest bill first.
239
+ *
240
+ * The grouping a decision is actually made at. "Route `classify` to something
241
+ * cheaper" is a question about the calls `classify` makes to one model, and a
242
+ * label that spans two models has no single answer — pricing it against a
243
+ * cheaper candidate would mean picking one of the two current prices and
244
+ * applying it to tokens that were never billed at it.
245
+ */
246
+ byLabelAndModel: Array<{ label: string; model: string; breakdown: UsageBreakdown }>;
247
+ /**
248
+ * Models in the log that the pricing catalogue does not know.
249
+ *
250
+ * Named rather than silently costed at zero. A profile that quietly omits a
251
+ * model reports a total lower than the real bill, which is the flattering
252
+ * direction and the one this repository refuses.
253
+ */
254
+ unpricedModels: string[];
255
+ /**
256
+ * What those models used, kept entirely out of `total`.
257
+ *
258
+ * The first version added their **tokens** to the totals and their **dollars**
259
+ * to nothing, because pricing failed after the counts had been accumulated. So
260
+ * `total.inputTokens` included them and `total.inputUsd` did not, and anybody
261
+ * dividing one by the other got a cost per token that was wrong by however much
262
+ * of the log was unpriced — silently, and low.
263
+ *
264
+ * They are separated now. `total` is what could be priced, tokens and dollars
265
+ * describing the same calls. This is what could not, so the size of the gap is
266
+ * visible instead of being folded into a number that looks complete.
267
+ */
268
+ unpriced: UsageBreakdown;
269
+ /**
270
+ * Lines that could not be read, with their 1-based position.
271
+ *
272
+ * Reported rather than thrown on. A log with three malformed lines out of forty
273
+ * thousand should still produce a profile, and a parser that dies on the first
274
+ * one makes the tool unusable on real data — but a parser that skips quietly
275
+ * produces a total that is wrong by an unknown amount.
276
+ */
277
+ skippedLines: number[];
278
+ /**
279
+ * What re-sending the conversation costs, where the log carries a session.
280
+ *
281
+ * Empty when it does not, which is a different statement from zero growth — the
282
+ * report says which, because "nothing to report" and "nothing recorded" are the
283
+ * two answers a reader would act on differently.
284
+ */
285
+ conversations: ConversationGrowth[];
286
+ /** Whether any record carried a session at all. */
287
+ hasSessions: boolean;
288
+ /**
289
+ * Where the output spend concentrates, for slices whose output is a real share
290
+ * of the bill. The actionable half of "output dominates": six per cent of calls
291
+ * holding half the spend is a tail worth hunting, forty-five per cent is a task
292
+ * whose answers are inherently long — and the total cannot tell them apart.
293
+ */
294
+ outputShapes: OutputShape[];
295
+ /**
296
+ * How big a call's input is, and how uneven that is across a slice — the
297
+ * half of the bill a total could only name. "Input is 63% of this bill" is
298
+ * unactionable; whether the p95 call carries twelve times the median call's
299
+ * input decides between capping something and rewriting a prompt.
300
+ *
301
+ * Every figure is a bucket ceiling rather than an interpolated percentile,
302
+ * and slices with too few calls for a percentile to mean anything are left
303
+ * out entirely rather than reported at a precision they do not have.
304
+ */
305
+ inputShapes: InputShape[];
306
+ /**
307
+ * Calls that re-sent the previous call's exact input size, in the same
308
+ * conversation, seconds later — the shape of a retry or a loop.
309
+ *
310
+ * A conversation's input grows with every turn, so two consecutive calls
311
+ * carrying the same size a moment apart is a thing going wrong rather than
312
+ * a thing working. Needs both a session and a clock; empty when the log
313
+ * carries neither, which is a different statement from "none happened".
314
+ */
315
+ repeatedTurns: RepeatedTurns[];
316
+ /**
317
+ * The period the log covers, when its records carry a clock, over every
318
+ * parsed record — priced and unpriced alike, because when a call happened is
319
+ * a fact about the log rather than about the catalogue.
320
+ *
321
+ * `calls` is how many records carried a timestamp; compared against the
322
+ * parsed total it says whether the span describes the whole log or a slice
323
+ * of it, and the report states which. **The span is stated, never
324
+ * extrapolated**: "this log covers 13 days" makes the reader's own monthly
325
+ * arithmetic valid, while a per-month figure printed from a partial month
326
+ * would be this module doing the guessing it exists to end.
327
+ */
328
+ span: { fromMs: number; toMs: number; calls: number } | null;
329
+ /**
330
+ * Spend per UTC day, oldest first, over priced records that carry a clock.
331
+ *
332
+ * The shape of a bill over time is the finding the total hides: a steady $3 a
333
+ * day and a quiet week broken by one $40 spike sum to the same number and
334
+ * call for opposite responses. Each day carries its most expensive label so a
335
+ * spike arrives with a suspect attached — per *label*, never per session.
336
+ *
337
+ * UTC deliberately: the log's timestamps carry no zone once parsed, and
338
+ * bucketing by the reader's local midnight would make the same log answer
339
+ * differently in two offices.
340
+ */
341
+ spendByDay: Array<{
342
+ /** `YYYY-MM-DD`, UTC. */
343
+ day: string;
344
+ usd: number;
345
+ calls: number;
346
+ /** The label that spent the most this day, or null when nothing had one. */
347
+ topLabel: string | null;
348
+ topLabelUsd: number;
349
+ }>;
350
+ /**
351
+ * Lines that are exact duplicates of an earlier line, and what they added
352
+ * to the total.
353
+ *
354
+ * Reading a directory of rotated logs — or catting them together by hand —
355
+ * makes double-counting easy: a log exported twice, an overlapping export,
356
+ * a copy left in the folder. The bill then reads high, and nothing else in
357
+ * this report can see it, because two identical calls are indistinguishable
358
+ * from one call recorded twice *unless* the record carries a clock.
359
+ *
360
+ * So this counts only records with a `ts`: identical token counts, identical
361
+ * label and session, and the same millisecond. Two real calls colliding on
362
+ * all of that is possible and vanishingly unlikely; without a clock it is
363
+ * ordinary, which is why clockless records are excluded rather than guessed
364
+ * at. The report states the count and the money and stops — whether it is a
365
+ * double export or a busy millisecond is the reader's to know.
366
+ *
367
+ * The comparison is over the **raw line**, not a hash of it: a hash
368
+ * collision would report a duplicate that is not one, and this figure exists
369
+ * to make somebody distrust a total.
370
+ */
371
+ duplicateLines: { count: number; usd: number };
372
+ /**
373
+ * How many parsed records carried each optional field.
374
+ *
375
+ * Every finding this module makes beyond the totals needs a field the log
376
+ * format does not require, and a reader who never adds them sees a report
377
+ * quietly missing half of itself. Counting them turns "Trazum did not tell
378
+ * me about conversation growth" into "none of your 40,000 records carry a
379
+ * session", which is a fact somebody can act on in an afternoon.
380
+ *
381
+ * Counted over records that **parsed**, priced or not: whether a field is
382
+ * present is a property of the log, not of the price catalogue. Partial
383
+ * coverage is the interesting case and is why these are counts rather than
384
+ * booleans — 12 records out of 40,000 carrying a label is not "labelled",
385
+ * and a boolean would call it that.
386
+ */
387
+ fieldCoverage: {
388
+ /** Records with a usable `label`. */
389
+ label: number;
390
+ /** Records with a usable `session` or `conversation_id`. */
391
+ session: number;
392
+ /** Records with a readable timestamp. */
393
+ ts: number;
394
+ /** Records with a `stop_reason` or `finish_reason`. */
395
+ stopReason: number;
396
+ /** Records whose cache writes stated which TTL they used. */
397
+ cacheTtl: number;
398
+ /** Records that wrote to the cache at all — the denominator for `cacheTtl`. */
399
+ cacheWrites: number;
400
+ /** Every record that parsed, priced or not — the denominator for the rest. */
401
+ parsed: number;
402
+ };
403
+ /**
404
+ * Spend per hour of the UTC day, 0–23, over priced records that carry a
405
+ * clock — and only the hours that saw traffic.
406
+ *
407
+ * The shape a day has says what kind of workload this is. Spend packed into
408
+ * the hours a country is awake is interactive traffic somebody is waiting
409
+ * on; spend spread evenly across all twenty-four is background work, and
410
+ * background work is exactly what the Batch API halves the price of. The
411
+ * total cannot tell those apart, and neither can the per-day series.
412
+ *
413
+ * UTC deliberately, like `spendByDay`: bucketing by the reader's local hour
414
+ * would make the same log answer differently in two offices, and the log's
415
+ * timestamps carry no zone once parsed. A reader who knows their traffic is
416
+ * in one region can shift the hours themselves; Trazum inventing an offset
417
+ * would be guessing.
418
+ */
419
+ spendByHour: Array<{ hour: number; usd: number; calls: number }>;
420
+ /**
421
+ * Whether each slice's cache TTL fits how fast its turns arrive — the
422
+ * mechanism behind a losing cache verdict, and the one place an overlong TTL
423
+ * (2x writes surviving gaps measured in seconds) is ever visible. Needs
424
+ * `session` and a timestamp on the records; empty otherwise, which the
425
+ * report distinguishes from "measured and fine".
426
+ */
427
+ cacheTtlFit: CacheTtlFit[];
428
+ /**
429
+ * The time filter this report was computed under, or `null` when there was
430
+ * none — so a rendering can say "this is a window, not the log" instead of
431
+ * presenting a slice as the whole.
432
+ *
433
+ * `undatedExcluded` is the honesty cost of filtering by a clock some records
434
+ * do not carry: calls that passed every other filter but could not be
435
+ * placed in or out of the window. Non-zero means the window's figures are a
436
+ * floor on the period, and every rendering says so out loud.
437
+ */
438
+ timeWindow: { sinceMs: number | null; untilMs: number | null; undatedExcluded: number } | null;
439
+ /**
440
+ * Cache writes made by conversations that ended after one turn — reuse paid
441
+ * for that their own conversation never made. A ceiling named as one: the
442
+ * provider's cache is keyed by prefix, so another session sharing the
443
+ * prefix within the TTL could have read these writes, and the log cannot
444
+ * see whose write a read hit. When the slice's `cacheReadTokens` is zero
445
+ * the ceiling collapses into a fact — nothing read those writes at all —
446
+ * and the renderings say which of the two they are stating.
447
+ */
448
+ singleTurnCacheWrites: SingleTurnCacheWrites[];
449
+ /**
450
+ * What one conversation costs — median, p95 and maximum per slice, exact
451
+ * and billed. The question a total cannot answer: whether $4,000 is forty
452
+ * thousand cheap conversations or four hundred expensive ones, which is
453
+ * what a per-seat price or a quota is set from. Empty when the log carries
454
+ * no session, or when no slice has enough conversations for a median to
455
+ * mean anything.
456
+ */
457
+ sessionCosts: SessionCostShape[];
458
+ }
459
+
460
+ /** The share of the bill each part accounts for, as fractions of 1. */
461
+ export interface UsageShares {
462
+ input: number;
463
+ cacheRead: number;
464
+ cacheWrite: number;
465
+ output: number;
466
+ }
467
+
468
+ const EMPTY = (): UsageBreakdown => ({
469
+ calls: 0,
470
+ inputTokens: 0,
471
+ cacheReadTokens: 0,
472
+ cacheWriteTokens: 0,
473
+ cacheWrite5mTokens: 0,
474
+ cacheWrite1hTokens: 0,
475
+ outputTokens: 0,
476
+ assumedWriteTtlCalls: 0,
477
+ maxCallInputTokens: 0,
478
+ inputUsd: 0,
479
+ cacheReadUsd: 0,
480
+ cacheWriteUsd: 0,
481
+ outputUsd: 0,
482
+ totalUsd: 0,
483
+ cachedTokensAtInputRateUsd: 0,
484
+ cacheWriteUsdIfAssumed1h: 0,
485
+ truncatedCalls: 0,
486
+ truncatedOutputUsd: 0,
487
+ stopReasonCalls: 0,
488
+ });
489
+
490
+ /**
491
+ * A count, and whether the log actually said it.
492
+ *
493
+ * **Absent and corrupt are different, and conflating them cost the whole bill.**
494
+ * The first version used one helper that returned a fallback for both, so a field
495
+ * present as `"200000"` or `null` — a string count out of `jq`, a null out of a
496
+ * Postgres JSON round-trip — became a clean zero indistinguishable from a real
497
+ * one. The record survived, its token class vanished, and it was never added to
498
+ * `skippedLines`, so nothing on screen said a number had been thrown away.
499
+ *
500
+ * Measured on a two-line log with a stringified `input_tokens`: the report came to
501
+ * $0.0150 against a true $2.015, and the headline flipped to "output is 100% of
502
+ * this bill, so shortening prompts has a low ceiling" — the opposite of the truth
503
+ * on a workload that was almost entirely prompt.
504
+ *
505
+ * So: absent is a zero anybody may legitimately mean, and corrupt rejects the
506
+ * line.
507
+ */
508
+ type Count = { kind: 'ok'; value: number } | { kind: 'absent' } | { kind: 'corrupt' };
509
+
510
+ const OK = (value: number): Count => ({ kind: 'ok', value });
511
+
512
+ function readCount(...candidates: unknown[]): Count {
513
+ let sawCorrupt = false;
514
+ for (const value of candidates) {
515
+ if (value === undefined) continue;
516
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return OK(value);
517
+ // Present and unusable: a string, a null, a negative, a NaN.
518
+ sawCorrupt = true;
519
+ }
520
+ return sawCorrupt ? { kind: 'corrupt' } : { kind: 'absent' };
521
+ }
522
+
523
+ /** Zero for an absent count. Callers reject corrupt ones before reaching this. */
524
+ const valueOf = (count: Count): number => (count.kind === 'ok' ? count.value : 0);
525
+
526
+ /**
527
+ * One line of a usage log, or `null` when it is not one.
528
+ *
529
+ * Accepts the Anthropic shape and the OpenAI one, because those are the two
530
+ * things people actually have. The alternative — a Trazum-specific schema — asks
531
+ * for a transformation step before the tool will read anything, and a tool with a
532
+ * setup cost that exceeds its payoff does not get run twice.
533
+ *
534
+ * `null` in three cases, and the third is the one that was wrong:
535
+ *
536
+ * 1. Not JSON, or not an object, or no `model`.
537
+ * 2. **No** token counts at all — counting it would inflate the call count while
538
+ * contributing nothing, which lowers every per-call figure.
539
+ * 3. **Any** count present but unreadable. A field that is there and unusable is
540
+ * corruption, and a corrupt line belongs in `skippedLines` where the report
541
+ * names it, not in the totals as a silent zero.
542
+ */
543
+ /**
544
+ * A moment, from whatever a real log holds, in epoch milliseconds.
545
+ *
546
+ * The same three-state discipline as the counts: absent is fine (`null`),
547
+ * present-and-unreadable is corruption and rejects the line. A timestamp of
548
+ * `null` out of a Postgres round-trip, or `"yesterday"`, silently dropped would
549
+ * mis-measure every gap that record participates in — and unlike a wrong total,
550
+ * a wrong gap has nothing downstream to disagree with it.
551
+ *
552
+ * Numbers are epoch seconds or milliseconds, told apart by magnitude: anything
553
+ * from 1e12 up is milliseconds (September 2001 onward), anything from 1e8 up is
554
+ * seconds (March 1973 onward), and anything smaller names no real moment a
555
+ * usage log could contain. Strings go through `Date.parse`, which reads ISO
556
+ * 8601 — the format both `new Date().toISOString()` and every structured
557
+ * logger emit.
558
+ */
559
+ type Moment = { kind: 'ok'; ms: number } | { kind: 'absent' } | { kind: 'corrupt' };
560
+
561
+ function readMoment(...candidates: unknown[]): Moment {
562
+ let sawCorrupt = false;
563
+ for (const value of candidates) {
564
+ if (value === undefined) continue;
565
+ if (typeof value === 'number' && Number.isFinite(value)) {
566
+ if (value >= 1e12) return { kind: 'ok', ms: value };
567
+ if (value >= 1e8) return { kind: 'ok', ms: value * 1000 };
568
+ sawCorrupt = true;
569
+ continue;
570
+ }
571
+ if (typeof value === 'string') {
572
+ const parsed = Date.parse(value);
573
+ if (Number.isFinite(parsed)) return { kind: 'ok', ms: parsed };
574
+ }
575
+ sawCorrupt = true;
576
+ }
577
+ return sawCorrupt ? { kind: 'corrupt' } : { kind: 'absent' };
578
+ }
579
+
580
+ export function parseUsageLine(line: string): UsageRecord | null {
581
+ let raw: unknown;
582
+ try {
583
+ raw = JSON.parse(line);
584
+ } catch {
585
+ return null;
586
+ }
587
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
588
+
589
+ const record = raw as Record<string, unknown>;
590
+ // Anthropic nests usage on a response; a hand-rolled log usually flattens it.
591
+ const usage =
592
+ typeof record.usage === 'object' && record.usage !== null
593
+ ? (record.usage as Record<string, unknown>)
594
+ : record;
595
+
596
+ const model = typeof record.model === 'string' ? record.model : null;
597
+ if (!model) return null;
598
+
599
+ /**
600
+ * OpenAI reports cached tokens inside `prompt_tokens_details` **and counts them
601
+ * in `prompt_tokens`**, while Anthropic reports them separately and does not.
602
+ * Subtracting in one case and not the other is the difference between a correct
603
+ * bill and one that charges the cached half twice.
604
+ */
605
+ const details =
606
+ typeof usage.prompt_tokens_details === 'object' && usage.prompt_tokens_details !== null
607
+ ? (usage.prompt_tokens_details as Record<string, unknown>)
608
+ : null;
609
+ const openAiCached = details ? readCount(details.cached_tokens) : ({ kind: 'absent' } as Count);
610
+
611
+ /**
612
+ * Anthropic splits cache writes by time-to-live, and the two cost different
613
+ * amounts: 1.25x input for the 5-minute entry, **2x** for the 1-hour one.
614
+ *
615
+ * Reading only the flat `cache_creation_input_tokens` threw that distinction
616
+ * away and then priced everything at the cheaper rate — a 1-hour workload
617
+ * reported 37.5% under, silently, on its largest line. The split is in the log
618
+ * whenever the recording recipe in the README is followed, because it is part of
619
+ * the `usage` object the API returns.
620
+ */
621
+ const creation =
622
+ typeof usage.cache_creation === 'object' && usage.cache_creation !== null
623
+ ? (usage.cache_creation as Record<string, unknown>)
624
+ : null;
625
+ const write5m = creation ? readCount(creation.ephemeral_5m_input_tokens) : ({ kind: 'absent' } as Count);
626
+ const write1h = creation ? readCount(creation.ephemeral_1h_input_tokens) : ({ kind: 'absent' } as Count);
627
+
628
+ const counts: Record<string, Count> = {
629
+ input: readCount(usage.input_tokens, usage.inputTokens, usage.prompt_tokens),
630
+ output: readCount(usage.output_tokens, usage.outputTokens, usage.completion_tokens),
631
+ cacheRead: readCount(usage.cache_read_input_tokens, usage.cacheReadTokens),
632
+ cacheWrite: readCount(usage.cache_creation_input_tokens, usage.cacheWriteTokens),
633
+ openAiCached,
634
+ write5m,
635
+ write1h,
636
+ };
637
+
638
+ // Any field present and unreadable rejects the line. See `readCount`.
639
+ if (Object.values(counts).some((c) => c.kind === 'corrupt')) return null;
640
+ // Nothing to count at all.
641
+ if (Object.values(counts).every((c) => c.kind === 'absent')) return null;
642
+
643
+ /**
644
+ * The clock, under the same rule as the counts: a timestamp that is present
645
+ * and unreadable rejects the line rather than becoming a silent absence.
646
+ * `created` is where OpenAI responses carry it (epoch seconds), so a log
647
+ * written by spreading the response already has one.
648
+ */
649
+ const moment = readMoment(record.ts, record.timestamp, record.created_at, record.created);
650
+ if (moment.kind === 'corrupt') return null;
651
+
652
+ const cached = valueOf(counts.openAiCached!);
653
+ const flatWrite = valueOf(counts.cacheWrite!);
654
+ const split5m = valueOf(counts.write5m!);
655
+ const split1h = valueOf(counts.write1h!);
656
+ const hasSplit = counts.write5m!.kind === 'ok' || counts.write1h!.kind === 'ok';
657
+
658
+ return {
659
+ model,
660
+ inputTokens: Math.max(0, valueOf(counts.input!) - cached),
661
+ cacheReadTokens: counts.cacheRead!.kind === 'ok' ? counts.cacheRead!.value : cached,
662
+ /**
663
+ * The split when the log carries it, the flat number otherwise — and
664
+ * `writeTtlKnown` says which, so the report can admit that a rate was assumed
665
+ * rather than quietly choosing the cheaper one.
666
+ */
667
+ cacheWrite5mTokens: hasSplit ? split5m : flatWrite,
668
+ cacheWrite1hTokens: hasSplit ? split1h : 0,
669
+ writeTtlKnown: hasSplit || flatWrite === 0,
670
+ outputTokens: valueOf(counts.output!),
671
+ /**
672
+ * Trimmed and internally normalised: any whitespace run becomes one space.
673
+ * A label is a workload name, and it is also used as half of structured keys
674
+ * that split on `\n` — `byLabelAndModel`, the conversation tracker, the
675
+ * output-shape tracker. A label carrying a newline would corrupt that split
676
+ * and mis-file every figure under a truncated name; normalising at the one
677
+ * boundary where labels enter keeps every consumer honest at once.
678
+ */
679
+ label: nameOf(record.label),
680
+ /**
681
+ * Read from either spelling, because both are what people already have:
682
+ * `session` in a hand-rolled log, `conversation_id` in most chat schemas.
683
+ * Refusing one of them would make the field's adoption a chore, and a field
684
+ * nobody sets measures nothing.
685
+ */
686
+ session: nameOf(record.session) ?? nameOf(record.conversation_id),
687
+ ts: moment.kind === 'ok' ? moment.ms : null,
688
+ /**
689
+ * Anthropic spells it `stop_reason: "max_tokens"`, OpenAI
690
+ * `finish_reason: "length"`. Any other recorded reason is a completed
691
+ * answer; an absent field is `null`, which is "not measured" and not "did
692
+ * not happen" — the report treats those differently on purpose.
693
+ */
694
+ truncated: (() => {
695
+ const reason = record.stop_reason ?? record.finish_reason;
696
+ if (typeof reason !== 'string') return null;
697
+ return reason === 'max_tokens' || reason === 'length';
698
+ })(),
699
+ };
700
+ }
701
+
702
+ /**
703
+ * A label or session identifier, from whatever a real log holds.
704
+ *
705
+ * **Numbers are identifiers too.** A conversation id is an auto-incremented
706
+ * integer in half the databases in existence, and the string-only version
707
+ * dropped `session: 12345` silently and then printed "No call in this log
708
+ * carried a session" — a false claim about a log that carried one on every
709
+ * line. A finite number is taken by its decimal form; booleans, objects and
710
+ * non-finite numbers stay out, because `session: true` names nothing.
711
+ *
712
+ * Strings are trimmed and internally normalised — any whitespace run becomes
713
+ * one space — because labels are half of structured keys that split on a
714
+ * newline, and a label carrying one would mis-file every figure it touches.
715
+ */
716
+ function nameOf(value: unknown): string | null {
717
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
718
+ if (typeof value !== 'string') return null;
719
+ const trimmed = value.trim().replace(/\s+/g, ' ');
720
+ return trimmed === '' ? null : trimmed;
721
+ }
722
+
723
+ /**
724
+ * The bucket unlabelled calls land in.
725
+ *
726
+ * The empty string, because it is the one value a parsed label can never be —
727
+ * `parseUsageLine` trims and rejects empty. The first version used the literal
728
+ * string `'unlabelled'`, and a workload somebody had actually named `unlabelled`
729
+ * merged silently into the missing-label bucket: 200 labelled calls and 200
730
+ * unlabelled ones reported as one row of 400, and the "none of these calls
731
+ * carried a label" warning fired over a log where half of them had.
732
+ *
733
+ * Presentation stays in the CLI, which translates this sentinel through the
734
+ * message catalogue; data consumers can tell `''` from any real label.
735
+ */
736
+ export const UNLABELLED = '';
737
+
738
+ /** Token counts only. Used for both halves, because both need them. */
739
+ function countInto(into: UsageBreakdown, record: UsageRecord): void {
740
+ into.calls += 1;
741
+ into.inputTokens += record.inputTokens;
742
+ into.cacheReadTokens += record.cacheReadTokens;
743
+ into.cacheWriteTokens += record.cacheWrite5mTokens + record.cacheWrite1hTokens;
744
+ into.cacheWrite5mTokens += record.cacheWrite5mTokens;
745
+ into.cacheWrite1hTokens += record.cacheWrite1hTokens;
746
+ if (!record.writeTtlKnown) into.assumedWriteTtlCalls += 1;
747
+ into.outputTokens += record.outputTokens;
748
+ into.maxCallInputTokens = Math.max(
749
+ into.maxCallInputTokens,
750
+ record.inputTokens + record.cacheReadTokens + record.cacheWrite5mTokens + record.cacheWrite1hTokens,
751
+ );
752
+ if (record.truncated !== null) {
753
+ into.stopReasonCalls += 1;
754
+ if (record.truncated) into.truncatedCalls += 1;
755
+ }
756
+ }
757
+
758
+ function add(into: UsageBreakdown, record: UsageRecord, catalogue: PricingCatalogue, on: Date): boolean {
759
+ /**
760
+ * Looked up directly rather than through `modelFrom`, which **throws** on an id
761
+ * it does not know. A usage log is somebody's production traffic and will
762
+ * contain models this catalogue has never heard of — a fine-tune, a preview, a
763
+ * competitor. Throwing means one unfamiliar id destroys the whole profile;
764
+ * naming it separately means the report is honest about what it could not price
765
+ * and useful about everything else.
766
+ *
767
+ * **Priced first, counted second.** The other order was the bug: counts landed
768
+ * before the lookup could fail, so an unpriced call contributed tokens to a
769
+ * total whose dollars excluded it.
770
+ */
771
+ const model = catalogue.byId.get(record.model);
772
+ if (!model) return false;
773
+
774
+ countInto(into, record);
775
+ const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
776
+ const rates = multipliersFor(model);
777
+ const per = (tokens: number, rate: number): number => (tokens / 1_000_000) * rate;
778
+
779
+ into.inputUsd += per(record.inputTokens, inputPerMTok);
780
+ into.cacheReadUsd += per(record.cacheReadTokens, inputPerMTok * rates.cacheRead);
781
+ /**
782
+ * Each TTL at its own rate. Anthropic charges 1.25x input for a 5-minute entry
783
+ * and 2x for a 1-hour one, and the first version applied 1.25x to both — 37.5%
784
+ * under on a 1-hour workload, on the largest line, with nothing on screen
785
+ * saying a rate had been chosen.
786
+ */
787
+ into.cacheWriteUsd += per(record.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m);
788
+ into.cacheWriteUsd += per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h);
789
+ /**
790
+ * The same writes with the assumption taken the other way.
791
+ *
792
+ * A record whose TTL the log did not state has all of its writes in the
793
+ * 5-minute bucket — `parseUsageLine` puts them there because one rate has to be
794
+ * chosen — so this prices exactly those at the 1-hour rate instead. Accumulated
795
+ * per call, and per model, because the ratio between the two rates is not a
796
+ * constant: 2.0/1.25 on Anthropic, 1.0/1.0 where a write costs what input
797
+ * costs. Scaling the total afterwards would invent a premium for providers that
798
+ * have none.
799
+ */
800
+ const writeRateIfWrong = record.writeTtlKnown ? rates.cacheWrite5m : rates.cacheWrite1h;
801
+ into.cacheWriteUsdIfAssumed1h += per(record.cacheWrite5mTokens, inputPerMTok * writeRateIfWrong);
802
+ into.cacheWriteUsdIfAssumed1h += per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h);
803
+ into.outputUsd += per(record.outputTokens, outputPerMTok);
804
+ if (record.truncated === true) {
805
+ into.truncatedOutputUsd += per(record.outputTokens, outputPerMTok);
806
+ }
807
+ /**
808
+ * The same cache-touched tokens at the plain input rate, banked here because
809
+ * `inputPerMTok` is per model and is gone by the time anybody reads the total.
810
+ */
811
+ into.cachedTokensAtInputRateUsd += per(
812
+ record.cacheReadTokens + record.cacheWrite5mTokens + record.cacheWrite1hTokens,
813
+ inputPerMTok,
814
+ );
815
+ into.totalUsd =
816
+ into.inputUsd + into.cacheReadUsd + into.cacheWriteUsd + into.outputUsd;
817
+ return true;
818
+ }
819
+
820
+ export interface UsageProfileOptions {
821
+ catalogue: PricingCatalogue;
822
+ /** Date the prices are read at, so a promotional rate resolves the same way. */
823
+ on?: Date;
824
+ /**
825
+ * Profile only the records carrying this label — the drill-down, once the
826
+ * full report has named a suspect. `UNLABELLED` (the empty string) selects
827
+ * the records with no label at all. Unreadable lines still land in
828
+ * `skippedLines` whatever they might have been labelled: a filter must not
829
+ * make corruption disappear.
830
+ */
831
+ label?: string;
832
+ /**
833
+ * Profile only the records whose clock falls in `[sinceMs, untilMs)` — the
834
+ * drill-down in time, once the peak day or the span has named a period.
835
+ *
836
+ * Epoch milliseconds, half-open on the right so two adjacent windows share
837
+ * no record. Either bound alone works. **A record with no clock cannot be
838
+ * placed inside or outside a window**, so under a time filter it is
839
+ * excluded and counted in `timeWindow.undatedExcluded` — excluded, because
840
+ * including it would put unknown-time spend inside a window it may not
841
+ * belong to; counted, because dropping it silently would understate the
842
+ * period's bill by an invisible amount, which is the flattering direction.
843
+ */
844
+ sinceMs?: number;
845
+ untilMs?: number;
846
+ }
847
+
848
+ /**
849
+ * Reads a usage log and says where the money went.
850
+ *
851
+ * Takes the whole text rather than a stream: a usage log is measured in megabytes
852
+ * and this package imports no Node builtins, so streaming would mean an interface
853
+ * the browser build cannot satisfy. `@trazum/core/node` is where file reading
854
+ * lives, and it can chunk if it ever needs to.
855
+ */
856
+ export function profileUsage(text: string, options: UsageProfileOptions): UsageProfileReport {
857
+ const { catalogue, on = new Date(), label: onlyLabel, sinceMs, untilMs } = options;
858
+ const windowed = sinceMs !== undefined || untilMs !== undefined;
859
+ let undatedExcluded = 0;
860
+
861
+ const total = EMPTY();
862
+ const unpriced = EMPTY();
863
+ const byLabel = new Map<string, UsageBreakdown>();
864
+ const byModel = new Map<string, UsageBreakdown>();
865
+ // Keyed on a pair, so the key carries a separator that cannot occur in either
866
+ // half. A model id is `[A-Za-z0-9._-]`, so a newline is safe in both.
867
+ const byPair = new Map<string, UsageBreakdown>();
868
+ const unpricedModels = new Set<string>();
869
+ const skippedLines: number[] = [];
870
+ /**
871
+ * Fed in the pass this function already makes, rather than by keeping the records
872
+ * for a second one: a usage log is measured in megabytes, and what this holds is
873
+ * bounded by the number of conversations instead.
874
+ */
875
+ const conversations = createConversationTracker({ catalogue, on });
876
+ const output = createOutputShapeTracker({ catalogue, on });
877
+ const input = createInputShapeTracker({ catalogue, on });
878
+ const repeats = createRepeatsTracker({ catalogue, on });
879
+ const ttlFit = createTtlFitTracker({ catalogue, on });
880
+ const ledger = createSessionLedgerTracker({ catalogue, on });
881
+ const sessionCosts = createSessionCostTracker({ catalogue, on });
882
+ let hasSessions = false;
883
+ const coverage = { label: 0, session: 0, ts: 0, stopReason: 0, cacheTtl: 0, cacheWrites: 0, parsed: 0 };
884
+ /**
885
+ * Raw lines already seen, for the duplicate check. Bounded by the number of
886
+ * *timestamped* lines — the price of catching a doubled bill, paid only on
887
+ * logs that carry a clock.
888
+ */
889
+ const seenLines = new Set<string>();
890
+ const duplicates = { count: 0, usd: 0 };
891
+ let spanFrom = Infinity;
892
+ let spanTo = -Infinity;
893
+ let spanCalls = 0;
894
+ /** Per UTC day: spend, calls, and spend per label. Bounded by days × labels. */
895
+ const days = new Map<string, { usd: number; calls: number; byLabel: Map<string, number> }>();
896
+ /** Per hour of the UTC day. Bounded by twenty-four entries, whatever the log. */
897
+ const hours = new Map<number, { usd: number; calls: number }>();
898
+
899
+ const lines = text.split('\n');
900
+ for (let i = 0; i < lines.length; i += 1) {
901
+ const line = lines[i]!.trim();
902
+ if (line === '') continue;
903
+
904
+ const record = parseUsageLine(line);
905
+ if (!record) {
906
+ skippedLines.push(i + 1);
907
+ continue;
908
+ }
909
+
910
+ // The drill-down: after the skip accounting, so a corrupt line is reported
911
+ // whatever it might have been labelled.
912
+ if (onlyLabel !== undefined && (record.label ?? UNLABELLED) !== onlyLabel) continue;
913
+
914
+ /**
915
+ * The time window, after the label filter so `undatedExcluded` counts only
916
+ * the selected workload's clockless calls — a count polluted by every other
917
+ * label's records would overstate how much of *this* answer is missing.
918
+ * Records outside the window are simply not the question; records with no
919
+ * clock are the question left unanswerable, so they are counted.
920
+ */
921
+ if (windowed) {
922
+ if (record.ts === null) {
923
+ undatedExcluded += 1;
924
+ continue;
925
+ }
926
+ if (sinceMs !== undefined && record.ts < sinceMs) continue;
927
+ if (untilMs !== undefined && record.ts >= untilMs) continue;
928
+ }
929
+
930
+ /**
931
+ * Coverage is counted before pricing: whether a field is present is a
932
+ * property of the log, and a record on an unknown model still says
933
+ * whether somebody set `label`.
934
+ */
935
+ coverage.parsed += 1;
936
+ if (record.label !== null) coverage.label += 1;
937
+ if (record.session !== null) coverage.session += 1;
938
+ if (record.ts !== null) coverage.ts += 1;
939
+ if (record.truncated !== null) coverage.stopReason += 1;
940
+ if (record.cacheWrite5mTokens + record.cacheWrite1hTokens > 0) {
941
+ coverage.cacheWrites += 1;
942
+ if (record.writeTtlKnown) coverage.cacheTtl += 1;
943
+ }
944
+
945
+ if (record.session !== null) hasSessions = true;
946
+ if (record.ts !== null) {
947
+ spanFrom = Math.min(spanFrom, record.ts);
948
+ spanTo = Math.max(spanTo, record.ts);
949
+ spanCalls += 1;
950
+ }
951
+ conversations.add(record);
952
+ output.add(record);
953
+ input.add(record);
954
+ repeats.add(record);
955
+ ttlFit.add(record);
956
+ ledger.add(record);
957
+ sessionCosts.add(record);
958
+
959
+ const usdBefore = total.totalUsd;
960
+ if (!add(total, record, catalogue, on)) {
961
+ unpricedModels.add(record.model);
962
+ countInto(unpriced, record);
963
+ // Still grouped by model, so the reader can see which unknown id is costing
964
+ // them attention — but with zero dollars, which the grouping makes obvious.
965
+ if (!byModel.has(record.model)) byModel.set(record.model, EMPTY());
966
+ countInto(byModel.get(record.model)!, record);
967
+ continue;
968
+ }
969
+
970
+ /**
971
+ * A line identical to one already read, with a clock to make the claim
972
+ * safe. Counted after pricing so the money is the exact delta this line
973
+ * added, which is what a doubled bill is overstated by.
974
+ */
975
+ if (record.ts !== null) {
976
+ if (seenLines.has(line)) {
977
+ duplicates.count += 1;
978
+ duplicates.usd += total.totalUsd - usdBefore;
979
+ } else {
980
+ seenLines.add(line);
981
+ }
982
+ }
983
+
984
+ /**
985
+ * The day's spend, as the exact delta this record just added to the total —
986
+ * the one place the per-record dollar exists without re-deriving the rate
987
+ * arithmetic a second time, where the two could drift apart.
988
+ */
989
+ if (record.ts !== null) {
990
+ const day = new Date(record.ts).toISOString().slice(0, 10);
991
+ const usd = total.totalUsd - usdBefore;
992
+ let entry = days.get(day);
993
+ if (!entry) {
994
+ entry = { usd: 0, calls: 0, byLabel: new Map() };
995
+ days.set(day, entry);
996
+ }
997
+ entry.usd += usd;
998
+ entry.calls += 1;
999
+ const labelKey = record.label ?? UNLABELLED;
1000
+ entry.byLabel.set(labelKey, (entry.byLabel.get(labelKey) ?? 0) + usd);
1001
+
1002
+ // The same exact per-record dollar, bucketed by hour of the UTC day.
1003
+ const hour = new Date(record.ts).getUTCHours();
1004
+ const hourEntry = hours.get(hour);
1005
+ if (hourEntry) {
1006
+ hourEntry.usd += usd;
1007
+ hourEntry.calls += 1;
1008
+ } else {
1009
+ hours.set(hour, { usd, calls: 1 });
1010
+ }
1011
+ }
1012
+
1013
+ const labelKey = record.label ?? UNLABELLED;
1014
+ if (!byLabel.has(labelKey)) byLabel.set(labelKey, EMPTY());
1015
+ add(byLabel.get(labelKey)!, record, catalogue, on);
1016
+
1017
+ if (!byModel.has(record.model)) byModel.set(record.model, EMPTY());
1018
+ add(byModel.get(record.model)!, record, catalogue, on);
1019
+
1020
+ const pairKey = `${labelKey}\n${record.model}`;
1021
+ if (!byPair.has(pairKey)) byPair.set(pairKey, EMPTY());
1022
+ add(byPair.get(pairKey)!, record, catalogue, on);
1023
+ }
1024
+
1025
+ const sorted = <K extends string>(
1026
+ map: Map<string, UsageBreakdown>,
1027
+ key: K,
1028
+ ): Array<Record<K, string> & { breakdown: UsageBreakdown }> =>
1029
+ [...map.entries()]
1030
+ .sort((a, b) => b[1].totalUsd - a[1].totalUsd || a[0].localeCompare(b[0]))
1031
+ .map(([name, breakdown]) => ({ [key]: name, breakdown }) as Record<K, string> & {
1032
+ breakdown: UsageBreakdown;
1033
+ });
1034
+
1035
+ return {
1036
+ total,
1037
+ byLabel: sorted(byLabel, 'label'),
1038
+ byModel: sorted(byModel, 'model'),
1039
+ byLabelAndModel: [...byPair.entries()]
1040
+ .sort((a, b) => b[1].totalUsd - a[1].totalUsd || a[0].localeCompare(b[0]))
1041
+ .map(([key, breakdown]) => {
1042
+ const split = key.indexOf('\n');
1043
+ return { label: key.slice(0, split), model: key.slice(split + 1), breakdown };
1044
+ }),
1045
+ unpricedModels: [...unpricedModels].sort(),
1046
+ unpriced,
1047
+ skippedLines,
1048
+ conversations: conversations.finish(total.totalUsd),
1049
+ hasSessions,
1050
+ outputShapes: output.finish(total.totalUsd),
1051
+ inputShapes: input.finish(total.totalUsd),
1052
+ repeatedTurns: repeats.finish(),
1053
+ span: spanCalls > 0 ? { fromMs: spanFrom, toMs: spanTo, calls: spanCalls } : null,
1054
+ spendByDay: [...days.entries()]
1055
+ .sort((a, b) => a[0].localeCompare(b[0]))
1056
+ .map(([day, entry]) => {
1057
+ let topLabel: string | null = null;
1058
+ let topLabelUsd = 0;
1059
+ for (const [label, usd] of entry.byLabel) {
1060
+ if (usd > topLabelUsd) {
1061
+ topLabel = label;
1062
+ topLabelUsd = usd;
1063
+ }
1064
+ }
1065
+ return { day, usd: entry.usd, calls: entry.calls, topLabel, topLabelUsd };
1066
+ }),
1067
+ duplicateLines: duplicates,
1068
+ fieldCoverage: coverage,
1069
+ spendByHour: [...hours.entries()]
1070
+ .sort((a, b) => a[0] - b[0])
1071
+ .map(([hour, entry]) => ({ hour, usd: entry.usd, calls: entry.calls })),
1072
+ cacheTtlFit: ttlFit.finish(),
1073
+ timeWindow: windowed
1074
+ ? { sinceMs: sinceMs ?? null, untilMs: untilMs ?? null, undatedExcluded }
1075
+ : null,
1076
+ singleTurnCacheWrites: ledger.finish(),
1077
+ sessionCosts: sessionCosts.finish(),
1078
+ };
1079
+ }
1080
+
1081
+ /**
1082
+ * What share of the bill each part is.
1083
+ *
1084
+ * The point of the whole module in one function: a caller can print "output is
1085
+ * 87% of this" without doing arithmetic that would drift from the arithmetic
1086
+ * here.
1087
+ *
1088
+ * All zeroes when nothing was spent, rather than `NaN`. A profile of an empty log
1089
+ * is a legitimate result — no calls yet — and a report full of `NaN%` is a bug
1090
+ * report from somebody who did nothing wrong.
1091
+ */
1092
+ export function sharesOf(breakdown: UsageBreakdown): UsageShares {
1093
+ const { totalUsd } = breakdown;
1094
+ if (totalUsd <= 0) return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
1095
+ return {
1096
+ input: breakdown.inputUsd / totalUsd,
1097
+ cacheRead: breakdown.cacheReadUsd / totalUsd,
1098
+ cacheWrite: breakdown.cacheWriteUsd / totalUsd,
1099
+ output: breakdown.outputUsd / totalUsd,
1100
+ };
1101
+ }
1102
+
1103
+ /**
1104
+ * How much of the input that could have been cached was.
1105
+ *
1106
+ * `null` when nothing was cacheable-looking at all — no reads and no writes —
1107
+ * because a hit rate over zero attempts is not zero, it is undefined, and
1108
+ * printing "0% cache hit rate" for somebody who never turned caching on is a
1109
+ * finding about nothing.
1110
+ *
1111
+ * Reads against reads-plus-full-price-input, deliberately. Cache *writes* are
1112
+ * excluded from the denominator: a write is the cost of establishing an entry,
1113
+ * not a missed read, and counting it as a miss makes a healthy cache look broken
1114
+ * on the day it warms.
1115
+ */
1116
+ export function cacheHitRate(breakdown: UsageBreakdown): number | null {
1117
+ const attempts = breakdown.cacheReadTokens + breakdown.inputTokens;
1118
+ if (breakdown.cacheReadTokens === 0 && breakdown.cacheWriteTokens === 0) return null;
1119
+ if (attempts === 0) return null;
1120
+ return breakdown.cacheReadTokens / attempts;
1121
+ }
1122
+
1123
+ /** What caching did to this bill, measured against the same tokens uncached. */
1124
+ export interface CacheEconomics {
1125
+ /** What the cache-touched tokens actually cost: reads plus writes. */
1126
+ spentUsd: number;
1127
+ /** What those same tokens would have cost billed as ordinary input. */
1128
+ withoutCachingUsd: number;
1129
+ /**
1130
+ * `spentUsd - withoutCachingUsd`.
1131
+ *
1132
+ * **Positive means caching cost more than it saved** — the opposite of the sign
1133
+ * convention everywhere else in Trazum, and deliberately so, because this is the
1134
+ * number nobody expects to come out positive and the one worth interrupting for.
1135
+ */
1136
+ deltaUsd: number;
1137
+ /**
1138
+ * Read tokens per write token, or `null` when nothing was written.
1139
+ *
1140
+ * Context for the delta, not a verdict of its own: the delta already decides,
1141
+ * and it decides at the real per-model rates. This says *why* — a ratio near
1142
+ * zero on an Anthropic workload is a prefix being rebuilt faster than it is
1143
+ * reused, which is the shape of a cache that never gets to work.
1144
+ */
1145
+ readsPerWrite: number | null;
1146
+ verdict: CacheVerdict;
1147
+ /**
1148
+ * `deltaUsd` with every write whose TTL the log did not state priced at the
1149
+ * 1-hour rate instead of the assumed 5-minute one.
1150
+ *
1151
+ * Equal to `deltaUsd` when every TTL was recorded, and never smaller: the
1152
+ * 1-hour multiplier is at or above the 5-minute one on every model in the
1153
+ * catalogue, so this is a genuine worst case rather than the other end of a
1154
+ * range.
1155
+ */
1156
+ worstCaseDeltaUsd: number;
1157
+ /**
1158
+ * The verdict at that worst case.
1159
+ *
1160
+ * **When this differs from `verdict`, the log cannot settle the question** and
1161
+ * neither can any report built from it. That is not a rare shape: a workload
1162
+ * reading back between 0.28 and 1.11 tokens per token written flips between
1163
+ * `paid-off` and `lost-money` on the TTL alone, and a log carrying only the flat
1164
+ * `cache_creation_input_tokens` never says which. Measured on a million written
1165
+ * tokens against three hundred thousand read back, the difference was a $0.10
1166
+ * saving against a $3.65 loss — a $3.75 swing across the sign, and the assumed
1167
+ * half is the flattering one.
1168
+ */
1169
+ worstCaseVerdict: CacheVerdict;
1170
+ }
1171
+
1172
+ /**
1173
+ * - `paid-off` — caching took money off the bill.
1174
+ * - `lost-money` — caching added to it. Possible on Anthropic, where a write
1175
+ * costs 1.25x input (5-minute) or 2x (1-hour); a prefix that never gets read
1176
+ * back is billed at a premium for nothing.
1177
+ * - `no-difference` — the multipliers cancelled out. This is where automatic
1178
+ * caching with a 1x write rate lands when nothing was ever read.
1179
+ * - `not-attempted` — no cache tokens at all, in either direction.
1180
+ * - `unpriced` — cache tokens with no prices behind them, so there is no
1181
+ * comparison to make. Saying nothing is the only honest answer.
1182
+ */
1183
+ export type CacheVerdict =
1184
+ | 'paid-off'
1185
+ | 'lost-money'
1186
+ | 'no-difference'
1187
+ | 'not-attempted'
1188
+ | 'unpriced';
1189
+
1190
+ /**
1191
+ * Floating-point noise, not a judgement threshold.
1192
+ *
1193
+ * Summing a million per-call doubles around a $100 bill accumulates roughly
1194
+ * `n · eps · magnitude` ≈ $2e-8 of drift, and a verdict that flipped on that would
1195
+ * be reporting arithmetic error as a finding. A millionth of a dollar is orders of
1196
+ * magnitude below anything this tool prints, so nothing real is being rounded away
1197
+ * — deliberately not a "too small to care about" cutoff, which would be a
1198
+ * judgement and would belong somewhere a reader can see it.
1199
+ */
1200
+ const CACHE_DELTA_NOISE_USD = 1e-6;
1201
+
1202
+ /**
1203
+ * Did caching pay for itself?
1204
+ *
1205
+ * The question nothing else in this package can answer, and the one that decides
1206
+ * whether the advice the rest of it gives was right. Trazum tells people to cache;
1207
+ * on Anthropic a cache **write** costs 1.25x plain input at the 5-minute TTL and
1208
+ * **2x** at the 1-hour one, so a prefix that changes faster than it is reused is
1209
+ * billed at a premium and returns nothing. That workload would be cheaper with
1210
+ * caching switched off, and no other report in this repository would ever say so.
1211
+ *
1212
+ * The counterfactual is exact, which is why this is allowed to exist here at all:
1213
+ * caching changes the multiplier on a token, never the token. Had `cache_control`
1214
+ * not been set, the identical prefix would have gone up as ordinary input at 1x.
1215
+ * So `withoutCachingUsd` is not an estimate of a different call — it is the same
1216
+ * call, arithmetic away.
1217
+ *
1218
+ * Worth running per label as well as over the whole log. A profitable cache on one
1219
+ * workload and a bleeding one on another net out to a comfortable-looking total,
1220
+ * and the aggregate is exactly where a loss hides.
1221
+ */
1222
+ export function cacheEconomics(breakdown: UsageBreakdown): CacheEconomics {
1223
+ const touchedTokens = breakdown.cacheReadTokens + breakdown.cacheWriteTokens;
1224
+ const spentUsd = breakdown.cacheReadUsd + breakdown.cacheWriteUsd;
1225
+ const withoutCachingUsd = breakdown.cachedTokensAtInputRateUsd;
1226
+
1227
+ const none = (verdict: CacheVerdict): CacheEconomics => ({
1228
+ spentUsd,
1229
+ withoutCachingUsd,
1230
+ deltaUsd: 0,
1231
+ readsPerWrite: null,
1232
+ verdict,
1233
+ worstCaseDeltaUsd: 0,
1234
+ worstCaseVerdict: verdict,
1235
+ });
1236
+
1237
+ if (touchedTokens === 0) return none('not-attempted');
1238
+ /**
1239
+ * Tokens went through the cache and no money is attached to either side, so
1240
+ * there is nothing to compare. This is what an unpriced model looks like: the
1241
+ * counts accumulate through `countInto` and the dollars never do. Without this
1242
+ * guard the delta is `0 - 0` and the verdict comes out `no-difference` — a
1243
+ * confident claim about a bill that was never computed.
1244
+ */
1245
+ if (spentUsd === 0 && withoutCachingUsd === 0) return none('unpriced');
1246
+
1247
+ const deltaUsd = spentUsd - withoutCachingUsd;
1248
+ const worstCaseDeltaUsd =
1249
+ breakdown.cacheReadUsd + breakdown.cacheWriteUsdIfAssumed1h - withoutCachingUsd;
1250
+ const readsPerWrite =
1251
+ breakdown.cacheWriteTokens === 0
1252
+ ? null
1253
+ : breakdown.cacheReadTokens / breakdown.cacheWriteTokens;
1254
+
1255
+ const decide = (delta: number): CacheVerdict =>
1256
+ Math.abs(delta) < CACHE_DELTA_NOISE_USD ? 'no-difference' : delta > 0 ? 'lost-money' : 'paid-off';
1257
+
1258
+ return {
1259
+ spentUsd,
1260
+ withoutCachingUsd,
1261
+ deltaUsd,
1262
+ readsPerWrite,
1263
+ verdict: decide(deltaUsd),
1264
+ worstCaseDeltaUsd,
1265
+ worstCaseVerdict: decide(worstCaseDeltaUsd),
1266
+ };
1267
+ }