@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/core",
3
- "version": "1.9.0",
3
+ "version": "1.25.0",
4
4
  "description": "Trazum core: priced advisories for LLM prompts (caching, model tier, batching, schemas), plus deterministic trimming, token counting and pricing.",
5
5
  "license": "MIT",
6
6
  "author": "David Mu\u00f1oz Rey",
package/src/advisories.ts CHANGED
@@ -125,7 +125,25 @@ export function buildAdvisories(
125
125
  const monthlyOutputUsd =
126
126
  (usage.avgOutputTokens / 1_000_000) * outputPerMTok * usage.callsPerMonth * batchFactor;
127
127
 
128
- // --- Context window ---
128
+ /**
129
+ * --- Context window ---
130
+ *
131
+ * The third place an estimate was compared against a hard threshold and the
132
+ * answer stated as fact, after `cache-prefix-reorder` and `prompt-caching`. This
133
+ * one has no dollar figure and is the most absolute of the three: **"The call
134
+ * will fail."**
135
+ *
136
+ * With a ±10% band it fails in both directions. An estimated 1,050,000 tokens
137
+ * against a 1,000,000 window can truly be 945,000 — the call succeeds and the
138
+ * reader has been sent to split a prompt that fitted. And an estimated 990,000
139
+ * can truly be 1,089,000, which does not fit, and nothing said anything at all.
140
+ *
141
+ * The silent direction is the worse one. A prompt over the window fails
142
+ * outright rather than degrading, so there is no partial result to notice.
143
+ */
144
+ const estimated = count === estimateTokens;
145
+ const band = ESTIMATE_ERROR_BAND_PCT / 100;
146
+
129
147
  if (tokensAfter > model.contextWindow) {
130
148
  advisories.push({
131
149
  id: 'context-overflow',
@@ -134,6 +152,20 @@ export function buildAdvisories(
134
152
  tokens: tokensAfter,
135
153
  modelName: model.displayName,
136
154
  contextWindow: model.contextWindow,
155
+ // Only an estimate can be uncertain. A caller who counted exactly is told
156
+ // the call fails, because it does.
157
+ uncertain: estimated && tokensAfter * (1 - band) <= model.contextWindow,
158
+ }),
159
+ estimatedMonthlyUsd: null,
160
+ });
161
+ } else if (estimated && tokensAfter * (1 + band) > model.contextWindow) {
162
+ advisories.push({
163
+ id: 'context-near-limit',
164
+ severity: 'warning',
165
+ ...t.advisories.contextNearLimit({
166
+ tokens: tokensAfter,
167
+ modelName: model.displayName,
168
+ contextWindow: model.contextWindow,
137
169
  }),
138
170
  estimatedMonthlyUsd: null,
139
171
  });
@@ -185,6 +217,22 @@ export function buildAdvisories(
185
217
  readPct: Math.round(rates.cacheRead * 100),
186
218
  writePct: Math.round(rates.cacheWrite5m * 100),
187
219
  explicit: (model.caching ?? 'explicit') === 'explicit',
220
+ /**
221
+ * The mirror of `couldReachMinimum` on `below-cache-minimum`, and the
222
+ * asymmetry between them was a real gap: that one hedged an estimate
223
+ * landing just *under* the threshold, while this one promised money on
224
+ * an estimate landing just *over* it. With a ±10% band an estimated
225
+ * 528-token prefix can truly be 475, and then nothing caches at all.
226
+ *
227
+ * The cautionary direction is the one that needed it, because this is
228
+ * the side with a dollar figure attached. Only when the number is an
229
+ * estimate: a caller who supplied their own counter has an
230
+ * authoritative prefix and hedging it would push them toward a check
231
+ * they have already done.
232
+ */
233
+ nearMinimum:
234
+ count === estimateTokens &&
235
+ cache.stablePrefixTokens * (1 - ESTIMATE_ERROR_BAND_PCT / 100) < minTokens,
188
236
  }),
189
237
  estimatedMonthlyUsd: saving,
190
238
  });
@@ -223,12 +271,49 @@ export function buildAdvisories(
223
271
  });
224
272
  }
225
273
 
226
- // Stable content placed AFTER the first placeholder: never cached today,
227
- // but moving it in front would make it cacheable.
274
+ /**
275
+ * Stable content placed AFTER the first placeholder: never cached today, and
276
+ * cacheable if it moves in front.
277
+ *
278
+ * **The prefix it would produce has to clear the minimum, and it did not used
279
+ * to be checked.** That was a money figure in the flattering direction, which
280
+ * is the one fault this file exists to avoid. On a 306-token support prompt
281
+ * against Claude Opus 5's 512-token minimum, the best prefix a rearrangement
282
+ * can build is 302 — so nothing caches, and the advisory offered $48.67 a
283
+ * month that cannot be collected.
284
+ *
285
+ * Worse, it said so in the same report as `below-cache-minimum`, which was
286
+ * telling the reader caching would not work here at all. Two advisories
287
+ * contradicting each other, and the one with a dollar sign winning the
288
+ * argument.
289
+ *
290
+ * `reorderForCache` already refused these prompts for exactly this reason, so
291
+ * the tool's advice and its action disagreed: follow the advice, run
292
+ * `--reorder`, and watch nothing happen.
293
+ */
294
+ /**
295
+ * The best prefix any rearrangement could build, compared strictly.
296
+ *
297
+ * **No band hedge here, and that was tried first.** Widening the comparison by
298
+ * ±10% — on the same reasoning that makes `below-cache-minimum` hedge near the
299
+ * line — opened a window between 466 and 512 tokens where this advisory
300
+ * offered a saving and `reorderForCache` refused to perform it. That is the
301
+ * fault being fixed, reintroduced one layer up, and a test caught it.
302
+ *
303
+ * The near-the-line case is already handled and in the right place:
304
+ * `below-cache-minimum` says the estimate is close to the threshold and names
305
+ * `--exact-tokens`. Settle the number and both this advisory and the command
306
+ * work from the same certainty. Two components disagreeing is worse than one
307
+ * of them being briefly quiet.
308
+ */
309
+ const reorderedPrefix = cache.stablePrefixTokens + cache.staticTokensAfter;
310
+ const reachableAfterReorder = reorderedPrefix >= minTokens;
311
+
228
312
  if (
229
313
  cache.firstPlaceholder &&
230
314
  cache.staticTokensAfter >= 200 &&
231
- cache.staticTokensAfter >= tokensAfter * 0.3
315
+ cache.staticTokensAfter >= tokensAfter * 0.3 &&
316
+ reachableAfterReorder
232
317
  ) {
233
318
  const movableShare = tokensAfter > 0 ? cache.staticTokensAfter / tokensAfter : 0;
234
319
  const saving = monthlyInputUsd * movableShare * Math.max(0, 1 - factor);
@@ -239,6 +324,13 @@ export function buildAdvisories(
239
324
  staticTokensAfter: cache.staticTokensAfter,
240
325
  sharePct: Math.round(movableShare * 100),
241
326
  placeholder: cache.firstPlaceholder,
327
+ /**
328
+ * Trazum can do this, and until now it told you to do it by hand.
329
+ * `reorderForCache` moves whole blocks, refuses any block carrying a
330
+ * backward reference, and refuses everything after one — so the command
331
+ * is the safe way to attempt what the prose was describing.
332
+ */
333
+ command: 'trazum optimize <file> --reorder',
242
334
  }),
243
335
  estimatedMonthlyUsd: saving > 0 ? saving : null,
244
336
  });
package/src/against.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The drivers of a change between two bills.
3
+ *
4
+ * One implementation, exported from core, because the sign convention here —
5
+ * **positive means the bill grew**, the diff convention — has already flipped
6
+ * once in this repository's history when restated by hand. The CLI, the MCP
7
+ * and the web all render these rows; three inline computations of the same
8
+ * union-and-subtract is three chances for one of them to disagree about what
9
+ * a vanished workload contributed.
10
+ *
11
+ * Derived over the **union** of keys, so an appeared or vanished workload is
12
+ * named rather than folded silently into the total: `was: null` is a key the
13
+ * previous log did not have, `now: null` one this log no longer has. Both are
14
+ * different statements from `$0.00`, and the renderings say which.
15
+ */
16
+
17
+ export interface AgainstDriver {
18
+ key: string;
19
+ /** What this key cost in the previous log, or null when it was not there. */
20
+ was: number | null;
21
+ /** What it costs now, or null when it is gone. */
22
+ now: number | null;
23
+ /** `(now ?? 0) - (was ?? 0)` — positive means the bill grew. */
24
+ delta: number;
25
+ }
26
+
27
+ /**
28
+ * Noise floor, not a judgement threshold: the same accumulated-double drift
29
+ * `cacheEconomics` refuses to report as a finding.
30
+ */
31
+ const DRIVER_NOISE_USD = 1e-9;
32
+
33
+ export function driversBetween(
34
+ before: ReadonlyArray<{ key: string; usd: number }>,
35
+ after: ReadonlyArray<{ key: string; usd: number }>,
36
+ ): AgainstDriver[] {
37
+ const wasBy = new Map(before.map((row) => [row.key, row.usd]));
38
+ const nowBy = new Map(after.map((row) => [row.key, row.usd]));
39
+ return [...new Set([...wasBy.keys(), ...nowBy.keys()])]
40
+ .map((key) => ({
41
+ key,
42
+ was: wasBy.has(key) ? wasBy.get(key)! : null,
43
+ now: nowBy.has(key) ? nowBy.get(key)! : null,
44
+ }))
45
+ .map((driver) => ({ ...driver, delta: (driver.now ?? 0) - (driver.was ?? 0) }))
46
+ .filter((driver) => Math.abs(driver.delta) > DRIVER_NOISE_USD)
47
+ .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
48
+ }
@@ -48,6 +48,21 @@ export interface BaselineConfig {
48
48
  maxGrowthPct?: number;
49
49
  }
50
50
 
51
+ /**
52
+ * Money budgets, in dollars, for the log-reading side of the tool.
53
+ *
54
+ * A budget for a workload that made no calls is **not** a pass and not a
55
+ * failure: it is a measurement that did not happen, and the report says so
56
+ * rather than reporting green over an absence. That is the same three-state
57
+ * rule the counts, the timestamps and the stop reasons all follow.
58
+ */
59
+ export interface SpendConfig {
60
+ /** Whole-log budget. `--max-usd` overrides it. */
61
+ maxUsd?: number;
62
+ /** Per-label budgets, each gated against that label's own spend. */
63
+ byLabel?: Record<string, number>;
64
+ }
65
+
51
66
  export interface TrazumConfig {
52
67
  level?: RuleLevel;
53
68
  locale?: Locale;
@@ -59,6 +74,32 @@ export interface TrazumConfig {
59
74
  * leaving it to be inferred.
60
75
  */
61
76
  budgets?: Record<string, number>;
77
+ /**
78
+ * Which prompt file each usage-log label sends, so `profile` can close the
79
+ * loop it opens.
80
+ *
81
+ * `profile` can say "caching loses money on `support-rag`" and nothing more —
82
+ * the log carries counts, not content. With this map it reads the named file
83
+ * and says *why*: where the first placeholder sits, how many stable tokens
84
+ * never reach the cacheable prefix, and whether the model's minimum is met at
85
+ * all. The file is whatever is in the repository today, which may not be what
86
+ * produced the log, and the report says so.
87
+ */
88
+ labels?: Record<string, string>;
89
+ /**
90
+ * Money budgets for `trazum profile`, in dollars.
91
+ *
92
+ * `budgets` gates the tokens a prompt file may hold; this gates the dollars
93
+ * a usage log records — the same difference `check` and `profile` have
94
+ * everywhere else. Written in the repository rather than passed as a flag
95
+ * because a per-workload budget is a policy several people agree on, and a
96
+ * policy that lives in one CI invocation is a policy nobody can read.
97
+ *
98
+ * `maxUsd` is the default for `--max-usd`; `byLabel` gates each named
99
+ * workload against its own limit in the same run. A flag still wins over
100
+ * the config, as everywhere in this tool.
101
+ */
102
+ spend?: SpendConfig;
62
103
  /** Default for `trazum diff --max-growth`, in tokens. */
63
104
  maxGrowth?: number;
64
105
  /**
@@ -94,6 +135,8 @@ export const CONFIG_KEYS = [
94
135
  'disable',
95
136
  'usage',
96
137
  'budgets',
138
+ 'labels',
139
+ 'spend',
97
140
  'maxGrowth',
98
141
  'baseline',
99
142
  'extensions',
@@ -102,6 +145,8 @@ export const CONFIG_KEYS = [
102
145
 
103
146
  export const CONFIG_BASELINE_KEYS = ['path', 'maxGrowthTokens', 'maxGrowthPct'] as const;
104
147
 
148
+ export const CONFIG_SPEND_KEYS = ['maxUsd', 'byLabel'] as const;
149
+
105
150
  export const CONFIG_USAGE_KEYS = [
106
151
  'model',
107
152
  'callsPerMonth',
@@ -217,6 +262,31 @@ function parseUsage(raw: unknown, source: string): Partial<UsageProfile> {
217
262
  */
218
263
  const IS_ABSOLUTE = /^(?:[/\\]|[A-Za-z]:[/\\])/;
219
264
 
265
+ function parseLabels(raw: unknown, source: string): Record<string, string> {
266
+ if (!isPlainObject(raw)) throw new ConfigError('"labels" must be an object', source);
267
+
268
+ const labels: Record<string, string> = {};
269
+ for (const [label, value] of Object.entries(raw)) {
270
+ if (label.length === 0) {
271
+ throw new ConfigError('"labels" has an empty label', source);
272
+ }
273
+ if (typeof value !== 'string' || value.length === 0) {
274
+ throw new ConfigError(`labels["${label}"] must be a file path`, source);
275
+ }
276
+ // Same boundary as budgets, for the same reason: an absolute path or one
277
+ // that climbs out with ".." points outside the project, and both are
278
+ // mistakes worth naming rather than files worth reading.
279
+ if (IS_ABSOLUTE.test(value) || value.includes('..')) {
280
+ throw new ConfigError(
281
+ `labels["${label}"] must be a relative path inside the project`,
282
+ source,
283
+ );
284
+ }
285
+ labels[label] = value;
286
+ }
287
+ return labels;
288
+ }
289
+
220
290
  function parseBudgets(raw: unknown, source: string): Record<string, number> {
221
291
  if (!isPlainObject(raw)) throw new ConfigError('"budgets" must be an object', source);
222
292
 
@@ -243,6 +313,40 @@ function parseBudgets(raw: unknown, source: string): Record<string, number> {
243
313
  return budgets;
244
314
  }
245
315
 
316
+ /**
317
+ * Validates the `spend` block.
318
+ *
319
+ * Dollars, not tokens, so non-integers are legitimate — $0.50 is a budget
320
+ * somebody means. Negative is not: a budget below zero can only fail, which
321
+ * makes it a mistake dressed as a policy. An empty label is rejected for the
322
+ * reason the empty string is the unlabelled bucket's sentinel: a config that
323
+ * meant "calls with no label" should say so through a real key, not through a
324
+ * value that collides with an internal one.
325
+ */
326
+ function parseSpend(raw: unknown, source: string): SpendConfig {
327
+ if (!isPlainObject(raw)) throw new ConfigError('"spend" must be an object', source);
328
+ rejectUnknownKeys(raw, CONFIG_SPEND_KEYS, source, 'spend.');
329
+
330
+ const spend: SpendConfig = {};
331
+ if (raw.maxUsd !== undefined) {
332
+ spend.maxUsd = requireNonNegativeNumber(raw.maxUsd, 'spend.maxUsd', source);
333
+ }
334
+ if (raw.byLabel !== undefined) {
335
+ if (!isPlainObject(raw.byLabel)) {
336
+ throw new ConfigError('"spend.byLabel" must be an object', source);
337
+ }
338
+ const byLabel: Record<string, number> = {};
339
+ for (const [label, value] of Object.entries(raw.byLabel)) {
340
+ if (label.trim().length === 0) {
341
+ throw new ConfigError('"spend.byLabel" has an empty label', source);
342
+ }
343
+ byLabel[label] = requireNonNegativeNumber(value, `spend.byLabel["${label}"]`, source);
344
+ }
345
+ spend.byLabel = byLabel;
346
+ }
347
+ return spend;
348
+ }
349
+
246
350
  /**
247
351
  * Validates the `baseline` block.
248
352
  *
@@ -390,6 +494,8 @@ export function parseConfig(raw: string, source = CONFIG_FILENAME): TrazumConfig
390
494
 
391
495
  if (document.usage !== undefined) config.usage = parseUsage(document.usage, source);
392
496
  if (document.budgets !== undefined) config.budgets = parseBudgets(document.budgets, source);
497
+ if (document.labels !== undefined) config.labels = parseLabels(document.labels, source);
498
+ if (document.spend !== undefined) config.spend = parseSpend(document.spend, source);
393
499
  if (document.baseline !== undefined) {
394
500
  config.baseline = parseBaselineConfig(document.baseline, source);
395
501
  }
@@ -0,0 +1,305 @@
1
+ import { effectivePricing, multipliersFor } from './pricing.js';
2
+ import { UNLABELLED } from './usage.js';
3
+ import type { PricingCatalogue } from './pricing.js';
4
+ import type { UsageRecord } from './usage.js';
5
+
6
+ /**
7
+ * What re-sending the conversation costs.
8
+ *
9
+ * ## The line nothing was watching
10
+ *
11
+ * A chat or agent workload sends the whole conversation back on every turn. Turn
12
+ * one is a system prompt and a question; turn twenty is a system prompt and
13
+ * nineteen previous exchanges and a question. The input grows linearly with the
14
+ * turn count, and on an agent bill that growth is routinely the largest single
15
+ * line — larger than the prompt, larger than the answers.
16
+ *
17
+ * Nothing in this package could see it. A prompt file shows the system prompt and
18
+ * not the history. A total shows the sum and not the shape. Even `profile` reported
19
+ * "input is 71% of this bill" without being able to say that most of that input was
20
+ * the same sentences, sent again.
21
+ *
22
+ * ## What it will and will not claim
23
+ *
24
+ * The honest figure is a **ceiling**, and the token half of it is exact: the
25
+ * input tokens beyond every turn being the size of the session's smallest turn.
26
+ * That quantity is order-independent — the first-seen anchor made the identical
27
+ * workload vanish when exported newest-first — and it is immune to billing
28
+ * rates: a cost-based anchor charged an ordinary 5-minute-TTL agent 77.5%
29
+ * "growth" on a conversation that never grew, because a cache-miss turn costs
30
+ * 12.5x a cache-hit turn of the same size. The dollars are that token share of
31
+ * what the session actually spent, at its own blended rate.
32
+ *
33
+ * It is a ceiling and not a saving because part of that growth is the user's own
34
+ * new messages, which nobody can truncate away, and this module cannot tell those
35
+ * apart from re-sent history — it sees counts, not content. Reporting the ceiling
36
+ * as an opportunity would be the flattering direction; reporting nothing because
37
+ * the exact split is unknowable would be worse. So it reports the bound and says
38
+ * what it is.
39
+ *
40
+ * ## The session key never leaves this module
41
+ *
42
+ * A session identifier is somebody's conversation, and in a real log it is often an
43
+ * account id, a ticket number or an email. It is used to group calls and count
44
+ * turns; **no figure reported anywhere carries it**, and every result is aggregated
45
+ * per label. The promise that a usage log handed to Trazum contains no content is
46
+ * only worth something if nothing identifying comes back out either.
47
+ */
48
+
49
+ /** How one label-and-model slice grows across a conversation. */
50
+ export interface ConversationGrowth {
51
+ label: string;
52
+ model: string;
53
+ modelName: string;
54
+ /** How many distinct conversations were seen. Never which ones. */
55
+ sessions: number;
56
+ calls: number;
57
+ /** Mean input tokens on the smallest turn of a conversation. */
58
+ minTurnTokens: number;
59
+ /** Mean input tokens on the largest turn. */
60
+ maxTurnTokens: number;
61
+ /** Turns in the longest conversation seen. */
62
+ longestSession: number;
63
+ /** Input-side spend: plain input, cache reads and cache writes. */
64
+ inputUsd: number;
65
+ /** What that would have been if every turn had cost what its cheapest turn did. */
66
+ flatUsd: number;
67
+ /**
68
+ * `inputUsd - flatUsd`. **A ceiling on what removing conversation growth could
69
+ * be worth, not a saving** — part of it is the user's own new messages.
70
+ */
71
+ growthUsd: number;
72
+ /** `growthUsd` as a fraction of the whole bill in the log. */
73
+ shareOfBill: number;
74
+ }
75
+
76
+ export interface ConversationOptions {
77
+ catalogue: PricingCatalogue;
78
+ on?: Date;
79
+ /**
80
+ * Slices whose growth is below this share of the bill are dropped, and slices
81
+ * shorter than `minTurns` never count as conversations at all.
82
+ */
83
+ minShare?: number;
84
+ /**
85
+ * Conversations shorter than this are ignored.
86
+ *
87
+ * Two turns is not a conversation, it is a retry — and a workload that never
88
+ * exceeds two turns has no growth to measure, so including it would put a row on
89
+ * screen whose figure is arithmetic noise. Default 3.
90
+ */
91
+ minTurns?: number;
92
+ }
93
+
94
+ /** Input-side cost of one call at its own model's rates. */
95
+ function inputCostOf(record: UsageRecord, catalogue: PricingCatalogue, on: Date): number | null {
96
+ const model = catalogue.byId.get(record.model);
97
+ if (!model) return null;
98
+ const { inputPerMTok } = effectivePricing(model, on);
99
+ const rates = multipliersFor(model);
100
+ const per = (tokens: number, rate: number): number => (tokens / 1_000_000) * rate;
101
+ return (
102
+ per(record.inputTokens, inputPerMTok) +
103
+ per(record.cacheReadTokens, inputPerMTok * rates.cacheRead) +
104
+ per(record.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m) +
105
+ per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h)
106
+ );
107
+ }
108
+
109
+ /** Every input-side token of one call, whatever rate it was billed at. */
110
+ const inputTokensOf = (r: UsageRecord): number =>
111
+ r.inputTokens + r.cacheReadTokens + r.cacheWrite5mTokens + r.cacheWrite1hTokens;
112
+
113
+ interface Session {
114
+ turns: number;
115
+ /**
116
+ * The smallest turn by **tokens**, not by billed cost.
117
+ *
118
+ * Two faults taught this shape. Anchoring on the first record seen made the
119
+ * measurement depend on the order of the log: the identical workload exported
120
+ * newest-first computed a *negative* growth and the section silently vanished.
121
+ * Anchoring on the cheapest turn's *cost* fixed the ordering and introduced
122
+ * the second fault: per-turn cost varies with the cache multiplier even when
123
+ * the input never grows — an identical 10,000-token turn costs 12.5x more as
124
+ * a cache write than as a cache read — so an ordinary 5-minute-TTL agent
125
+ * whose conversation stayed flat reported 77.5% of its bill as "conversation
126
+ * growth", and the report recommended trimming history that was not there.
127
+ *
128
+ * Tokens are what growth *is*, and they are immune to both: order-independent,
129
+ * and identical however each turn happened to be billed.
130
+ */
131
+ minTokens: number;
132
+ maxTokens: number;
133
+ totalTokens: number;
134
+ totalUsd: number;
135
+ }
136
+
137
+ /**
138
+ * Measures what conversation growth costs, from records that carry a session.
139
+ *
140
+ * Records without one are skipped rather than lumped together: calls from
141
+ * different conversations pushed into a single bucket would report a turn count
142
+ * that is really a call count, and a growth figure derived from it would be
143
+ * arithmetic performed on a fiction.
144
+ *
145
+ * Takes records rather than a report because turn order is the whole measurement,
146
+ * and a breakdown has already thrown it away.
147
+ */
148
+ export interface ConversationTracker {
149
+ /** Feed one parsed record. Records without a session are ignored. */
150
+ add(record: UsageRecord): void;
151
+ /** The finished measurement, once the whole bill is known. */
152
+ finish(totalUsd: number): ConversationGrowth[];
153
+ }
154
+
155
+ /**
156
+ * An accumulator, so a profile can measure this in the pass it already makes.
157
+ *
158
+ * The alternative was holding every record to hand to a pure function afterwards,
159
+ * and a usage log is measured in megabytes — a profile that needs the whole file in
160
+ * memory to answer one question is a profile that stops working on the logs most
161
+ * worth reading. What this holds is bounded by the number of **conversations**, and
162
+ * only ever four numbers each.
163
+ */
164
+ export function createConversationTracker(options: ConversationOptions): ConversationTracker {
165
+ const { catalogue, on = new Date(), minShare = 0.01, minTurns = 3 } = options;
166
+
167
+ // Keyed on the pair, then on the session inside it. A newline cannot occur in a
168
+ // model id, and both halves are trimmed strings.
169
+ const slices = new Map<string, Map<string, Session>>();
170
+
171
+ const add = (record: UsageRecord): void => {
172
+ if (record.session === null) return;
173
+ const cost = inputCostOf(record, catalogue, on);
174
+ // An unpriced model contributes no dollars anywhere else either; including it
175
+ // here would report growth of zero on a workload that grew.
176
+ if (cost === null) return;
177
+
178
+ const sliceKey = `${record.label ?? UNLABELLED}\n${record.model}`;
179
+ let sessions = slices.get(sliceKey);
180
+ if (!sessions) {
181
+ sessions = new Map();
182
+ slices.set(sliceKey, sessions);
183
+ }
184
+
185
+ const tokens = inputTokensOf(record);
186
+ const existing = sessions.get(record.session);
187
+ if (!existing) {
188
+ sessions.set(record.session, {
189
+ turns: 1,
190
+ minTokens: tokens,
191
+ maxTokens: tokens,
192
+ totalTokens: tokens,
193
+ totalUsd: cost,
194
+ });
195
+ return;
196
+ }
197
+ existing.turns += 1;
198
+ existing.minTokens = Math.min(existing.minTokens, tokens);
199
+ existing.maxTokens = Math.max(existing.maxTokens, tokens);
200
+ existing.totalTokens += tokens;
201
+ existing.totalUsd += cost;
202
+ };
203
+
204
+ const finish = (totalUsd: number): ConversationGrowth[] => {
205
+ const out: ConversationGrowth[] = [];
206
+
207
+ for (const [sliceKey, sessions] of slices) {
208
+ const split = sliceKey.indexOf('\n');
209
+ const label = sliceKey.slice(0, split);
210
+ const modelId = sliceKey.slice(split + 1);
211
+ const model = catalogue.byId.get(modelId);
212
+ if (!model) continue;
213
+
214
+ /**
215
+ * Only conversations long enough to have grown. A two-turn session has one
216
+ * step of growth and is as likely to be a retry, and averaging it in drags the
217
+ * measured shape towards flat — understating the real thing, which is the
218
+ * direction that flatters.
219
+ */
220
+ const long = [...sessions.values()].filter((s) => s.turns >= minTurns);
221
+ if (long.length === 0) continue;
222
+
223
+ let inputUsd = 0;
224
+ let flatUsd = 0;
225
+ let minTokens = 0;
226
+ let maxTokens = 0;
227
+ let calls = 0;
228
+ let longestSession = 0;
229
+
230
+ for (const session of long) {
231
+ inputUsd += session.totalUsd;
232
+ /**
233
+ * The growth is measured in tokens — `totalTokens - minTokens·turns`,
234
+ * which is exact, order-independent, and zero for a conversation whose
235
+ * turns never change size however each one was billed. The money is that
236
+ * token share of what the session actually spent: pricing the excess at
237
+ * any single rate would either overstate it (full input rate, when most
238
+ * re-sent history is cache-read cheap) or move with billing noise (the
239
+ * cheapest turn's rate, which is what mis-billed a flat cached agent).
240
+ */
241
+ const flatTokens = session.minTokens * session.turns;
242
+ flatUsd +=
243
+ session.totalTokens > 0
244
+ ? session.totalUsd * (flatTokens / session.totalTokens)
245
+ : session.totalUsd;
246
+ minTokens += session.minTokens;
247
+ maxTokens += session.maxTokens;
248
+ calls += session.turns;
249
+ longestSession = Math.max(longestSession, session.turns);
250
+ }
251
+
252
+ const growthUsd = inputUsd - flatUsd;
253
+ const shareOfBill = totalUsd > 0 ? growthUsd / totalUsd : 0;
254
+ /**
255
+ * Below the attention threshold — **and that covers shrinking conversations
256
+ * too**, because a negative share is below any threshold at or above zero.
257
+ *
258
+ * There was a separate `growthUsd <= 0` check here. No mutation could break
259
+ * it: every case it caught, this one caught first. A guard nothing can
260
+ * distinguish is not defence in depth, it is a second place for the intent to
261
+ * drift from the code, so the intent lives in this comment instead.
262
+ *
263
+ * The case it was written for is ordinary: an opening turn carrying an
264
+ * attachment or a retrieved document is bigger than everything after it, and
265
+ * reporting that as "conversation growth" would be a negative ceiling
266
+ * presented as an opportunity.
267
+ */
268
+ if (shareOfBill < minShare) continue;
269
+
270
+ out.push({
271
+ label,
272
+ model: modelId,
273
+ modelName: model.displayName,
274
+ sessions: long.length,
275
+ calls,
276
+ minTurnTokens: minTokens / long.length,
277
+ maxTurnTokens: maxTokens / long.length,
278
+ longestSession,
279
+ inputUsd,
280
+ flatUsd,
281
+ growthUsd,
282
+ shareOfBill,
283
+ });
284
+ }
285
+
286
+ return out.sort((a, b) => b.growthUsd - a.growthUsd);
287
+ };
288
+
289
+ return { add, finish };
290
+ }
291
+
292
+ /**
293
+ * The same measurement over a list of records, for a caller holding one already.
294
+ *
295
+ * `profileUsage` uses the tracker instead, so it never has to keep the log.
296
+ */
297
+ export function conversationGrowth(
298
+ records: readonly UsageRecord[],
299
+ totalUsd: number,
300
+ options: ConversationOptions,
301
+ ): ConversationGrowth[] {
302
+ const tracker = createConversationTracker(options);
303
+ for (const record of records) tracker.add(record);
304
+ return tracker.finish(totalUsd);
305
+ }