@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
@@ -0,0 +1,259 @@
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
+ * How big a call's input actually is, and how uneven that is across a slice.
8
+ *
9
+ * ## The half of the bill nothing described
10
+ *
11
+ * `outputShapes` says where the *output* spend concentrates. Input had a total
12
+ * and nothing else — and on a RAG or agent workload input is most of the bill,
13
+ * made of retrieved context, conversation history and tool results that no
14
+ * prompt file contains. "Input is 63% of this bill" is true and unactionable;
15
+ * the question somebody can act on is whether that 63% is *every* call
16
+ * carrying a large prompt, or a few calls carrying an enormous one.
17
+ *
18
+ * Two slices with identical input spend want opposite responses:
19
+ *
20
+ * - **Even.** The p95 call carries roughly what the median call carries. The
21
+ * prompt is simply large, and the lever is the prompt: fewer retrieved
22
+ * documents, a shorter system block, caching if the prefix repeats.
23
+ * - **Skewed.** The p95 call carries twelve times the median. Something is
24
+ * growing — a conversation nobody truncates, a retrieval with no cap, a
25
+ * tool result pasted in whole. The median call is fine and the fix is a
26
+ * limit, not a rewrite.
27
+ *
28
+ * A total cannot tell those apart, and neither can the per-day series.
29
+ *
30
+ * ## What "input" means here
31
+ *
32
+ * Everything the model read: fresh input, cache reads and cache writes. That
33
+ * is the size of the request, which is what a context window and a retrieval
34
+ * cap are about. `cachedShare` then says how much of it was billed at the
35
+ * cache-read rate — a tenth of input on Anthropic — because a slice whose
36
+ * large calls are almost entirely cache reads is a very different bill from
37
+ * one paying full rate for the same tokens, and the token counts alone cannot
38
+ * tell them apart.
39
+ *
40
+ * ## Ceilings, never interpolations
41
+ *
42
+ * The counts live in fixed buckets, so a usage log measured in megabytes costs
43
+ * bounded memory. Every figure reported is a **bucket edge**: "half the calls
44
+ * fit within N tokens" is exact for the N named, where interpolating a median
45
+ * between two buckets would invent a call nobody made. `p95OverMedian` is
46
+ * therefore a ratio of two ceilings and coarse by construction — it is a shape,
47
+ * not a measurement, and the copy that renders it says which.
48
+ */
49
+
50
+ /** How one label-and-model slice's input is distributed across its calls. */
51
+ export interface InputShape {
52
+ label: string;
53
+ model: string;
54
+ modelName: string;
55
+ calls: number;
56
+ /** Fresh input, cache reads and cache writes — everything the model read. */
57
+ inputTokens: number;
58
+ /** What those tokens cost, at each class's own rate. */
59
+ inputUsd: number;
60
+ /** This slice's input spend as a fraction of the whole bill. */
61
+ shareOfBill: number;
62
+ /**
63
+ * The bucket ceiling at least half the calls fit within, and the same for
64
+ * 95% of them. `null` only when the covering bucket is the open-ended last
65
+ * one, which has no ceiling to name.
66
+ */
67
+ medianWithinTokens: number | null;
68
+ p95WithinTokens: number | null;
69
+ /**
70
+ * `p95WithinTokens / medianWithinTokens` — how much bigger the large calls
71
+ * are than the ordinary one. A ratio of two ceilings, so it is coarse on
72
+ * purpose; `null` when either ceiling is unknown or the median ceiling is
73
+ * zero.
74
+ */
75
+ p95OverMedian: number | null;
76
+ /**
77
+ * The share of these tokens that were cache reads.
78
+ *
79
+ * Says what the size actually costs: on Anthropic a cache read is a tenth of
80
+ * input, so a slice at 0.9 here is large and cheap, and one at 0 is large at
81
+ * full rate. Without it, "the p95 call carries 400,000 tokens" reads as an
82
+ * emergency in a workload that is caching correctly.
83
+ */
84
+ cachedShare: number;
85
+ }
86
+
87
+ export interface InputShapeOptions {
88
+ catalogue: PricingCatalogue;
89
+ on?: Date;
90
+ /** Slices whose input is below this share of the bill are dropped. Default 5%. */
91
+ minShare?: number;
92
+ /**
93
+ * Slices with fewer calls than this are dropped. Default 20.
94
+ *
95
+ * A p95 over four calls is the largest of the four wearing a percentile's
96
+ * name, and the sentence this feeds — "the large calls are twelve times the
97
+ * ordinary one" — would be a description of one call.
98
+ */
99
+ minCalls?: number;
100
+ }
101
+
102
+ /**
103
+ * Bucket edges sized for requests rather than answers.
104
+ *
105
+ * 512 tokens up to 65,536 is finer than any decision about a prompt, and past
106
+ * that the buckets widen to 8,192: the difference between a 400,000-token
107
+ * request and a 404,000-token one changes nothing. The last bucket is
108
+ * open-ended so a call larger than the widest edge still lands somewhere,
109
+ * counted rather than dropped.
110
+ */
111
+ const SMALL_STEP = 512;
112
+ const SMALL_LIMIT = 65_536;
113
+ const LARGE_STEP = 8_192;
114
+ const LARGE_LIMIT = 1_048_576;
115
+
116
+ const EDGES: number[] = (() => {
117
+ const edges: number[] = [];
118
+ for (let t = 0; t < SMALL_LIMIT; t += SMALL_STEP) edges.push(t);
119
+ for (let t = SMALL_LIMIT; t < LARGE_LIMIT; t += LARGE_STEP) edges.push(t);
120
+ return edges;
121
+ })();
122
+
123
+ const SMALL_BUCKETS = SMALL_LIMIT / SMALL_STEP;
124
+
125
+ /** Index of the bucket a count falls in. The last bucket is open-ended. */
126
+ function bucketOf(tokens: number): number {
127
+ if (tokens >= EDGES[EDGES.length - 1]!) return EDGES.length - 1;
128
+ if (tokens < SMALL_LIMIT) return Math.floor(tokens / SMALL_STEP);
129
+ return SMALL_BUCKETS + Math.floor((tokens - SMALL_LIMIT) / LARGE_STEP);
130
+ }
131
+
132
+ /** A bucket's upper edge, or `null` for the open-ended last one. */
133
+ function upperEdgeOf(bucket: number): number | null {
134
+ if (bucket >= EDGES.length - 1) return null;
135
+ return EDGES[bucket + 1]!;
136
+ }
137
+
138
+ /**
139
+ * The bucket ceiling covering `share` of the calls, walking up from the
140
+ * smallest requests. Exact over the histogram: every call at or below the
141
+ * returned ceiling is counted, none is interpolated.
142
+ */
143
+ function ceilingFor(buckets: Map<number, number>, totalCalls: number, share: number): number | null {
144
+ const ascending = [...buckets.keys()].sort((a, b) => a - b);
145
+ const target = totalCalls * share;
146
+ let covered = 0;
147
+ for (const b of ascending) {
148
+ covered += buckets.get(b)!;
149
+ if (covered >= target) return upperEdgeOf(b);
150
+ }
151
+ return upperEdgeOf(ascending[ascending.length - 1]!);
152
+ }
153
+
154
+ interface Slice {
155
+ calls: number;
156
+ inputTokens: number;
157
+ cachedTokens: number;
158
+ inputUsd: number;
159
+ /** Calls per bucket, sparse. */
160
+ buckets: Map<number, number>;
161
+ }
162
+
163
+ export interface InputShapeTracker {
164
+ add(record: UsageRecord): void;
165
+ finish(totalUsd: number): InputShape[];
166
+ }
167
+
168
+ /** An accumulator, fed in the pass a profile already makes. */
169
+ export function createInputShapeTracker(options: InputShapeOptions): InputShapeTracker {
170
+ const { catalogue, on = new Date(), minShare = 0.05, minCalls = 20 } = options;
171
+ const slices = new Map<string, Slice>();
172
+
173
+ const add = (record: UsageRecord): void => {
174
+ const model = catalogue.byId.get(record.model);
175
+ // An unpriced model contributes no dollars anywhere else; a shape drawn
176
+ // from one would describe a bill that was never computed.
177
+ if (!model) return;
178
+
179
+ const tokens =
180
+ record.inputTokens +
181
+ record.cacheReadTokens +
182
+ record.cacheWrite5mTokens +
183
+ record.cacheWrite1hTokens;
184
+ if (tokens <= 0) return;
185
+
186
+ const key = `${record.label ?? UNLABELLED}\n${record.model}`;
187
+ let slice = slices.get(key);
188
+ if (!slice) {
189
+ slice = { calls: 0, inputTokens: 0, cachedTokens: 0, inputUsd: 0, buckets: new Map() };
190
+ slices.set(key, slice);
191
+ }
192
+
193
+ const { inputPerMTok } = effectivePricing(model, on);
194
+ const rates = multipliersFor(model);
195
+ const per = (count: number, rate: number): number => (count / 1_000_000) * rate;
196
+
197
+ slice.calls += 1;
198
+ slice.inputTokens += tokens;
199
+ slice.cachedTokens += record.cacheReadTokens;
200
+ slice.inputUsd +=
201
+ per(record.inputTokens, inputPerMTok) +
202
+ per(record.cacheReadTokens, inputPerMTok * rates.cacheRead) +
203
+ per(record.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m) +
204
+ per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h);
205
+
206
+ const b = bucketOf(tokens);
207
+ slice.buckets.set(b, (slice.buckets.get(b) ?? 0) + 1);
208
+ };
209
+
210
+ const finish = (totalUsd: number): InputShape[] => {
211
+ const out: InputShape[] = [];
212
+
213
+ for (const [key, slice] of slices) {
214
+ const split = key.indexOf('\n');
215
+ const label = key.slice(0, split);
216
+ const modelId = key.slice(split + 1);
217
+ const model = catalogue.byId.get(modelId);
218
+ if (!model || slice.calls < minCalls) continue;
219
+
220
+ const shareOfBill = totalUsd > 0 ? slice.inputUsd / totalUsd : 0;
221
+ if (shareOfBill < minShare) continue;
222
+
223
+ const medianWithinTokens = ceilingFor(slice.buckets, slice.calls, 0.5);
224
+ const p95WithinTokens = ceilingFor(slice.buckets, slice.calls, 0.95);
225
+
226
+ out.push({
227
+ label,
228
+ model: modelId,
229
+ modelName: model.displayName,
230
+ calls: slice.calls,
231
+ inputTokens: slice.inputTokens,
232
+ inputUsd: slice.inputUsd,
233
+ shareOfBill,
234
+ medianWithinTokens,
235
+ p95WithinTokens,
236
+ p95OverMedian:
237
+ medianWithinTokens !== null && p95WithinTokens !== null && medianWithinTokens > 0
238
+ ? p95WithinTokens / medianWithinTokens
239
+ : null,
240
+ cachedShare: slice.inputTokens > 0 ? slice.cachedTokens / slice.inputTokens : 0,
241
+ });
242
+ }
243
+
244
+ return out.sort((a, b) => b.inputUsd - a.inputUsd);
245
+ };
246
+
247
+ return { add, finish };
248
+ }
249
+
250
+ /** The same measurement over a list of records, for a caller holding one. */
251
+ export function inputShapes(
252
+ records: readonly UsageRecord[],
253
+ totalUsd: number,
254
+ options: InputShapeOptions,
255
+ ): InputShape[] {
256
+ const tracker = createInputShapeTracker(options);
257
+ for (const record of records) tracker.add(record);
258
+ return tracker.finish(totalUsd);
259
+ }
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.