@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.
- package/README.md +12 -5
- package/dist/advisories.d.ts.map +1 -1
- package/dist/advisories.js +94 -4
- package/dist/advisories.js.map +1 -1
- package/dist/against.d.ts +32 -0
- package/dist/against.d.ts.map +1 -0
- package/dist/against.js +34 -0
- package/dist/against.js.map +1 -0
- package/dist/config-schema.d.ts +42 -1
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +61 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/conversation.d.ts +121 -0
- package/dist/conversation.d.ts.map +1 -0
- package/dist/conversation.js +157 -0
- package/dist/conversation.js.map +1 -0
- package/dist/csv.d.ts +61 -0
- package/dist/csv.d.ts.map +1 -0
- package/dist/csv.js +149 -0
- package/dist/csv.js.map +1 -0
- package/dist/evaluate.d.ts +24 -0
- package/dist/evaluate.d.ts.map +1 -1
- package/dist/evaluate.js +5 -2
- package/dist/evaluate.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +18 -7
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +18 -7
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +28 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/input-shape.d.ts +104 -0
- package/dist/input-shape.d.ts.map +1 -0
- package/dist/input-shape.js +132 -0
- package/dist/input-shape.js.map +1 -0
- package/dist/levers.d.ts +151 -0
- package/dist/levers.d.ts.map +1 -0
- package/dist/levers.js +160 -0
- package/dist/levers.js.map +1 -0
- package/dist/node.d.ts +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/output-shape.d.ts +96 -0
- package/dist/output-shape.d.ts.map +1 -0
- package/dist/output-shape.js +145 -0
- package/dist/output-shape.js.map +1 -0
- package/dist/pricing-overlay.d.ts +1 -1
- package/dist/pricing-overlay.d.ts.map +1 -1
- package/dist/pricing-overlay.js +46 -0
- package/dist/pricing-overlay.js.map +1 -1
- package/dist/repeats.d.ts +75 -0
- package/dist/repeats.d.ts.map +1 -0
- package/dist/repeats.js +82 -0
- package/dist/repeats.js.map +1 -0
- package/dist/reprice.d.ts +143 -0
- package/dist/reprice.d.ts.map +1 -0
- package/dist/reprice.js +82 -0
- package/dist/reprice.js.map +1 -0
- package/dist/session-cost.d.ts +70 -0
- package/dist/session-cost.d.ts.map +1 -0
- package/dist/session-cost.js +90 -0
- package/dist/session-cost.js.map +1 -0
- package/dist/session-ledger.d.ts +77 -0
- package/dist/session-ledger.d.ts.map +1 -0
- package/dist/session-ledger.js +99 -0
- package/dist/session-ledger.js.map +1 -0
- package/dist/tokenizer.d.ts +33 -26
- package/dist/tokenizer.d.ts.map +1 -1
- package/dist/tokenizer.js +95 -34
- package/dist/tokenizer.js.map +1 -1
- package/dist/ttl-fit.d.ts +103 -0
- package/dist/ttl-fit.d.ts.map +1 -0
- package/dist/ttl-fit.js +184 -0
- package/dist/ttl-fit.js.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/usage.d.ts +637 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +634 -0
- package/dist/usage.js.map +1 -0
- package/package.json +1 -1
- package/src/advisories.ts +96 -4
- package/src/against.ts +48 -0
- package/src/config-schema.ts +106 -0
- package/src/conversation.ts +305 -0
- package/src/csv.ts +184 -0
- package/src/evaluate.ts +33 -3
- package/src/i18n/en.ts +19 -6
- package/src/i18n/es.ts +19 -6
- package/src/i18n/types.ts +29 -1
- package/src/index.ts +65 -1
- package/src/input-shape.ts +259 -0
- package/src/levers.ts +331 -0
- package/src/node.ts +1 -1
- package/src/output-shape.ts +254 -0
- package/src/pricing-overlay.ts +52 -1
- package/src/repeats.ts +166 -0
- package/src/reprice.ts +227 -0
- package/src/session-cost.ts +170 -0
- package/src/session-ledger.ts +189 -0
- package/src/tokenizer.ts +93 -30
- package/src/ttl-fit.ts +251 -0
- package/src/types.ts +1 -0
- package/src/usage.ts +1267 -0
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
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
* Cache writes made by conversations that never came back.
|
|
8
|
+
*
|
|
9
|
+
* ## The waste the aggregate hides
|
|
10
|
+
*
|
|
11
|
+
* A cache write is a bet: pay 1.25x input now (2x at the 1-hour TTL) so the
|
|
12
|
+
* *next* call reads the prefix at 0.1x. A conversation that ends after its
|
|
13
|
+
* first turn never places that next call — its write bought reuse that its own
|
|
14
|
+
* conversation never made. On a workload with many short sessions this is a
|
|
15
|
+
* steady leak, and it hides inside healthy-looking totals: the long sessions'
|
|
16
|
+
* reads pay for the cache overall, so `cacheEconomics` reports `paid-off`
|
|
17
|
+
* while every one-turn drive-by pays the premium for nothing.
|
|
18
|
+
*
|
|
19
|
+
* ## The caveat that keeps the figure honest
|
|
20
|
+
*
|
|
21
|
+
* The provider's cache is keyed by prefix content, not by conversation. A
|
|
22
|
+
* one-turn session's write **can** be read back by a different session that
|
|
23
|
+
* sends the same prefix within the TTL — a shared system prompt does exactly
|
|
24
|
+
* that — and a usage log cannot see whose write a read hit. So the figure
|
|
25
|
+
* reported here is a **ceiling, named as one**: these writes paid off only if
|
|
26
|
+
* another conversation shared the prefix in time, and the log cannot say
|
|
27
|
+
* whether one did.
|
|
28
|
+
*
|
|
29
|
+
* There is one case where the ceiling collapses into a fact, and the caller
|
|
30
|
+
* can detect it from the slice it already has: when the slice recorded **zero
|
|
31
|
+
* cache reads**, nothing read those writes — within the session, across
|
|
32
|
+
* sessions, at all. The row deliberately does not decide this itself; the
|
|
33
|
+
* breakdown holding the slice's reads belongs to the caller, and deriving it
|
|
34
|
+
* twice is how two figures drift.
|
|
35
|
+
*
|
|
36
|
+
* Session keys group turns and never leave this module, like everywhere else
|
|
37
|
+
* the field is touched.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export interface SingleTurnCacheWrites {
|
|
41
|
+
label: string;
|
|
42
|
+
model: string;
|
|
43
|
+
modelName: string;
|
|
44
|
+
/** Conversations seen in this slice — with a session key, priced model. */
|
|
45
|
+
sessions: number;
|
|
46
|
+
/** Median turns per conversation, for scale: 1-turn sessions in a sea of 40-turn ones read differently than in a sea of 2s. */
|
|
47
|
+
medianTurns: number;
|
|
48
|
+
/** Conversations that ended after exactly one recorded turn. */
|
|
49
|
+
singleTurnSessions: number;
|
|
50
|
+
/** Cache-write tokens those one-turn conversations paid for. */
|
|
51
|
+
singleTurnWriteTokens: number;
|
|
52
|
+
/**
|
|
53
|
+
* What those writes cost, at the same rates the bill used — the 5-minute
|
|
54
|
+
* rate for writes whose TTL the log did not state, so like the bill it is
|
|
55
|
+
* a floor when `assumedTtlTokens` is non-zero. A **ceiling on the waste**
|
|
56
|
+
* (another conversation may have read the prefix; the log cannot see it)
|
|
57
|
+
* built on a **floor of a price** — both directions named, neither guessed.
|
|
58
|
+
*/
|
|
59
|
+
singleTurnWriteUsd: number;
|
|
60
|
+
/** The part of `singleTurnWriteTokens` whose TTL the log did not record. */
|
|
61
|
+
assumedTtlTokens: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SessionLedgerOptions {
|
|
65
|
+
catalogue: PricingCatalogue;
|
|
66
|
+
/** Date the prices are read at, so a promotional rate resolves the same way. */
|
|
67
|
+
on?: Date;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SessionLedgerTracker {
|
|
71
|
+
add(record: UsageRecord): void;
|
|
72
|
+
finish(): SingleTurnCacheWrites[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface SessionTally {
|
|
76
|
+
turns: number;
|
|
77
|
+
write5mTokens: number;
|
|
78
|
+
write1hTokens: number;
|
|
79
|
+
assumedTokens: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const median = (sorted: number[]): number => {
|
|
83
|
+
const mid = Math.floor(sorted.length / 2);
|
|
84
|
+
return sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An accumulator, like the TTL-fit tracker and for the same reason: one pass
|
|
89
|
+
* over a log measured in megabytes, holding one small tally per conversation.
|
|
90
|
+
* No timestamp needed — "came back" is a fact about turn count, not the clock,
|
|
91
|
+
* so this measures logs the TTL-fit cannot.
|
|
92
|
+
*/
|
|
93
|
+
export function createSessionLedgerTracker(options: SessionLedgerOptions): SessionLedgerTracker {
|
|
94
|
+
const { catalogue, on = new Date() } = options;
|
|
95
|
+
const slices = new Map<string, Map<string, SessionTally>>();
|
|
96
|
+
|
|
97
|
+
const add = (record: UsageRecord): void => {
|
|
98
|
+
// An unpriced model has no rate to price the waste at, and contributes no
|
|
99
|
+
// dollars anywhere else either.
|
|
100
|
+
if (record.session === null || !catalogue.byId.has(record.model)) return;
|
|
101
|
+
|
|
102
|
+
const key = `${record.label ?? UNLABELLED}\n${record.model}`;
|
|
103
|
+
let sessions = slices.get(key);
|
|
104
|
+
if (!sessions) {
|
|
105
|
+
sessions = new Map();
|
|
106
|
+
slices.set(key, sessions);
|
|
107
|
+
}
|
|
108
|
+
let tally = sessions.get(record.session);
|
|
109
|
+
if (!tally) {
|
|
110
|
+
tally = { turns: 0, write5mTokens: 0, write1hTokens: 0, assumedTokens: 0 };
|
|
111
|
+
sessions.set(record.session, tally);
|
|
112
|
+
}
|
|
113
|
+
tally.turns += 1;
|
|
114
|
+
if (record.writeTtlKnown) {
|
|
115
|
+
tally.write5mTokens += record.cacheWrite5mTokens;
|
|
116
|
+
tally.write1hTokens += record.cacheWrite1hTokens;
|
|
117
|
+
} else {
|
|
118
|
+
// The flat count sits in the 5m bucket by pricing convention; kept
|
|
119
|
+
// apart here so the row can say how much of its price is a floor.
|
|
120
|
+
tally.assumedTokens += record.cacheWrite5mTokens;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const finish = (): SingleTurnCacheWrites[] => {
|
|
125
|
+
const out: SingleTurnCacheWrites[] = [];
|
|
126
|
+
|
|
127
|
+
for (const [key, sessions] of slices) {
|
|
128
|
+
let singleTurnSessions = 0;
|
|
129
|
+
let write5m = 0;
|
|
130
|
+
let write1h = 0;
|
|
131
|
+
let assumed = 0;
|
|
132
|
+
const turnCounts: number[] = [];
|
|
133
|
+
for (const tally of sessions.values()) {
|
|
134
|
+
turnCounts.push(tally.turns);
|
|
135
|
+
if (tally.turns !== 1) continue;
|
|
136
|
+
singleTurnSessions += 1;
|
|
137
|
+
write5m += tally.write5mTokens;
|
|
138
|
+
write1h += tally.write1hTokens;
|
|
139
|
+
assumed += tally.assumedTokens;
|
|
140
|
+
}
|
|
141
|
+
const singleTurnWriteTokens = write5m + write1h + assumed;
|
|
142
|
+
// One-turn conversations that wrote nothing wasted nothing; a row about
|
|
143
|
+
// them would be a finding about the absence of a finding.
|
|
144
|
+
if (singleTurnWriteTokens === 0) continue;
|
|
145
|
+
|
|
146
|
+
const split = key.indexOf('\n');
|
|
147
|
+
const modelId = key.slice(split + 1);
|
|
148
|
+
const model = catalogue.byId.get(modelId)!;
|
|
149
|
+
const { inputPerMTok } = effectivePricing(model, on);
|
|
150
|
+
const rates = multipliersFor(model);
|
|
151
|
+
const per = (tokens: number, rate: number): number =>
|
|
152
|
+
(tokens / 1_000_000) * inputPerMTok * rate;
|
|
153
|
+
// The bill's own convention: unstated TTLs at the cheaper rate, so this
|
|
154
|
+
// is the same floor the totals already stand on — never a new guess.
|
|
155
|
+
const singleTurnWriteUsd =
|
|
156
|
+
per(write5m + assumed, rates.cacheWrite5m) + per(write1h, rates.cacheWrite1h);
|
|
157
|
+
|
|
158
|
+
turnCounts.sort((a, b) => a - b);
|
|
159
|
+
out.push({
|
|
160
|
+
label: key.slice(0, split),
|
|
161
|
+
model: modelId,
|
|
162
|
+
modelName: model.displayName,
|
|
163
|
+
sessions: sessions.size,
|
|
164
|
+
medianTurns: median(turnCounts),
|
|
165
|
+
singleTurnSessions,
|
|
166
|
+
singleTurnWriteTokens,
|
|
167
|
+
singleTurnWriteUsd,
|
|
168
|
+
assumedTtlTokens: assumed,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The most money first — the order somebody would act in.
|
|
173
|
+
return out.sort(
|
|
174
|
+
(a, b) => b.singleTurnWriteUsd - a.singleTurnWriteUsd || a.label.localeCompare(b.label),
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return { add, finish };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The same measurement over a list, for a caller holding one already. */
|
|
182
|
+
export function singleTurnCacheWrites(
|
|
183
|
+
records: readonly UsageRecord[],
|
|
184
|
+
options: SessionLedgerOptions,
|
|
185
|
+
): SingleTurnCacheWrites[] {
|
|
186
|
+
const tracker = createSessionLedgerTracker(options);
|
|
187
|
+
for (const record of records) tracker.add(record);
|
|
188
|
+
return tracker.finish();
|
|
189
|
+
}
|