@trazum/core 1.10.0 → 1.26.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 (84) hide show
  1. package/dist/against.d.ts +32 -0
  2. package/dist/against.d.ts.map +1 -0
  3. package/dist/against.js +34 -0
  4. package/dist/against.js.map +1 -0
  5. package/dist/config-schema.d.ts +42 -1
  6. package/dist/config-schema.d.ts.map +1 -1
  7. package/dist/config-schema.js +61 -0
  8. package/dist/config-schema.js.map +1 -1
  9. package/dist/conversation.d.ts +121 -0
  10. package/dist/conversation.d.ts.map +1 -0
  11. package/dist/conversation.js +157 -0
  12. package/dist/conversation.js.map +1 -0
  13. package/dist/csv.d.ts +61 -0
  14. package/dist/csv.d.ts.map +1 -0
  15. package/dist/csv.js +149 -0
  16. package/dist/csv.js.map +1 -0
  17. package/dist/evaluate.d.ts +24 -0
  18. package/dist/evaluate.d.ts.map +1 -1
  19. package/dist/evaluate.js +5 -2
  20. package/dist/evaluate.js.map +1 -1
  21. package/dist/index.d.ts +25 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +28 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/input-shape.d.ts +104 -0
  26. package/dist/input-shape.d.ts.map +1 -0
  27. package/dist/input-shape.js +132 -0
  28. package/dist/input-shape.js.map +1 -0
  29. package/dist/levers.d.ts +151 -0
  30. package/dist/levers.d.ts.map +1 -0
  31. package/dist/levers.js +160 -0
  32. package/dist/levers.js.map +1 -0
  33. package/dist/node.d.ts +1 -1
  34. package/dist/node.d.ts.map +1 -1
  35. package/dist/output-shape.d.ts +96 -0
  36. package/dist/output-shape.d.ts.map +1 -0
  37. package/dist/output-shape.js +145 -0
  38. package/dist/output-shape.js.map +1 -0
  39. package/dist/pricing-overlay.d.ts +1 -1
  40. package/dist/pricing-overlay.d.ts.map +1 -1
  41. package/dist/pricing-overlay.js +46 -0
  42. package/dist/pricing-overlay.js.map +1 -1
  43. package/dist/repeats.d.ts +75 -0
  44. package/dist/repeats.d.ts.map +1 -0
  45. package/dist/repeats.js +82 -0
  46. package/dist/repeats.js.map +1 -0
  47. package/dist/reprice.d.ts +143 -0
  48. package/dist/reprice.d.ts.map +1 -0
  49. package/dist/reprice.js +82 -0
  50. package/dist/reprice.js.map +1 -0
  51. package/dist/session-cost.d.ts +70 -0
  52. package/dist/session-cost.d.ts.map +1 -0
  53. package/dist/session-cost.js +90 -0
  54. package/dist/session-cost.js.map +1 -0
  55. package/dist/session-ledger.d.ts +77 -0
  56. package/dist/session-ledger.d.ts.map +1 -0
  57. package/dist/session-ledger.js +99 -0
  58. package/dist/session-ledger.js.map +1 -0
  59. package/dist/ttl-fit.d.ts +103 -0
  60. package/dist/ttl-fit.d.ts.map +1 -0
  61. package/dist/ttl-fit.js +184 -0
  62. package/dist/ttl-fit.js.map +1 -0
  63. package/dist/usage.d.ts +434 -16
  64. package/dist/usage.d.ts.map +1 -1
  65. package/dist/usage.js +383 -23
  66. package/dist/usage.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/against.ts +48 -0
  69. package/src/config-schema.ts +106 -0
  70. package/src/conversation.ts +305 -0
  71. package/src/csv.ts +184 -0
  72. package/src/evaluate.ts +33 -3
  73. package/src/index.ts +51 -1
  74. package/src/input-shape.ts +259 -0
  75. package/src/levers.ts +331 -0
  76. package/src/node.ts +1 -1
  77. package/src/output-shape.ts +254 -0
  78. package/src/pricing-overlay.ts +52 -1
  79. package/src/repeats.ts +166 -0
  80. package/src/reprice.ts +227 -0
  81. package/src/session-cost.ts +170 -0
  82. package/src/session-ledger.ts +189 -0
  83. package/src/ttl-fit.ts +251 -0
  84. package/src/usage.ts +795 -7
package/src/levers.ts ADDED
@@ -0,0 +1,331 @@
1
+ import { effectivePricing, multipliersFor } from './pricing.js';
2
+ import { UNLABELLED } from './usage.js';
3
+ import type { PricingCatalogue } from './pricing.js';
4
+ import type { UsageBreakdown, UsageProfileReport } from './usage.js';
5
+ import type { Capability, ModelPricing } from './types.js';
6
+
7
+ /**
8
+ * What would actually move this bill.
9
+ *
10
+ * ## The number this exists to answer
11
+ *
12
+ * Trazum's rules recover about **1%** of a bill. Measured, on an ordinary support
13
+ * prompt: three tokens out of three hundred and six. On a company spending twenty
14
+ * thousand a month that is two hundred, and nobody installs a tool for two
15
+ * hundred. The complaint is correct and it is the most important thing anybody has
16
+ * said about this product.
17
+ *
18
+ * The rest of the package reads a prompt file and shortens it. This reads what was
19
+ * charged and prices the levers that are **not** the prompt, from the same log, at
20
+ * the same arithmetic:
21
+ *
22
+ * | lever | what it moves |
23
+ * |---|---|
24
+ * | which model the call goes to | Opus 5 → Sonnet 5 is 40% off; → Haiku 4.5 is 80% |
25
+ * | the Batch API | 50% flat, on input and output |
26
+ * | prompt caching | 3–4x the rules |
27
+ * | shortening the prompt | ~1% |
28
+ *
29
+ * So the honest headline is that **the money is in what you call, not in how long
30
+ * the prompt is** — and the tool that only did the last row should say so, in the
31
+ * reader's own figures, rather than reporting a 1% win as a success.
32
+ *
33
+ * ## Every figure here is arithmetic on tokens that were billed
34
+ *
35
+ * Nothing is modelled and nothing is extrapolated. A route lever is the same token
36
+ * counts at another model's published rate. A batch lever is the same tokens at the
37
+ * provider's batch multiplier. There is no assumed traffic, no assumed prompt, no
38
+ * assumed anything — which is the whole reason this reads a usage log instead of a
39
+ * directory.
40
+ *
41
+ * ## What it refuses to do
42
+ *
43
+ * **It never says a lever is safe to take.** Routing a workload to a cheaper model
44
+ * is a quality question that arithmetic cannot answer, and this module has never
45
+ * seen the prompt or a single answer. So a route carries its dollar figure *and*
46
+ * the command that measures whether it holds, and it is described as worth testing
47
+ * rather than worth doing. The same posture the `model-downgrade` advisory has
48
+ * always had, for the same reason.
49
+ *
50
+ * **It never says "per month".** A usage log covers whatever period somebody
51
+ * happened to record, and this module is not told which. Every figure is "on this
52
+ * bill" — over exactly the calls in the file. Multiplying an unknown period into a
53
+ * monthly headline is how a tool ends up quoting a saving four times the real one.
54
+ *
55
+ * **It never crosses a vendor.** A cheaper model at another provider is a
56
+ * migration, not a routing change, and pricing one as though it were a switch you
57
+ * could make on Tuesday is a saving nobody can take.
58
+ */
59
+
60
+ /** What a lever is. */
61
+ export type LeverId =
62
+ /** Send these calls to a cheaper model of the same family. */
63
+ | 'route'
64
+ /** Send these calls through the Batch API. */
65
+ | 'batch';
66
+
67
+ /**
68
+ * Everything available on one label-and-model slice, and what it comes to.
69
+ *
70
+ * **Grouped by slice rather than listed as separate levers, because the levers
71
+ * are not additive and a list invites adding them.** The first version printed
72
+ * "route support-rag: $12.60" and "batch support-rag: $10.50" as two rows against
73
+ * a slice that had only spent $21.00 — a reader who added them got $23.10, a
74
+ * saving larger than the bill it came from. Impossible, and in the flattering
75
+ * direction.
76
+ *
77
+ * They do combine, just not by addition: batching a routed call saves half of the
78
+ * *cheaper* model's price, not half of the one you left. `combinedUsd` is that
79
+ * figure, computed rather than summed.
80
+ */
81
+ export interface SliceLevers {
82
+ /** The label these calls carry, or `UNLABELLED`. */
83
+ label: string;
84
+ /** The model they go to now. */
85
+ model: string;
86
+ modelName: string;
87
+ /** Calls affected — the reader's own judgement of whether it is worth a day. */
88
+ calls: number;
89
+ /** What these exact calls cost. */
90
+ spentUsd: number;
91
+ /** A cheaper model one capability step down, if the catalogue has one. */
92
+ route: { candidate: { id: string; displayName: string }; savingUsd: number } | null;
93
+ /** The Batch API, where the provider sells one. */
94
+ batch: { savingUsd: number } | null;
95
+ /**
96
+ * Both together, **computed and never summed**. Equal to the single available
97
+ * lever when only one is.
98
+ */
99
+ combinedUsd: number;
100
+ /** `combinedUsd` as a fraction of the whole bill in the log, not of this slice. */
101
+ shareOfBill: number;
102
+ }
103
+
104
+ export interface BillLevers {
105
+ /** Ranked by what the whole slice could save, largest first. */
106
+ slices: SliceLevers[];
107
+ /**
108
+ * The most that shortening prompt text could ever be worth on this bill.
109
+ *
110
+ * Everything that is not output: plain input, cache reads, cache writes. It is a
111
+ * **ceiling and not an estimate** — deliberately generous, because it counts
112
+ * retrieved context, conversation history and tool results, none of which live in
113
+ * a prompt file and none of which a rules pass can touch. The real figure is
114
+ * below it, usually far below.
115
+ *
116
+ * It is here so the levers above have something to be compared against. A tool
117
+ * that reports a 1% win without saying 1% of what is not being useful.
118
+ */
119
+ promptCeilingUsd: number;
120
+ promptCeilingShare: number;
121
+ /** The bill the shares are taken against. */
122
+ totalUsd: number;
123
+ }
124
+
125
+ /**
126
+ * Weakest first. A step *down* this ladder is what a route offers.
127
+ *
128
+ * `unknown` is absent on purpose rather than placed at one end: a model whose
129
+ * capability nobody recorded cannot be ranked against one whose capability is
130
+ * known, and guessing puts a real workload on a model chosen by a default value.
131
+ */
132
+ const CAPABILITY_LADDER: Capability[] = ['small', 'mid', 'large', 'frontier'];
133
+
134
+ /**
135
+ * The next step down, or `null` at the bottom and for `unknown`.
136
+ *
137
+ * One step, not the cheapest available. Frontier to small is an 80% saving and a
138
+ * different product, and offering it as the headline would be the arithmetic
139
+ * leading the advice — exactly the failure this file is written against. The
140
+ * reader who wants the bigger jump can ask for it once the first one holds.
141
+ */
142
+ function stepDown(capability: Capability): Capability | null {
143
+ const at = CAPABILITY_LADDER.indexOf(capability);
144
+ return at <= 0 ? null : CAPABILITY_LADDER[at - 1]!;
145
+ }
146
+
147
+ /** What a breakdown's tokens would cost at a model's rates, split so batch can apply. */
148
+ function repriceAt(
149
+ breakdown: UsageBreakdown,
150
+ model: ModelPricing,
151
+ on: Date,
152
+ ): { inputUsd: number; outputUsd: number; cacheUsd: number; totalUsd: number } {
153
+ const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
154
+ const rates = multipliersFor(model);
155
+ const per = (tokens: number, rate: number): number => (tokens / 1_000_000) * rate;
156
+
157
+ const inputUsd = per(breakdown.inputTokens, inputPerMTok);
158
+ const outputUsd = per(breakdown.outputTokens, outputPerMTok);
159
+ /**
160
+ * Cache writes at the 5-minute rate. The breakdown does not carry the recorded
161
+ * TTL split per class, so one has to be chosen — and the same choice sits on
162
+ * both sides of every subtraction here, so it cancels out of the saving. Worth
163
+ * stating rather than papering over: it would not cancel if the two models had
164
+ * different write multipliers, which is why a route never crosses a vendor.
165
+ */
166
+ const cacheUsd =
167
+ per(breakdown.cacheReadTokens, inputPerMTok * rates.cacheRead) +
168
+ per(breakdown.cacheWriteTokens, inputPerMTok * rates.cacheWrite5m);
169
+
170
+ return { inputUsd, outputUsd, cacheUsd, totalUsd: inputUsd + outputUsd + cacheUsd };
171
+ }
172
+
173
+ /**
174
+ * The cheapest recommendable model one capability step below, same provider.
175
+ *
176
+ * Same provider because switching vendor is a migration rather than a routing
177
+ * change, and the context window has to hold what these calls already sent — a
178
+ * cheaper model that cannot fit the prompt is not cheaper, it is broken.
179
+ */
180
+ function candidateFor(
181
+ model: ModelPricing,
182
+ breakdown: UsageBreakdown,
183
+ catalogue: PricingCatalogue,
184
+ on: Date,
185
+ ): ModelPricing | null {
186
+ const target = stepDown(model.capability);
187
+ if (target === null) return null;
188
+
189
+ /**
190
+ * The largest single call cannot be recovered from a total, so this uses the
191
+ * **average** input per call and refuses any candidate that could not hold it.
192
+ * An average understates the peak, so this is the permissive direction — stated
193
+ * rather than hidden, because the reader will check the window properly when
194
+ * they run the evaluation this points them at.
195
+ */
196
+ const avgInput =
197
+ breakdown.calls === 0
198
+ ? 0
199
+ : (breakdown.inputTokens + breakdown.cacheReadTokens + breakdown.cacheWriteTokens) /
200
+ breakdown.calls;
201
+
202
+ const candidates = catalogue.models.filter(
203
+ (m) =>
204
+ m.id !== model.id &&
205
+ m.capability === target &&
206
+ m.provider === model.provider &&
207
+ m.recommendable !== false &&
208
+ m.contextWindow >= avgInput,
209
+ );
210
+ if (candidates.length === 0) return null;
211
+
212
+ return candidates.reduce((best, m) =>
213
+ repriceAt(breakdown, m, on).totalUsd < repriceAt(breakdown, best, on).totalUsd ? m : best,
214
+ );
215
+ }
216
+
217
+ export interface BillLeverOptions {
218
+ catalogue: PricingCatalogue;
219
+ /** Date the prices are read at, so a promotional rate resolves the same way. */
220
+ on?: Date;
221
+ /**
222
+ * Slices worth less than this share of the bill are dropped.
223
+ *
224
+ * Not a judgement about small money — a judgement about attention. Thirty rows
225
+ * worth a tenth of a percent each bury the two worth twenty, and a report nobody
226
+ * finishes reading is a report that changed nothing. Default 1%.
227
+ */
228
+ minShare?: number;
229
+ }
230
+
231
+ /**
232
+ * Prices the levers that are not the prompt, from a profile of real calls.
233
+ *
234
+ * Returns them ranked by money, with the ceiling on prompt shortening beside them
235
+ * so the comparison is unavoidable. Empty when nothing clears `minShare`, which is
236
+ * a legitimate answer: a bill already on the cheapest model of its family, with no
237
+ * batch API to reach for, has no lever here, and saying so is more useful than
238
+ * manufacturing one.
239
+ */
240
+ export function billLevers(
241
+ report: UsageProfileReport,
242
+ options: BillLeverOptions,
243
+ ): BillLevers {
244
+ const { catalogue, on = new Date(), minShare = 0.01 } = options;
245
+ const totalUsd = report.total.totalUsd;
246
+
247
+ const promptCeilingUsd =
248
+ report.total.inputUsd + report.total.cacheReadUsd + report.total.cacheWriteUsd;
249
+
250
+ if (totalUsd <= 0) {
251
+ return { slices: [], promptCeilingUsd, promptCeilingShare: 0, totalUsd };
252
+ }
253
+
254
+ const slices: SliceLevers[] = [];
255
+
256
+ for (const { label, model: modelId, breakdown } of report.byLabelAndModel) {
257
+ const model = catalogue.byId.get(modelId);
258
+ // An unpriced model never reaches this list with dollars on it, and a lever
259
+ // computed from a zero bill is a saving invented out of nothing.
260
+ if (!model || breakdown.totalUsd <= 0) continue;
261
+
262
+ const candidate = candidateFor(model, breakdown, catalogue, on);
263
+ const routed = candidate ? repriceAt(breakdown, candidate, on) : null;
264
+ const route =
265
+ candidate && routed && breakdown.totalUsd - routed.totalUsd > 0
266
+ ? {
267
+ candidate: { id: candidate.id, displayName: candidate.displayName },
268
+ savingUsd: breakdown.totalUsd - routed.totalUsd,
269
+ }
270
+ : null;
271
+
272
+ /**
273
+ * `null` means the provider has no batch API, which is different from an
274
+ * unstated one — offering a discount nobody sells is worse than staying quiet.
275
+ *
276
+ * Applied to input and output only. The published discount covers those two
277
+ * lines; whether it also reaches cache reads and writes is not something this
278
+ * catalogue records, so they stay at full price. That understates the saving,
279
+ * which is the direction to be wrong in.
280
+ */
281
+ const batchRate = multipliersFor(model).batch;
282
+ const batchable = batchRate !== null && batchRate < 1;
283
+ const batch = batchable
284
+ ? { savingUsd: (breakdown.inputUsd + breakdown.outputUsd) * (1 - batchRate!) }
285
+ : null;
286
+
287
+ /**
288
+ * Both together — **computed, never summed.**
289
+ *
290
+ * Batching a routed call discounts the cheaper model's price, not the one you
291
+ * left behind. Adding the two figures produced a saving larger than the slice
292
+ * had ever cost: $12.60 and $10.50 against $21.00 spent.
293
+ */
294
+ const afterBoth = routed ?? {
295
+ inputUsd: breakdown.inputUsd,
296
+ outputUsd: breakdown.outputUsd,
297
+ cacheUsd: breakdown.cacheReadUsd + breakdown.cacheWriteUsd,
298
+ totalUsd: breakdown.totalUsd,
299
+ };
300
+ const combinedCost = batchable
301
+ ? afterBoth.totalUsd - (afterBoth.inputUsd + afterBoth.outputUsd) * (1 - batchRate!)
302
+ : afterBoth.totalUsd;
303
+ const combinedUsd = breakdown.totalUsd - combinedCost;
304
+
305
+ if (combinedUsd <= 0) continue;
306
+ const shareOfBill = combinedUsd / totalUsd;
307
+ if (shareOfBill < minShare) continue;
308
+
309
+ slices.push({
310
+ label,
311
+ model: modelId,
312
+ modelName: model.displayName,
313
+ calls: breakdown.calls,
314
+ spentUsd: breakdown.totalUsd,
315
+ route,
316
+ batch,
317
+ combinedUsd,
318
+ shareOfBill,
319
+ });
320
+ }
321
+
322
+ return {
323
+ slices: slices.sort((a, b) => b.combinedUsd - a.combinedUsd),
324
+ promptCeilingUsd,
325
+ promptCeilingShare: promptCeilingUsd / totalUsd,
326
+ totalUsd,
327
+ };
328
+ }
329
+
330
+ /** Named so a report can say "unlabelled" in the reader's language. */
331
+ export { UNLABELLED };
package/src/node.ts CHANGED
@@ -32,7 +32,7 @@ export {
32
32
  parseConfig,
33
33
  validateConfigModel,
34
34
  } from './config-schema.js';
35
- export type { ResolvedBudget, TrazumConfig } from './config-schema.js';
35
+ export type { ResolvedBudget, SpendConfig, TrazumConfig } from './config-schema.js';
36
36
 
37
37
  // Local price corrections. Pure, so also on the main entry point; re-exported
38
38
  // here so the CLI has one import for everything it needs to resolve a run.
@@ -0,0 +1,254 @@
1
+ import { effectivePricing } 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
+ * Where the output spend concentrates.
8
+ *
9
+ * ## The biggest line, and nothing said anything actionable about it
10
+ *
11
+ * Output is over half of many real bills — **87%** on the support prompt this
12
+ * repository measures itself against. `profile` could say that much and then
13
+ * stopped, because the advice that follows from "output dominates" is about
14
+ * answers rather than prompts, and the rules engine has nothing to offer there.
15
+ *
16
+ * But a total hides the shape, and the shape is the actionable part. Two bills
17
+ * with identical output spend want opposite responses:
18
+ *
19
+ * - **A tail.** Six per cent of calls hold half the output spend. Those calls are
20
+ * doing something the other ninety-four are not — a different path through the
21
+ * prompt, a runaway with no `max_tokens`, a retrieval that returned a book. They
22
+ * are a morning's work and they are worth finding.
23
+ * - **Flat.** Forty-five per cent of calls hold half of it, which is what "evenly
24
+ * spread" looks like. There is no tail to hunt; the answer length is inherent to
25
+ * the task, and the only lever is asking every answer to be shorter.
26
+ *
27
+ * ## The split is derived, not chosen
28
+ *
29
+ * The figure reported is **the smallest group of calls that holds at least half the
30
+ * output spend**. Half is the point that divides the spend in two — a median over
31
+ * money rather than a threshold somebody picked — and the group is found by walking
32
+ * the distribution down from the longest answers until half the spend is covered.
33
+ *
34
+ * "At least half" is meant literally. The walk stops on a bucket boundary, so the
35
+ * group it names is a whole number of buckets and can overshoot; saying "half"
36
+ * flat would be claiming a precision the histogram does not have.
37
+ *
38
+ * ## Bounded memory, exact statement
39
+ *
40
+ * The counts live in fixed buckets rather than a list of every call, because a
41
+ * usage log is measured in megabytes. Every call inside an included bucket is at or
42
+ * above that bucket's lower edge, so **"calls producing more than N tokens" is
43
+ * exact** for the N this reports — it is only ever a bucket edge.
44
+ */
45
+
46
+ /** How the output spend of one label-and-model slice is distributed. */
47
+ export interface OutputShape {
48
+ label: string;
49
+ model: string;
50
+ modelName: string;
51
+ calls: number;
52
+ outputTokens: number;
53
+ outputUsd: number;
54
+ /** The bucket edge the heaviest group sits above. Always a bucket boundary. */
55
+ aboveTokens: number;
56
+ /** How many calls are in that group. */
57
+ heavyCalls: number;
58
+ /** Their share of the calls in this slice. */
59
+ heavyCallShare: number;
60
+ /** Their share of this slice's output spend — at least a half, by construction. */
61
+ heavySpendShare: number;
62
+ /** This slice's output spend as a fraction of the whole bill. */
63
+ shareOfBill: number;
64
+ /**
65
+ * The bucket ceiling that at least half the measured answers fit within.
66
+ *
67
+ * A ceiling by construction, never an interpolation: the histogram knows
68
+ * which bucket the median call landed in, and the honest sentence is "half
69
+ * the answers fit within N tokens" where N is that bucket's upper edge.
70
+ * `null` only when the covering bucket is the open-ended last one, which has
71
+ * no ceiling to name.
72
+ */
73
+ medianWithinTokens: number | null;
74
+ /**
75
+ * The same ceiling for 95% of the measured answers — the number somebody
76
+ * setting `max_tokens` actually wants. Measured on these calls, promised for
77
+ * nothing.
78
+ */
79
+ p95WithinTokens: number | null;
80
+ }
81
+
82
+ export interface OutputShapeOptions {
83
+ catalogue: PricingCatalogue;
84
+ on?: Date;
85
+ /** Slices whose output is below this share of the bill are dropped. Default 5%. */
86
+ minShare?: number;
87
+ }
88
+
89
+ /**
90
+ * Bucket edges, fine where answers actually land and coarse in the tail.
91
+ *
92
+ * 64 tokens up to 8,192 covers ordinary answers at a resolution finer than anybody
93
+ * would act on; past that the buckets widen, because the difference between a
94
+ * 40,000-token answer and a 41,000-token one changes no decision. The last bucket is
95
+ * open-ended so nothing falls off the end — an answer longer than the largest edge
96
+ * still counts, in the group where it belongs.
97
+ */
98
+ const EDGES: number[] = (() => {
99
+ const edges: number[] = [];
100
+ for (let t = 0; t < 8192; t += 64) edges.push(t);
101
+ for (let t = 8192; t < 131_072; t += 1024) edges.push(t);
102
+ return edges;
103
+ })();
104
+
105
+ /** Index of the bucket a count falls in. The last bucket is open-ended. */
106
+ function bucketOf(tokens: number): number {
107
+ if (tokens >= EDGES[EDGES.length - 1]!) return EDGES.length - 1;
108
+ if (tokens < 8192) return Math.floor(tokens / 64);
109
+ return 128 + Math.floor((tokens - 8192) / 1024);
110
+ }
111
+
112
+ /** A bucket's upper edge, or `null` for the open-ended last one. */
113
+ function upperEdgeOf(bucket: number): number | null {
114
+ if (bucket >= EDGES.length - 1) return null;
115
+ return EDGES[bucket + 1]!;
116
+ }
117
+
118
+ /**
119
+ * The bucket ceiling covering `share` of the calls, walking up from the
120
+ * shortest answers. Exact over the histogram: every call at or below the
121
+ * returned ceiling is counted, none is interpolated.
122
+ */
123
+ function ceilingFor(
124
+ buckets: Map<number, { calls: number; tokens: number }>,
125
+ totalCalls: number,
126
+ share: number,
127
+ ): number | null {
128
+ const ascending = [...buckets.keys()].sort((a, b) => a - b);
129
+ const target = totalCalls * share;
130
+ let covered = 0;
131
+ for (const b of ascending) {
132
+ covered += buckets.get(b)!.calls;
133
+ if (covered >= target) return upperEdgeOf(b);
134
+ }
135
+ return upperEdgeOf(ascending[ascending.length - 1]!);
136
+ }
137
+
138
+ interface Slice {
139
+ calls: number;
140
+ outputTokens: number;
141
+ outputUsd: number;
142
+ /** Calls and output tokens per bucket, sparse. */
143
+ buckets: Map<number, { calls: number; tokens: number }>;
144
+ }
145
+
146
+ export interface OutputShapeTracker {
147
+ add(record: UsageRecord): void;
148
+ finish(totalUsd: number): OutputShape[];
149
+ }
150
+
151
+ /**
152
+ * An accumulator, fed in the pass a profile already makes.
153
+ *
154
+ * What it holds is bounded by the number of slices times the number of buckets any
155
+ * of them actually touches, not by the size of the log.
156
+ */
157
+ export function createOutputShapeTracker(options: OutputShapeOptions): OutputShapeTracker {
158
+ const { catalogue, on = new Date(), minShare = 0.05 } = options;
159
+ const slices = new Map<string, Slice>();
160
+
161
+ const add = (record: UsageRecord): void => {
162
+ const model = catalogue.byId.get(record.model);
163
+ // An unpriced model contributes no dollars anywhere else; a shape drawn from
164
+ // one would be a distribution of a bill that was never computed.
165
+ if (!model) return;
166
+ if (record.outputTokens <= 0) return;
167
+
168
+ const key = `${record.label ?? UNLABELLED}\n${record.model}`;
169
+ let slice = slices.get(key);
170
+ if (!slice) {
171
+ slice = { calls: 0, outputTokens: 0, outputUsd: 0, buckets: new Map() };
172
+ slices.set(key, slice);
173
+ }
174
+
175
+ const { outputPerMTok } = effectivePricing(model, on);
176
+ slice.calls += 1;
177
+ slice.outputTokens += record.outputTokens;
178
+ slice.outputUsd += (record.outputTokens / 1_000_000) * outputPerMTok;
179
+
180
+ const b = bucketOf(record.outputTokens);
181
+ const cell = slice.buckets.get(b);
182
+ if (cell) {
183
+ cell.calls += 1;
184
+ cell.tokens += record.outputTokens;
185
+ } else {
186
+ slice.buckets.set(b, { calls: 1, tokens: record.outputTokens });
187
+ }
188
+ };
189
+
190
+ const finish = (totalUsd: number): OutputShape[] => {
191
+ const out: OutputShape[] = [];
192
+
193
+ for (const [key, slice] of slices) {
194
+ const split = key.indexOf('\n');
195
+ const label = key.slice(0, split);
196
+ const modelId = key.slice(split + 1);
197
+ const model = catalogue.byId.get(modelId);
198
+ if (!model || slice.outputTokens === 0) continue;
199
+
200
+ const shareOfBill = totalUsd > 0 ? slice.outputUsd / totalUsd : 0;
201
+ if (shareOfBill < minShare) continue;
202
+
203
+ /**
204
+ * Walk down from the longest answers until half the output spend is
205
+ * covered. Spend is proportional to tokens inside one slice — one model,
206
+ * one rate — so the tokens are the money here and no second accumulator is
207
+ * needed.
208
+ */
209
+ const target = slice.outputTokens / 2;
210
+ const descending = [...slice.buckets.keys()].sort((a, b) => b - a);
211
+ let tokens = 0;
212
+ let heavyCalls = 0;
213
+ let lastBucket = descending[0]!;
214
+ for (const b of descending) {
215
+ const cell = slice.buckets.get(b)!;
216
+ tokens += cell.tokens;
217
+ heavyCalls += cell.calls;
218
+ lastBucket = b;
219
+ if (tokens >= target) break;
220
+ }
221
+
222
+ out.push({
223
+ label,
224
+ model: modelId,
225
+ modelName: model.displayName,
226
+ calls: slice.calls,
227
+ outputTokens: slice.outputTokens,
228
+ outputUsd: slice.outputUsd,
229
+ aboveTokens: EDGES[lastBucket]!,
230
+ heavyCalls,
231
+ heavyCallShare: heavyCalls / slice.calls,
232
+ heavySpendShare: tokens / slice.outputTokens,
233
+ shareOfBill,
234
+ medianWithinTokens: ceilingFor(slice.buckets, slice.calls, 0.5),
235
+ p95WithinTokens: ceilingFor(slice.buckets, slice.calls, 0.95),
236
+ });
237
+ }
238
+
239
+ return out.sort((a, b) => b.outputUsd - a.outputUsd);
240
+ };
241
+
242
+ return { add, finish };
243
+ }
244
+
245
+ /** The same measurement over a list of records, for a caller holding one. */
246
+ export function outputShapes(
247
+ records: readonly UsageRecord[],
248
+ totalUsd: number,
249
+ options: OutputShapeOptions,
250
+ ): OutputShape[] {
251
+ const tracker = createOutputShapeTracker(options);
252
+ for (const record of records) tracker.add(record);
253
+ return tracker.finish(totalUsd);
254
+ }
@@ -1,7 +1,7 @@
1
1
  import { BUNDLED_CATALOGUE } from './pricing.js';
2
2
  import { nearestName } from './nearest.js';
3
3
  import type { PricingCatalogue } from './pricing.js';
4
- import type { Capability, CachingMode, ModelPricing } from './types.js';
4
+ import type { Capability, CachingMode, CostMultipliers, ModelPricing } from './types.js';
5
5
 
6
6
  /**
7
7
  * Local price corrections, so a price change does not require a library upgrade.
@@ -34,10 +34,28 @@ export const PRICING_MODEL_KEYS = [
34
34
  'caching',
35
35
  'notes',
36
36
  'promo',
37
+ 'multipliers',
37
38
  ] as const;
38
39
 
39
40
  const PROMO_KEYS = ['inputPerMTok', 'outputPerMTok', 'until'] as const;
40
41
 
42
+ /**
43
+ * Cache and batch rates, which an overlay had no way to state — and had to.
44
+ *
45
+ * Without this, a model added through `--pricing` silently inherited Anthropic's
46
+ * multipliers: a cache write at 1.25x input, or 2x at the long TTL. Most
47
+ * providers charge plain input for a write, so `trazum profile` computed a
48
+ * premium that model never charged, accused the workload of a caching loss it
49
+ * could not have, and told the reader to turn caching off. Three documents
50
+ * claimed that could not happen to a provider whose writes cost what input costs,
51
+ * and for an overlay model it was exactly what happened.
52
+ *
53
+ * `batch` accepts `null`, which is not the same as leaving it out: "this provider
54
+ * has no batch API" should stop the advisory offering a discount nobody sells,
55
+ * while "nobody has said" should fall back to the default.
56
+ */
57
+ const MULTIPLIER_KEYS = ['cacheRead', 'cacheWrite5m', 'cacheWrite1h', 'batch'] as const;
58
+
41
59
  const TIERS: ModelPricing['tier'][] = ['frontier', 'opus', 'sonnet', 'haiku', 'unknown'];
42
60
  const CAPABILITIES: Capability[] = ['small', 'mid', 'large', 'frontier', 'unknown'];
43
61
  const CACHING_MODES: CachingMode[] = ['explicit', 'automatic', 'none', 'unknown'];
@@ -129,6 +147,36 @@ function parsePromo(raw: unknown, label: string, source: string): ModelPricing['
129
147
  };
130
148
  }
131
149
 
150
+ function parseMultipliers(raw: unknown, label: string, source: string): CostMultipliers {
151
+ if (!isPlainObject(raw)) {
152
+ throw new PricingOverlayError(`"${label}" must be an object`, source);
153
+ }
154
+ rejectUnknownKeys(raw, MULTIPLIER_KEYS, source, `${label}.`);
155
+ if (Object.keys(raw).length === 0) {
156
+ throw new PricingOverlayError(
157
+ `"${label}" is empty — remove it, or say which rate it changes`,
158
+ source,
159
+ );
160
+ }
161
+
162
+ const multipliers: CostMultipliers = {};
163
+ for (const key of ['cacheRead', 'cacheWrite5m', 'cacheWrite1h'] as const) {
164
+ if (raw[key] === undefined) continue;
165
+ /**
166
+ * Zero is refused along with the negatives. A free cache read is not a price
167
+ * anybody publishes, and admitting one would let an overlay typo turn a real
168
+ * cost into no cost — the flattering direction, and the one this whole file
169
+ * exists to keep out of a report.
170
+ */
171
+ multipliers[key] = positiveNumber(raw[key], `${label}.${key}`, source);
172
+ }
173
+ if (raw.batch !== undefined) {
174
+ multipliers.batch =
175
+ raw.batch === null ? null : positiveNumber(raw.batch, `${label}.batch`, source);
176
+ }
177
+ return multipliers;
178
+ }
179
+
132
180
  function parseModel(
133
181
  raw: unknown,
134
182
  id: string,
@@ -209,6 +257,9 @@ function parseModel(
209
257
  // `null` is how you cancel a bundled promotion that has been withdrawn.
210
258
  model.promo = raw.promo === null ? undefined : parsePromo(raw.promo, `models.${id}.promo`, source);
211
259
  }
260
+ if (raw.multipliers !== undefined) {
261
+ model.multipliers = parseMultipliers(raw.multipliers, `models.${id}.multipliers`, source);
262
+ }
212
263
 
213
264
  return model;
214
265
  }