@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/repeats.ts ADDED
@@ -0,0 +1,166 @@
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
+ * The same request, sent again a moment later.
8
+ *
9
+ * ## The failure mode nothing else here can see
10
+ *
11
+ * A conversation's input **grows with every turn** — that is the finding
12
+ * `conversations` exists for. So two consecutive calls in one conversation
13
+ * carrying *exactly* the same input size, seconds apart, is the shape of
14
+ * something going wrong rather than something working: a retry after a
15
+ * timeout, an agent step repeating because a tool call failed, a loop that
16
+ * re-sends the whole context and gets nowhere.
17
+ *
18
+ * That is expensive in a way a total hides completely. The retried call is
19
+ * billed in full, and on an agent workload the input is the bill.
20
+ * `duplicateLines` catches the same line recorded twice; this catches two
21
+ * *different* calls that sent the same thing.
22
+ *
23
+ * ## What it compares, and why only that
24
+ *
25
+ * Each call is compared to **the one immediately before it in the same
26
+ * session**, and only when the log carries both a session and a clock. Not to
27
+ * every earlier call: a workload that legitimately sends a fixed-size prompt
28
+ * would light up under a looser rule, and holding every size a session has
29
+ * ever sent would grow without bound on a log measured in megabytes. One
30
+ * previous call per session is bounded and is where a retry actually sits.
31
+ *
32
+ * The gap must be **non-negative and under the window** (a minute by default).
33
+ * Non-negative because a log is not guaranteed to be in time order, and a
34
+ * negative gap means the two records arrived out of order rather than that a
35
+ * call repeated.
36
+ *
37
+ * ## What it refuses to conclude
38
+ *
39
+ * It cannot see content, so it cannot tell a retry from two genuinely
40
+ * identical requests a second apart. It reports the count and the money and
41
+ * stops — the same rule `duplicateLines` follows. Every rendering says the
42
+ * pattern is *usually* a retry or a loop, never that it is one.
43
+ */
44
+
45
+ /** Consecutive same-size calls in one conversation, per slice. */
46
+ export interface RepeatedTurns {
47
+ label: string;
48
+ model: string;
49
+ modelName: string;
50
+ /** How many calls repeated the previous call's input size inside the window. */
51
+ repeats: number;
52
+ /** Calls in this slice that could be checked at all — the denominator. */
53
+ checkedCalls: number;
54
+ /** What those repeated calls cost, in full. */
55
+ usd: number;
56
+ /** The window they had to fall inside, in milliseconds. */
57
+ withinMs: number;
58
+ }
59
+
60
+ export interface RepeatsOptions {
61
+ catalogue: PricingCatalogue;
62
+ on?: Date;
63
+ /**
64
+ * How close two calls have to be. A minute by default: long enough to cover
65
+ * a timeout and a retry, short enough that an ordinary next turn — which
66
+ * needs a human or a tool to produce it — rarely lands inside it.
67
+ */
68
+ withinMs?: number;
69
+ /** Slices below this many repeats are dropped. Default 2. */
70
+ minRepeats?: number;
71
+ }
72
+
73
+ export interface RepeatsTracker {
74
+ add(record: UsageRecord): void;
75
+ finish(): RepeatedTurns[];
76
+ }
77
+
78
+ interface Slice {
79
+ repeats: number;
80
+ checkedCalls: number;
81
+ usd: number;
82
+ }
83
+
84
+ /** An accumulator, fed in the pass a profile already makes. */
85
+ export function createRepeatsTracker(options: RepeatsOptions): RepeatsTracker {
86
+ const { catalogue, on = new Date(), withinMs = 60_000, minRepeats = 2 } = options;
87
+ const slices = new Map<string, Slice>();
88
+ /** The previous call of each session: its input size and when it happened. */
89
+ const previous = new Map<string, { tokens: number; ts: number }>();
90
+
91
+ const add = (record: UsageRecord): void => {
92
+ const model = catalogue.byId.get(record.model);
93
+ // An unpriced model has no dollars anywhere else; naming money here that
94
+ // no total contains would be a figure with nothing behind it.
95
+ if (!model) return;
96
+ if (record.session === null || record.ts === null) return;
97
+
98
+ const tokens =
99
+ record.inputTokens +
100
+ record.cacheReadTokens +
101
+ record.cacheWrite5mTokens +
102
+ record.cacheWrite1hTokens;
103
+ if (tokens <= 0) return;
104
+
105
+ const key = `${record.label ?? UNLABELLED}\n${record.model}`;
106
+ let slice = slices.get(key);
107
+ if (!slice) {
108
+ slice = { repeats: 0, checkedCalls: 0, usd: 0 };
109
+ slices.set(key, slice);
110
+ }
111
+ slice.checkedCalls += 1;
112
+
113
+ const before = previous.get(record.session);
114
+ previous.set(record.session, { tokens, ts: record.ts });
115
+ if (before === undefined) return;
116
+
117
+ const gap = record.ts - before.ts;
118
+ // Out of order is not a repeat, and neither is a call an hour later.
119
+ if (gap < 0 || gap >= withinMs) return;
120
+ if (before.tokens !== tokens) return;
121
+
122
+ const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
123
+ const rates = multipliersFor(model);
124
+ const per = (count: number, rate: number): number => (count / 1_000_000) * rate;
125
+ slice.repeats += 1;
126
+ slice.usd +=
127
+ per(record.inputTokens, inputPerMTok) +
128
+ per(record.cacheReadTokens, inputPerMTok * rates.cacheRead) +
129
+ per(record.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m) +
130
+ per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h) +
131
+ per(record.outputTokens, outputPerMTok);
132
+ };
133
+
134
+ const finish = (): RepeatedTurns[] => {
135
+ const out: RepeatedTurns[] = [];
136
+ for (const [key, slice] of slices) {
137
+ if (slice.repeats < minRepeats) continue;
138
+ const split = key.indexOf('\n');
139
+ const modelId = key.slice(split + 1);
140
+ const model = catalogue.byId.get(modelId);
141
+ if (!model) continue;
142
+ out.push({
143
+ label: key.slice(0, split),
144
+ model: modelId,
145
+ modelName: model.displayName,
146
+ repeats: slice.repeats,
147
+ checkedCalls: slice.checkedCalls,
148
+ usd: slice.usd,
149
+ withinMs,
150
+ });
151
+ }
152
+ return out.sort((a, b) => b.usd - a.usd);
153
+ };
154
+
155
+ return { add, finish };
156
+ }
157
+
158
+ /** The same measurement over a list of records, for a caller holding one. */
159
+ export function repeatedTurns(
160
+ records: readonly UsageRecord[],
161
+ options: RepeatsOptions,
162
+ ): RepeatedTurns[] {
163
+ const tracker = createRepeatsTracker(options);
164
+ for (const record of records) tracker.add(record);
165
+ return tracker.finish();
166
+ }
package/src/reprice.ts ADDED
@@ -0,0 +1,227 @@
1
+ import { effectivePricing, multipliersFor } from './pricing.js';
2
+ import type { PricingCatalogue } from './pricing.js';
3
+ import type { ModelPricing } from './types.js';
4
+ import type { UsageBreakdown, UsageProfileReport } from './usage.js';
5
+
6
+ /**
7
+ * The same tokens at another model's rates.
8
+ *
9
+ * ## Why this is allowed to exist
10
+ *
11
+ * `usage.ts` refuses to report a saving, and the reason is that a saving
12
+ * requires imagining a prompt nobody wrote. This does not. Every token here
13
+ * was actually billed; the only thing being changed is the rate card it is
14
+ * multiplied by, which is the same move `cacheEconomics` makes and is
15
+ * arithmetic rather than a guess.
16
+ *
17
+ * What it answers is the question a bill provokes and a total cannot settle:
18
+ * `classify` spent $4,000 on a frontier model — what would those exact calls
19
+ * have cost on the small one? That is a number, and it is the number a routing
20
+ * decision is argued over.
21
+ *
22
+ * ## The four things it refuses
23
+ *
24
+ * **It says nothing about whether the answers would be as good.** This is
25
+ * multiplication. Whether the cheap model can do the work is a question about
26
+ * the work, and Trazum has never seen the prompts — by design, there is nowhere
27
+ * in a usage record to put one. Every rendering states this next to the figure,
28
+ * because a dollar number with no caveat attached reads as a recommendation.
29
+ *
30
+ * **It refuses to price traffic that would not fit.** A cheaper model with a
31
+ * smaller context window does not make a 400k-token call cheaper; it makes it
32
+ * impossible. Slices holding a call larger than the target's window are pulled
33
+ * out into `overContext` and their money is excluded from every total here —
34
+ * counting an impossible call's price difference as a saving is exactly the
35
+ * flattering direction this repository refuses. `maxCallInputTokens` is the
36
+ * maximum rather than an average for the same reason: one call over the ceiling
37
+ * is a failed call, and a mean hides it.
38
+ *
39
+ * **It excludes what is already there.** Calls billed on the target model
40
+ * reprice to themselves, and folding them in would pad both totals with money
41
+ * that cannot move — a $10,000 bill of which $9,900 is already on the cheap
42
+ * model would report a 1% difference and read as "not worth doing". They are
43
+ * counted in `alreadyOnTarget` instead, so the reader sees the shape.
44
+ *
45
+ * **It assumes the token counts survive the move, and says so.** A different
46
+ * model tokenizes differently, and one that is worse at the task may answer at
47
+ * greater length or be retried. The counts are the ones that were billed, not a
48
+ * prediction; that makes this an exact restatement of the past at another price
49
+ * and an approximation of the future. Stated, not hidden — `sameTokensAssumed`
50
+ * exists so no rendering can forget to say it.
51
+ *
52
+ * ## Why the write TTLs are kept apart
53
+ *
54
+ * A cache write costs 1.25x input at five minutes and 2x at an hour, and that
55
+ * ratio is not a constant across providers. Repricing a combined write total
56
+ * would mean picking one of the two rates for tokens that were billed at both,
57
+ * so `UsageBreakdown` carries the split and this reads it. Writes whose TTL the
58
+ * log never stated are in the 5-minute bucket, the same assumption
59
+ * `cacheWriteUsdIfAssumed1h` prices — `assumedWriteTtlCalls` is carried through
60
+ * so a reader knows the comparison inherits it.
61
+ */
62
+
63
+ /** One label-and-model slice, as billed and as it would have been billed. */
64
+ export interface RepricedSlice {
65
+ label: string;
66
+ /** The model these calls were actually billed on. */
67
+ model: string;
68
+ calls: number;
69
+ /** What the log says this slice cost. */
70
+ currentUsd: number;
71
+ /** What the same tokens cost at the target's rates. */
72
+ targetUsd: number;
73
+ /** `targetUsd - currentUsd`. Negative is cheaper on the target. */
74
+ deltaUsd: number;
75
+ /** The slice's largest single call, cache reads and writes included. */
76
+ maxCallInputTokens: number;
77
+ }
78
+
79
+ /** A slice holding a call the target model could not have accepted. */
80
+ export interface OverContextSlice {
81
+ label: string;
82
+ model: string;
83
+ calls: number;
84
+ currentUsd: number;
85
+ /** The call that does not fit, in tokens. */
86
+ maxCallInputTokens: number;
87
+ }
88
+
89
+ export interface RepriceReport {
90
+ target: {
91
+ id: string;
92
+ displayName: string;
93
+ /** The ceiling `overContext` was judged against. */
94
+ contextWindow: number;
95
+ };
96
+ /**
97
+ * Slices that could move, largest saving first. Ties break on the larger
98
+ * bill, so the row somebody would act on comes first.
99
+ */
100
+ slices: RepricedSlice[];
101
+ /** Totals over `slices` only — the money that would actually change hands. */
102
+ currentUsd: number;
103
+ targetUsd: number;
104
+ deltaUsd: number;
105
+ /**
106
+ * Slices excluded because at least one of their calls is larger than the
107
+ * target's context window. Their money is in none of the totals above.
108
+ */
109
+ overContext: OverContextSlice[];
110
+ /** Calls already billed on the target, and what they cost. Not repriced. */
111
+ alreadyOnTarget: { calls: number; usd: number };
112
+ /**
113
+ * Repriced calls whose write TTL the log did not state. Non-zero means both
114
+ * sides of the comparison rest on the cheaper assumption.
115
+ */
116
+ assumedWriteTtlCalls: number;
117
+ /**
118
+ * Models in the log the catalogue could not price, and how many calls they
119
+ * made. Their tokens could be priced on the target, but the difference
120
+ * cannot be — there is no current figure to subtract from. Named rather
121
+ * than dropped, so a comparison covering half a bill cannot look complete.
122
+ */
123
+ unpricedModels: string[];
124
+ unpricedCalls: number;
125
+ /** Always true. A field, not a comment, so a rendering can print it. */
126
+ sameTokensAssumed: true;
127
+ }
128
+
129
+ /**
130
+ * What a set of token counts costs on one model, at the rates in force on a
131
+ * date.
132
+ *
133
+ * The same arithmetic `profileUsage` does per call, over an aggregate — which
134
+ * is only sound because every token class is priced independently of the
135
+ * others and of how many calls produced them.
136
+ */
137
+ export function priceTokensOn(
138
+ breakdown: Pick<
139
+ UsageBreakdown,
140
+ 'inputTokens' | 'cacheReadTokens' | 'cacheWrite5mTokens' | 'cacheWrite1hTokens' | 'outputTokens'
141
+ >,
142
+ model: ModelPricing,
143
+ on: Date = new Date(),
144
+ ): number {
145
+ const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
146
+ const rates = multipliersFor(model);
147
+ const per = (tokens: number, rate: number): number => (tokens / 1_000_000) * rate;
148
+ return (
149
+ per(breakdown.inputTokens, inputPerMTok) +
150
+ per(breakdown.cacheReadTokens, inputPerMTok * rates.cacheRead) +
151
+ per(breakdown.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m) +
152
+ per(breakdown.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h) +
153
+ per(breakdown.outputTokens, outputPerMTok)
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Reprices a profile's label-and-model slices onto one target model.
159
+ *
160
+ * Returns `null` when the catalogue does not know the target: a comparison
161
+ * against a price nobody has is worse than no comparison, and the caller is
162
+ * better placed to say so in its own words than this is to invent a zero.
163
+ */
164
+ export function repriceProfile(
165
+ report: UsageProfileReport,
166
+ targetId: string,
167
+ catalogue: PricingCatalogue,
168
+ on: Date = new Date(),
169
+ ): RepriceReport | null {
170
+ const target = catalogue.byId.get(targetId);
171
+ if (!target) return null;
172
+
173
+ const slices: RepricedSlice[] = [];
174
+ const overContext: OverContextSlice[] = [];
175
+ const alreadyOnTarget = { calls: 0, usd: 0 };
176
+ let assumedWriteTtlCalls = 0;
177
+
178
+ for (const slice of report.byLabelAndModel) {
179
+ const { breakdown } = slice;
180
+ if (slice.model === target.id) {
181
+ alreadyOnTarget.calls += breakdown.calls;
182
+ alreadyOnTarget.usd += breakdown.totalUsd;
183
+ continue;
184
+ }
185
+ if (breakdown.maxCallInputTokens > target.contextWindow) {
186
+ overContext.push({
187
+ label: slice.label,
188
+ model: slice.model,
189
+ calls: breakdown.calls,
190
+ currentUsd: breakdown.totalUsd,
191
+ maxCallInputTokens: breakdown.maxCallInputTokens,
192
+ });
193
+ continue;
194
+ }
195
+ const targetUsd = priceTokensOn(breakdown, target, on);
196
+ assumedWriteTtlCalls += breakdown.assumedWriteTtlCalls;
197
+ slices.push({
198
+ label: slice.label,
199
+ model: slice.model,
200
+ calls: breakdown.calls,
201
+ currentUsd: breakdown.totalUsd,
202
+ targetUsd,
203
+ deltaUsd: targetUsd - breakdown.totalUsd,
204
+ maxCallInputTokens: breakdown.maxCallInputTokens,
205
+ });
206
+ }
207
+
208
+ slices.sort((a, b) => a.deltaUsd - b.deltaUsd || b.currentUsd - a.currentUsd);
209
+ overContext.sort((a, b) => b.currentUsd - a.currentUsd);
210
+
211
+ const currentUsd = slices.reduce((sum, s) => sum + s.currentUsd, 0);
212
+ const targetUsd = slices.reduce((sum, s) => sum + s.targetUsd, 0);
213
+
214
+ return {
215
+ target: { id: target.id, displayName: target.displayName, contextWindow: target.contextWindow },
216
+ slices,
217
+ currentUsd,
218
+ targetUsd,
219
+ deltaUsd: targetUsd - currentUsd,
220
+ overContext,
221
+ alreadyOnTarget,
222
+ assumedWriteTtlCalls,
223
+ unpricedModels: report.unpricedModels,
224
+ unpricedCalls: report.unpriced.calls,
225
+ sameTokensAssumed: true,
226
+ };
227
+ }
@@ -0,0 +1,170 @@
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 one conversation costs.
8
+ *
9
+ * ## The question a total cannot answer
10
+ *
11
+ * "Support cost $4,000 last month" does not say whether that is forty thousand
12
+ * cheap conversations or four hundred expensive ones, and every decision made
13
+ * on top of it needs the answer: what to charge per seat, where to put a quota,
14
+ * whether one runaway agent loop is eating the budget. The bill has the data —
15
+ * the log groups by `session` already — and nothing was reporting it.
16
+ *
17
+ * ## Median and p95, not mean
18
+ *
19
+ * A mean conversation cost is the total divided by the session count, which is
20
+ * the total again wearing a hat: one 400-turn agent loop drags it up and hides
21
+ * the ordinary case. The **median** is the conversation in the middle — what a
22
+ * typical one costs — and the **p95** is the one a quota has to survive. The
23
+ * gap between them is the finding: `$0.02 median, $1.80 p95` is a workload with
24
+ * a tail worth hunting; `$0.40 median, $0.55 p95` is a workload that is simply
25
+ * expensive, and no amount of tail-hunting will fix it.
26
+ *
27
+ * Every figure is **exact** — the provider's own billed counts, summed per
28
+ * conversation at each model's published rates. No counterfactual, no estimate.
29
+ *
30
+ * ## What it refuses to claim
31
+ *
32
+ * A conversation that started before this log or continues after it is counted
33
+ * only for the turns recorded here, so its cost is a floor. That is stated
34
+ * rather than corrected: guessing at unseen turns would be exactly the kind of
35
+ * invention this package exists to end. Session keys group turns and never
36
+ * leave this module, as everywhere the field is touched.
37
+ */
38
+
39
+ export interface SessionCostShape {
40
+ label: string;
41
+ model: string;
42
+ modelName: string;
43
+ /** Conversations measured. Never which ones. */
44
+ sessions: number;
45
+ calls: number;
46
+ /** What those conversations cost in total — exact, billed. */
47
+ totalUsd: number;
48
+ /** The conversation in the middle. */
49
+ medianUsd: number;
50
+ /** The conversation a quota has to survive: 95th percentile, by nearest rank. */
51
+ p95Usd: number;
52
+ /** The single most expensive conversation in the slice. */
53
+ maxUsd: number;
54
+ /** Turns in the median conversation, for scale. */
55
+ medianTurns: number;
56
+ }
57
+
58
+ export interface SessionCostOptions {
59
+ catalogue: PricingCatalogue;
60
+ on?: Date;
61
+ /**
62
+ * Slices with fewer conversations than this are dropped: a median over three
63
+ * sessions is not a median, it is one of the three, and a p95 over them is
64
+ * the maximum wearing a percentile's name. Default 5.
65
+ */
66
+ minSessions?: number;
67
+ }
68
+
69
+ export interface SessionCostTracker {
70
+ add(record: UsageRecord): void;
71
+ finish(): SessionCostShape[];
72
+ }
73
+
74
+ /** Every billed dollar of one call, at its own model's rates. */
75
+ function costOf(record: UsageRecord, catalogue: PricingCatalogue, on: Date): number | null {
76
+ const model = catalogue.byId.get(record.model);
77
+ if (!model) return null;
78
+ const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
79
+ const rates = multipliersFor(model);
80
+ const per = (tokens: number, rate: number): number => (tokens / 1_000_000) * rate;
81
+ return (
82
+ per(record.inputTokens, inputPerMTok) +
83
+ per(record.cacheReadTokens, inputPerMTok * rates.cacheRead) +
84
+ per(record.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m) +
85
+ per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h) +
86
+ per(record.outputTokens, outputPerMTok)
87
+ );
88
+ }
89
+
90
+ const median = (sorted: number[]): number => {
91
+ const mid = Math.floor(sorted.length / 2);
92
+ return sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
93
+ };
94
+
95
+ /**
96
+ * The 95th percentile by **nearest rank**: the smallest value at or above which
97
+ * 95% of the conversations sit. Interpolating between two conversations would
98
+ * report a cost no conversation had, and this figure exists to be compared
99
+ * against a real quota.
100
+ */
101
+ const p95 = (sorted: number[]): number =>
102
+ sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)]!;
103
+
104
+ export function createSessionCostTracker(options: SessionCostOptions): SessionCostTracker {
105
+ const { catalogue, on = new Date(), minSessions = 5 } = options;
106
+ const slices = new Map<string, Map<string, { usd: number; turns: number }>>();
107
+
108
+ const add = (record: UsageRecord): void => {
109
+ if (record.session === null) return;
110
+ const cost = costOf(record, catalogue, on);
111
+ // An unpriced model contributes no dollars anywhere else either.
112
+ if (cost === null) return;
113
+
114
+ const sliceKey = `${record.label ?? UNLABELLED}\n${record.model}`;
115
+ let sessions = slices.get(sliceKey);
116
+ if (!sessions) {
117
+ sessions = new Map();
118
+ slices.set(sliceKey, sessions);
119
+ }
120
+ const existing = sessions.get(record.session);
121
+ if (existing) {
122
+ existing.usd += cost;
123
+ existing.turns += 1;
124
+ } else {
125
+ sessions.set(record.session, { usd: cost, turns: 1 });
126
+ }
127
+ };
128
+
129
+ const finish = (): SessionCostShape[] => {
130
+ const out: SessionCostShape[] = [];
131
+
132
+ for (const [sliceKey, sessions] of slices) {
133
+ if (sessions.size < minSessions) continue;
134
+ const split = sliceKey.indexOf('\n');
135
+ const modelId = sliceKey.slice(split + 1);
136
+ const model = catalogue.byId.get(modelId);
137
+ if (!model) continue;
138
+
139
+ const costs = [...sessions.values()].map((s) => s.usd).sort((a, b) => a - b);
140
+ const turns = [...sessions.values()].map((s) => s.turns).sort((a, b) => a - b);
141
+ out.push({
142
+ label: sliceKey.slice(0, split),
143
+ model: modelId,
144
+ modelName: model.displayName,
145
+ sessions: sessions.size,
146
+ calls: turns.reduce((sum, t) => sum + t, 0),
147
+ totalUsd: costs.reduce((sum, c) => sum + c, 0),
148
+ medianUsd: median(costs),
149
+ p95Usd: p95(costs),
150
+ maxUsd: costs[costs.length - 1]!,
151
+ medianTurns: median(turns),
152
+ });
153
+ }
154
+
155
+ // The most money first — the order somebody would act in.
156
+ return out.sort((a, b) => b.totalUsd - a.totalUsd || a.label.localeCompare(b.label));
157
+ };
158
+
159
+ return { add, finish };
160
+ }
161
+
162
+ /** The same measurement over a list, for a caller holding one already. */
163
+ export function sessionCostShapes(
164
+ records: readonly UsageRecord[],
165
+ options: SessionCostOptions,
166
+ ): SessionCostShape[] {
167
+ const tracker = createSessionCostTracker(options);
168
+ for (const record of records) tracker.add(record);
169
+ return tracker.finish();
170
+ }