@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.
- 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/index.d.ts +25 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -1
- 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/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/usage.d.ts +434 -16
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +383 -23
- package/dist/usage.js.map +1 -1
- package/package.json +1 -1
- 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/index.ts +51 -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/ttl-fit.ts +251 -0
- package/src/usage.ts +795 -7
package/src/usage.ts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { effectivePricing, multipliersFor } from './pricing.js';
|
|
2
|
+
import { createConversationTracker } from './conversation.js';
|
|
3
|
+
import { createOutputShapeTracker } from './output-shape.js';
|
|
4
|
+
import { createInputShapeTracker } from './input-shape.js';
|
|
5
|
+
import { createRepeatsTracker } from './repeats.js';
|
|
6
|
+
import { createTtlFitTracker } from './ttl-fit.js';
|
|
7
|
+
import { createSessionLedgerTracker } from './session-ledger.js';
|
|
8
|
+
import { createSessionCostTracker } from './session-cost.js';
|
|
9
|
+
import type { SessionCostShape } from './session-cost.js';
|
|
10
|
+
import type { CacheTtlFit } from './ttl-fit.js';
|
|
11
|
+
import type { SingleTurnCacheWrites } from './session-ledger.js';
|
|
12
|
+
import type { ConversationGrowth } from './conversation.js';
|
|
13
|
+
import type { OutputShape } from './output-shape.js';
|
|
14
|
+
import type { InputShape } from './input-shape.js';
|
|
15
|
+
import type { RepeatedTurns } from './repeats.js';
|
|
2
16
|
import type { PricingCatalogue } from './pricing.js';
|
|
3
17
|
|
|
4
18
|
/**
|
|
@@ -55,6 +69,13 @@ import type { PricingCatalogue } from './pricing.js';
|
|
|
55
69
|
* exists precisely because guessing is what the rest of the package has to do.
|
|
56
70
|
* It reports what was spent, split by where it went. What to do about it is a
|
|
57
71
|
* different question and belongs to the advisories.
|
|
72
|
+
*
|
|
73
|
+
* `cacheEconomics` is the one counterfactual here, and it is not an exception to
|
|
74
|
+
* that rule — it is the line the rule draws. A saving requires imagining a prompt
|
|
75
|
+
* nobody wrote; this requires imagining the **same tokens at a different rate**,
|
|
76
|
+
* which is arithmetic. Caching does not change what is sent, only the multiplier
|
|
77
|
+
* it is billed at, so "these tokens cost 1.25x instead of 1x" is as measured as
|
|
78
|
+
* the total itself. Anything that would need a guess about content stays out.
|
|
58
79
|
*/
|
|
59
80
|
|
|
60
81
|
/** One recorded call, after parsing. All counts, no content. */
|
|
@@ -88,6 +109,42 @@ export interface UsageRecord {
|
|
|
88
109
|
* refuses to read a log until it is annotated is a profile nobody runs.
|
|
89
110
|
*/
|
|
90
111
|
label: string | null;
|
|
112
|
+
/**
|
|
113
|
+
* Optional conversation identifier, for measuring what re-sent history costs.
|
|
114
|
+
*
|
|
115
|
+
* On a chat or agent workload the input grows with every turn, because the whole
|
|
116
|
+
* conversation goes back up on each call. That is frequently the largest line on
|
|
117
|
+
* the bill and nothing watches it — a prompt file cannot show it, and a total
|
|
118
|
+
* cannot either.
|
|
119
|
+
*
|
|
120
|
+
* **Trazum never prints this value.** A session key is somebody's conversation
|
|
121
|
+
* and could easily be an account id or an email; it is used to group calls and
|
|
122
|
+
* to count turns, and every figure derived from it is reported per *label*. That
|
|
123
|
+
* keeps the guarantee this module is built on: a usage log handed to Trazum
|
|
124
|
+
* carries no content, and nothing identifying comes back out of it either.
|
|
125
|
+
*/
|
|
126
|
+
session: string | null;
|
|
127
|
+
/**
|
|
128
|
+
* When the call happened, as epoch milliseconds, or `null` when the log does
|
|
129
|
+
* not say.
|
|
130
|
+
*
|
|
131
|
+
* Read from `ts`, `timestamp`, `created_at` or OpenAI's `created`; ISO 8601
|
|
132
|
+
* strings and epoch numbers both work, with seconds told from milliseconds by
|
|
133
|
+
* magnitude. The clock unlocks the two findings counts alone cannot make:
|
|
134
|
+
* what period this log actually covers, and whether the cache TTL fits how
|
|
135
|
+
* fast the turns arrive — the single most common reason a cache loses money.
|
|
136
|
+
*/
|
|
137
|
+
ts: number | null;
|
|
138
|
+
/**
|
|
139
|
+
* Whether the answer hit the output ceiling, when the log says.
|
|
140
|
+
*
|
|
141
|
+
* `true` for Anthropic's `stop_reason: "max_tokens"` and OpenAI's
|
|
142
|
+
* `finish_reason: "length"`; `false` for any other recorded reason; `null`
|
|
143
|
+
* when the log does not carry the field. Three states, because "no truncation
|
|
144
|
+
* recorded" and "no truncation happened" are different answers — the report
|
|
145
|
+
* must not congratulate a log that never measured.
|
|
146
|
+
*/
|
|
147
|
+
truncated: boolean | null;
|
|
91
148
|
}
|
|
92
149
|
|
|
93
150
|
/** What a set of calls cost, split by where the money went. */
|
|
@@ -96,17 +153,78 @@ export interface UsageBreakdown {
|
|
|
96
153
|
inputTokens: number;
|
|
97
154
|
cacheReadTokens: number;
|
|
98
155
|
cacheWriteTokens: number;
|
|
156
|
+
/**
|
|
157
|
+
* The two write TTLs kept apart, because they are billed at different rates
|
|
158
|
+
* — 1.25x input for a 5-minute entry and **2x** for a 1-hour one.
|
|
159
|
+
*
|
|
160
|
+
* `cacheWriteTokens` is their sum and stays the figure to read for volume.
|
|
161
|
+
* These exist so the same tokens can be priced again at another model's
|
|
162
|
+
* rates without the ratio between the two being invented: it is not a
|
|
163
|
+
* constant across providers, so a total that has lost the split cannot be
|
|
164
|
+
* repriced, only guessed at.
|
|
165
|
+
*
|
|
166
|
+
* Writes whose TTL the log did not state are in the 5-minute bucket, the
|
|
167
|
+
* same assumption `cacheWriteUsdIfAssumed1h` measures the cost of.
|
|
168
|
+
*/
|
|
169
|
+
cacheWrite5mTokens: number;
|
|
170
|
+
cacheWrite1hTokens: number;
|
|
99
171
|
outputTokens: number;
|
|
100
172
|
/**
|
|
101
173
|
* Calls whose cache-write TTL the log did not state, so the cheaper rate was
|
|
102
174
|
* assumed. Non-zero means this total is a floor on those calls, not a figure.
|
|
103
175
|
*/
|
|
104
176
|
assumedWriteTtlCalls: number;
|
|
177
|
+
/**
|
|
178
|
+
* The largest single call's input, cache reads and writes included — the one
|
|
179
|
+
* number that says whether these calls would fit somewhere else.
|
|
180
|
+
*
|
|
181
|
+
* A cheaper model with a smaller context window does not make this traffic
|
|
182
|
+
* cheaper; it makes some of it impossible, and a price comparison that only
|
|
183
|
+
* multiplies rates would call that a saving. The maximum is the right
|
|
184
|
+
* statistic rather than the mean: one call over the ceiling is a failed
|
|
185
|
+
* call, and an average hides it.
|
|
186
|
+
*/
|
|
187
|
+
maxCallInputTokens: number;
|
|
105
188
|
inputUsd: number;
|
|
106
189
|
cacheReadUsd: number;
|
|
107
190
|
cacheWriteUsd: number;
|
|
108
191
|
outputUsd: number;
|
|
109
192
|
totalUsd: number;
|
|
193
|
+
/**
|
|
194
|
+
* What the cache-touched tokens would have cost as ordinary input.
|
|
195
|
+
*
|
|
196
|
+
* Reads plus writes, at each model's own full input rate, accumulated per call
|
|
197
|
+
* because the rate is per model and a total loses that. Not part of `totalUsd`
|
|
198
|
+
* and not a bill — it is the other half of `cacheEconomics`, kept here because
|
|
199
|
+
* it can only be computed while the model is still in hand.
|
|
200
|
+
*/
|
|
201
|
+
cachedTokensAtInputRateUsd: number;
|
|
202
|
+
/**
|
|
203
|
+
* `cacheWriteUsd` with every **unstated-TTL** write priced at the 1-hour rate.
|
|
204
|
+
*
|
|
205
|
+
* Equal to `cacheWriteUsd` when the log recorded which TTL each write used.
|
|
206
|
+
* When it did not, the cheaper 5-minute rate is assumed for the headline figure
|
|
207
|
+
* — and this is what the same calls cost if that assumption is wrong.
|
|
208
|
+
*
|
|
209
|
+
* It exists because the assumption reaches further than the total. It moves the
|
|
210
|
+
* *verdict*: a workload reading back between 0.28 and 1.11 tokens per token
|
|
211
|
+
* written is reported as paying for itself at 1.25x and as losing money at 2x,
|
|
212
|
+
* and the log is silent about which. A verdict that cannot see the assumption
|
|
213
|
+
* behind it states the flattering half as a fact.
|
|
214
|
+
*/
|
|
215
|
+
cacheWriteUsdIfAssumed1h: number;
|
|
216
|
+
/**
|
|
217
|
+
* Calls whose answer hit the output ceiling, and what their output cost.
|
|
218
|
+
*
|
|
219
|
+
* The one category of a bill that is waste without a counterpart: an answer
|
|
220
|
+
* cut off mid-generation was paid for in full, is frequently retried — billed
|
|
221
|
+
* again — and the truncated attempt bought nothing. Output is the largest
|
|
222
|
+
* line on most bills, and this is the slice of it nobody sees.
|
|
223
|
+
*/
|
|
224
|
+
truncatedCalls: number;
|
|
225
|
+
truncatedOutputUsd: number;
|
|
226
|
+
/** Calls that recorded a stop reason at all, truncated or not. */
|
|
227
|
+
stopReasonCalls: number;
|
|
110
228
|
}
|
|
111
229
|
|
|
112
230
|
export interface UsageProfileReport {
|
|
@@ -116,6 +234,16 @@ export interface UsageProfileReport {
|
|
|
116
234
|
byLabel: Array<{ label: string; breakdown: UsageBreakdown }>;
|
|
117
235
|
/** Per model, largest bill first. */
|
|
118
236
|
byModel: Array<{ model: string; breakdown: UsageBreakdown }>;
|
|
237
|
+
/**
|
|
238
|
+
* Per label **and** model, largest bill first.
|
|
239
|
+
*
|
|
240
|
+
* The grouping a decision is actually made at. "Route `classify` to something
|
|
241
|
+
* cheaper" is a question about the calls `classify` makes to one model, and a
|
|
242
|
+
* label that spans two models has no single answer — pricing it against a
|
|
243
|
+
* cheaper candidate would mean picking one of the two current prices and
|
|
244
|
+
* applying it to tokens that were never billed at it.
|
|
245
|
+
*/
|
|
246
|
+
byLabelAndModel: Array<{ label: string; model: string; breakdown: UsageBreakdown }>;
|
|
119
247
|
/**
|
|
120
248
|
* Models in the log that the pricing catalogue does not know.
|
|
121
249
|
*
|
|
@@ -147,6 +275,186 @@ export interface UsageProfileReport {
|
|
|
147
275
|
* produces a total that is wrong by an unknown amount.
|
|
148
276
|
*/
|
|
149
277
|
skippedLines: number[];
|
|
278
|
+
/**
|
|
279
|
+
* What re-sending the conversation costs, where the log carries a session.
|
|
280
|
+
*
|
|
281
|
+
* Empty when it does not, which is a different statement from zero growth — the
|
|
282
|
+
* report says which, because "nothing to report" and "nothing recorded" are the
|
|
283
|
+
* two answers a reader would act on differently.
|
|
284
|
+
*/
|
|
285
|
+
conversations: ConversationGrowth[];
|
|
286
|
+
/** Whether any record carried a session at all. */
|
|
287
|
+
hasSessions: boolean;
|
|
288
|
+
/**
|
|
289
|
+
* Where the output spend concentrates, for slices whose output is a real share
|
|
290
|
+
* of the bill. The actionable half of "output dominates": six per cent of calls
|
|
291
|
+
* holding half the spend is a tail worth hunting, forty-five per cent is a task
|
|
292
|
+
* whose answers are inherently long — and the total cannot tell them apart.
|
|
293
|
+
*/
|
|
294
|
+
outputShapes: OutputShape[];
|
|
295
|
+
/**
|
|
296
|
+
* How big a call's input is, and how uneven that is across a slice — the
|
|
297
|
+
* half of the bill a total could only name. "Input is 63% of this bill" is
|
|
298
|
+
* unactionable; whether the p95 call carries twelve times the median call's
|
|
299
|
+
* input decides between capping something and rewriting a prompt.
|
|
300
|
+
*
|
|
301
|
+
* Every figure is a bucket ceiling rather than an interpolated percentile,
|
|
302
|
+
* and slices with too few calls for a percentile to mean anything are left
|
|
303
|
+
* out entirely rather than reported at a precision they do not have.
|
|
304
|
+
*/
|
|
305
|
+
inputShapes: InputShape[];
|
|
306
|
+
/**
|
|
307
|
+
* Calls that re-sent the previous call's exact input size, in the same
|
|
308
|
+
* conversation, seconds later — the shape of a retry or a loop.
|
|
309
|
+
*
|
|
310
|
+
* A conversation's input grows with every turn, so two consecutive calls
|
|
311
|
+
* carrying the same size a moment apart is a thing going wrong rather than
|
|
312
|
+
* a thing working. Needs both a session and a clock; empty when the log
|
|
313
|
+
* carries neither, which is a different statement from "none happened".
|
|
314
|
+
*/
|
|
315
|
+
repeatedTurns: RepeatedTurns[];
|
|
316
|
+
/**
|
|
317
|
+
* The period the log covers, when its records carry a clock, over every
|
|
318
|
+
* parsed record — priced and unpriced alike, because when a call happened is
|
|
319
|
+
* a fact about the log rather than about the catalogue.
|
|
320
|
+
*
|
|
321
|
+
* `calls` is how many records carried a timestamp; compared against the
|
|
322
|
+
* parsed total it says whether the span describes the whole log or a slice
|
|
323
|
+
* of it, and the report states which. **The span is stated, never
|
|
324
|
+
* extrapolated**: "this log covers 13 days" makes the reader's own monthly
|
|
325
|
+
* arithmetic valid, while a per-month figure printed from a partial month
|
|
326
|
+
* would be this module doing the guessing it exists to end.
|
|
327
|
+
*/
|
|
328
|
+
span: { fromMs: number; toMs: number; calls: number } | null;
|
|
329
|
+
/**
|
|
330
|
+
* Spend per UTC day, oldest first, over priced records that carry a clock.
|
|
331
|
+
*
|
|
332
|
+
* The shape of a bill over time is the finding the total hides: a steady $3 a
|
|
333
|
+
* day and a quiet week broken by one $40 spike sum to the same number and
|
|
334
|
+
* call for opposite responses. Each day carries its most expensive label so a
|
|
335
|
+
* spike arrives with a suspect attached — per *label*, never per session.
|
|
336
|
+
*
|
|
337
|
+
* UTC deliberately: the log's timestamps carry no zone once parsed, and
|
|
338
|
+
* bucketing by the reader's local midnight would make the same log answer
|
|
339
|
+
* differently in two offices.
|
|
340
|
+
*/
|
|
341
|
+
spendByDay: Array<{
|
|
342
|
+
/** `YYYY-MM-DD`, UTC. */
|
|
343
|
+
day: string;
|
|
344
|
+
usd: number;
|
|
345
|
+
calls: number;
|
|
346
|
+
/** The label that spent the most this day, or null when nothing had one. */
|
|
347
|
+
topLabel: string | null;
|
|
348
|
+
topLabelUsd: number;
|
|
349
|
+
}>;
|
|
350
|
+
/**
|
|
351
|
+
* Lines that are exact duplicates of an earlier line, and what they added
|
|
352
|
+
* to the total.
|
|
353
|
+
*
|
|
354
|
+
* Reading a directory of rotated logs — or catting them together by hand —
|
|
355
|
+
* makes double-counting easy: a log exported twice, an overlapping export,
|
|
356
|
+
* a copy left in the folder. The bill then reads high, and nothing else in
|
|
357
|
+
* this report can see it, because two identical calls are indistinguishable
|
|
358
|
+
* from one call recorded twice *unless* the record carries a clock.
|
|
359
|
+
*
|
|
360
|
+
* So this counts only records with a `ts`: identical token counts, identical
|
|
361
|
+
* label and session, and the same millisecond. Two real calls colliding on
|
|
362
|
+
* all of that is possible and vanishingly unlikely; without a clock it is
|
|
363
|
+
* ordinary, which is why clockless records are excluded rather than guessed
|
|
364
|
+
* at. The report states the count and the money and stops — whether it is a
|
|
365
|
+
* double export or a busy millisecond is the reader's to know.
|
|
366
|
+
*
|
|
367
|
+
* The comparison is over the **raw line**, not a hash of it: a hash
|
|
368
|
+
* collision would report a duplicate that is not one, and this figure exists
|
|
369
|
+
* to make somebody distrust a total.
|
|
370
|
+
*/
|
|
371
|
+
duplicateLines: { count: number; usd: number };
|
|
372
|
+
/**
|
|
373
|
+
* How many parsed records carried each optional field.
|
|
374
|
+
*
|
|
375
|
+
* Every finding this module makes beyond the totals needs a field the log
|
|
376
|
+
* format does not require, and a reader who never adds them sees a report
|
|
377
|
+
* quietly missing half of itself. Counting them turns "Trazum did not tell
|
|
378
|
+
* me about conversation growth" into "none of your 40,000 records carry a
|
|
379
|
+
* session", which is a fact somebody can act on in an afternoon.
|
|
380
|
+
*
|
|
381
|
+
* Counted over records that **parsed**, priced or not: whether a field is
|
|
382
|
+
* present is a property of the log, not of the price catalogue. Partial
|
|
383
|
+
* coverage is the interesting case and is why these are counts rather than
|
|
384
|
+
* booleans — 12 records out of 40,000 carrying a label is not "labelled",
|
|
385
|
+
* and a boolean would call it that.
|
|
386
|
+
*/
|
|
387
|
+
fieldCoverage: {
|
|
388
|
+
/** Records with a usable `label`. */
|
|
389
|
+
label: number;
|
|
390
|
+
/** Records with a usable `session` or `conversation_id`. */
|
|
391
|
+
session: number;
|
|
392
|
+
/** Records with a readable timestamp. */
|
|
393
|
+
ts: number;
|
|
394
|
+
/** Records with a `stop_reason` or `finish_reason`. */
|
|
395
|
+
stopReason: number;
|
|
396
|
+
/** Records whose cache writes stated which TTL they used. */
|
|
397
|
+
cacheTtl: number;
|
|
398
|
+
/** Records that wrote to the cache at all — the denominator for `cacheTtl`. */
|
|
399
|
+
cacheWrites: number;
|
|
400
|
+
/** Every record that parsed, priced or not — the denominator for the rest. */
|
|
401
|
+
parsed: number;
|
|
402
|
+
};
|
|
403
|
+
/**
|
|
404
|
+
* Spend per hour of the UTC day, 0–23, over priced records that carry a
|
|
405
|
+
* clock — and only the hours that saw traffic.
|
|
406
|
+
*
|
|
407
|
+
* The shape a day has says what kind of workload this is. Spend packed into
|
|
408
|
+
* the hours a country is awake is interactive traffic somebody is waiting
|
|
409
|
+
* on; spend spread evenly across all twenty-four is background work, and
|
|
410
|
+
* background work is exactly what the Batch API halves the price of. The
|
|
411
|
+
* total cannot tell those apart, and neither can the per-day series.
|
|
412
|
+
*
|
|
413
|
+
* UTC deliberately, like `spendByDay`: bucketing by the reader's local hour
|
|
414
|
+
* would make the same log answer differently in two offices, and the log's
|
|
415
|
+
* timestamps carry no zone once parsed. A reader who knows their traffic is
|
|
416
|
+
* in one region can shift the hours themselves; Trazum inventing an offset
|
|
417
|
+
* would be guessing.
|
|
418
|
+
*/
|
|
419
|
+
spendByHour: Array<{ hour: number; usd: number; calls: number }>;
|
|
420
|
+
/**
|
|
421
|
+
* Whether each slice's cache TTL fits how fast its turns arrive — the
|
|
422
|
+
* mechanism behind a losing cache verdict, and the one place an overlong TTL
|
|
423
|
+
* (2x writes surviving gaps measured in seconds) is ever visible. Needs
|
|
424
|
+
* `session` and a timestamp on the records; empty otherwise, which the
|
|
425
|
+
* report distinguishes from "measured and fine".
|
|
426
|
+
*/
|
|
427
|
+
cacheTtlFit: CacheTtlFit[];
|
|
428
|
+
/**
|
|
429
|
+
* The time filter this report was computed under, or `null` when there was
|
|
430
|
+
* none — so a rendering can say "this is a window, not the log" instead of
|
|
431
|
+
* presenting a slice as the whole.
|
|
432
|
+
*
|
|
433
|
+
* `undatedExcluded` is the honesty cost of filtering by a clock some records
|
|
434
|
+
* do not carry: calls that passed every other filter but could not be
|
|
435
|
+
* placed in or out of the window. Non-zero means the window's figures are a
|
|
436
|
+
* floor on the period, and every rendering says so out loud.
|
|
437
|
+
*/
|
|
438
|
+
timeWindow: { sinceMs: number | null; untilMs: number | null; undatedExcluded: number } | null;
|
|
439
|
+
/**
|
|
440
|
+
* Cache writes made by conversations that ended after one turn — reuse paid
|
|
441
|
+
* for that their own conversation never made. A ceiling named as one: the
|
|
442
|
+
* provider's cache is keyed by prefix, so another session sharing the
|
|
443
|
+
* prefix within the TTL could have read these writes, and the log cannot
|
|
444
|
+
* see whose write a read hit. When the slice's `cacheReadTokens` is zero
|
|
445
|
+
* the ceiling collapses into a fact — nothing read those writes at all —
|
|
446
|
+
* and the renderings say which of the two they are stating.
|
|
447
|
+
*/
|
|
448
|
+
singleTurnCacheWrites: SingleTurnCacheWrites[];
|
|
449
|
+
/**
|
|
450
|
+
* What one conversation costs — median, p95 and maximum per slice, exact
|
|
451
|
+
* and billed. The question a total cannot answer: whether $4,000 is forty
|
|
452
|
+
* thousand cheap conversations or four hundred expensive ones, which is
|
|
453
|
+
* what a per-seat price or a quota is set from. Empty when the log carries
|
|
454
|
+
* no session, or when no slice has enough conversations for a median to
|
|
455
|
+
* mean anything.
|
|
456
|
+
*/
|
|
457
|
+
sessionCosts: SessionCostShape[];
|
|
150
458
|
}
|
|
151
459
|
|
|
152
460
|
/** The share of the bill each part accounts for, as fractions of 1. */
|
|
@@ -162,13 +470,21 @@ const EMPTY = (): UsageBreakdown => ({
|
|
|
162
470
|
inputTokens: 0,
|
|
163
471
|
cacheReadTokens: 0,
|
|
164
472
|
cacheWriteTokens: 0,
|
|
473
|
+
cacheWrite5mTokens: 0,
|
|
474
|
+
cacheWrite1hTokens: 0,
|
|
165
475
|
outputTokens: 0,
|
|
166
476
|
assumedWriteTtlCalls: 0,
|
|
477
|
+
maxCallInputTokens: 0,
|
|
167
478
|
inputUsd: 0,
|
|
168
479
|
cacheReadUsd: 0,
|
|
169
480
|
cacheWriteUsd: 0,
|
|
170
481
|
outputUsd: 0,
|
|
171
482
|
totalUsd: 0,
|
|
483
|
+
cachedTokensAtInputRateUsd: 0,
|
|
484
|
+
cacheWriteUsdIfAssumed1h: 0,
|
|
485
|
+
truncatedCalls: 0,
|
|
486
|
+
truncatedOutputUsd: 0,
|
|
487
|
+
stopReasonCalls: 0,
|
|
172
488
|
});
|
|
173
489
|
|
|
174
490
|
/**
|
|
@@ -224,6 +540,43 @@ const valueOf = (count: Count): number => (count.kind === 'ok' ? count.value : 0
|
|
|
224
540
|
* corruption, and a corrupt line belongs in `skippedLines` where the report
|
|
225
541
|
* names it, not in the totals as a silent zero.
|
|
226
542
|
*/
|
|
543
|
+
/**
|
|
544
|
+
* A moment, from whatever a real log holds, in epoch milliseconds.
|
|
545
|
+
*
|
|
546
|
+
* The same three-state discipline as the counts: absent is fine (`null`),
|
|
547
|
+
* present-and-unreadable is corruption and rejects the line. A timestamp of
|
|
548
|
+
* `null` out of a Postgres round-trip, or `"yesterday"`, silently dropped would
|
|
549
|
+
* mis-measure every gap that record participates in — and unlike a wrong total,
|
|
550
|
+
* a wrong gap has nothing downstream to disagree with it.
|
|
551
|
+
*
|
|
552
|
+
* Numbers are epoch seconds or milliseconds, told apart by magnitude: anything
|
|
553
|
+
* from 1e12 up is milliseconds (September 2001 onward), anything from 1e8 up is
|
|
554
|
+
* seconds (March 1973 onward), and anything smaller names no real moment a
|
|
555
|
+
* usage log could contain. Strings go through `Date.parse`, which reads ISO
|
|
556
|
+
* 8601 — the format both `new Date().toISOString()` and every structured
|
|
557
|
+
* logger emit.
|
|
558
|
+
*/
|
|
559
|
+
type Moment = { kind: 'ok'; ms: number } | { kind: 'absent' } | { kind: 'corrupt' };
|
|
560
|
+
|
|
561
|
+
function readMoment(...candidates: unknown[]): Moment {
|
|
562
|
+
let sawCorrupt = false;
|
|
563
|
+
for (const value of candidates) {
|
|
564
|
+
if (value === undefined) continue;
|
|
565
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
566
|
+
if (value >= 1e12) return { kind: 'ok', ms: value };
|
|
567
|
+
if (value >= 1e8) return { kind: 'ok', ms: value * 1000 };
|
|
568
|
+
sawCorrupt = true;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (typeof value === 'string') {
|
|
572
|
+
const parsed = Date.parse(value);
|
|
573
|
+
if (Number.isFinite(parsed)) return { kind: 'ok', ms: parsed };
|
|
574
|
+
}
|
|
575
|
+
sawCorrupt = true;
|
|
576
|
+
}
|
|
577
|
+
return sawCorrupt ? { kind: 'corrupt' } : { kind: 'absent' };
|
|
578
|
+
}
|
|
579
|
+
|
|
227
580
|
export function parseUsageLine(line: string): UsageRecord | null {
|
|
228
581
|
let raw: unknown;
|
|
229
582
|
try {
|
|
@@ -287,6 +640,15 @@ export function parseUsageLine(line: string): UsageRecord | null {
|
|
|
287
640
|
// Nothing to count at all.
|
|
288
641
|
if (Object.values(counts).every((c) => c.kind === 'absent')) return null;
|
|
289
642
|
|
|
643
|
+
/**
|
|
644
|
+
* The clock, under the same rule as the counts: a timestamp that is present
|
|
645
|
+
* and unreadable rejects the line rather than becoming a silent absence.
|
|
646
|
+
* `created` is where OpenAI responses carry it (epoch seconds), so a log
|
|
647
|
+
* written by spreading the response already has one.
|
|
648
|
+
*/
|
|
649
|
+
const moment = readMoment(record.ts, record.timestamp, record.created_at, record.created);
|
|
650
|
+
if (moment.kind === 'corrupt') return null;
|
|
651
|
+
|
|
290
652
|
const cached = valueOf(counts.openAiCached!);
|
|
291
653
|
const flatWrite = valueOf(counts.cacheWrite!);
|
|
292
654
|
const split5m = valueOf(counts.write5m!);
|
|
@@ -306,15 +668,72 @@ export function parseUsageLine(line: string): UsageRecord | null {
|
|
|
306
668
|
cacheWrite1hTokens: hasSplit ? split1h : 0,
|
|
307
669
|
writeTtlKnown: hasSplit || flatWrite === 0,
|
|
308
670
|
outputTokens: valueOf(counts.output!),
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
671
|
+
/**
|
|
672
|
+
* Trimmed and internally normalised: any whitespace run becomes one space.
|
|
673
|
+
* A label is a workload name, and it is also used as half of structured keys
|
|
674
|
+
* that split on `\n` — `byLabelAndModel`, the conversation tracker, the
|
|
675
|
+
* output-shape tracker. A label carrying a newline would corrupt that split
|
|
676
|
+
* and mis-file every figure under a truncated name; normalising at the one
|
|
677
|
+
* boundary where labels enter keeps every consumer honest at once.
|
|
678
|
+
*/
|
|
679
|
+
label: nameOf(record.label),
|
|
680
|
+
/**
|
|
681
|
+
* Read from either spelling, because both are what people already have:
|
|
682
|
+
* `session` in a hand-rolled log, `conversation_id` in most chat schemas.
|
|
683
|
+
* Refusing one of them would make the field's adoption a chore, and a field
|
|
684
|
+
* nobody sets measures nothing.
|
|
685
|
+
*/
|
|
686
|
+
session: nameOf(record.session) ?? nameOf(record.conversation_id),
|
|
687
|
+
ts: moment.kind === 'ok' ? moment.ms : null,
|
|
688
|
+
/**
|
|
689
|
+
* Anthropic spells it `stop_reason: "max_tokens"`, OpenAI
|
|
690
|
+
* `finish_reason: "length"`. Any other recorded reason is a completed
|
|
691
|
+
* answer; an absent field is `null`, which is "not measured" and not "did
|
|
692
|
+
* not happen" — the report treats those differently on purpose.
|
|
693
|
+
*/
|
|
694
|
+
truncated: (() => {
|
|
695
|
+
const reason = record.stop_reason ?? record.finish_reason;
|
|
696
|
+
if (typeof reason !== 'string') return null;
|
|
697
|
+
return reason === 'max_tokens' || reason === 'length';
|
|
698
|
+
})(),
|
|
313
699
|
};
|
|
314
700
|
}
|
|
315
701
|
|
|
316
|
-
/**
|
|
317
|
-
|
|
702
|
+
/**
|
|
703
|
+
* A label or session identifier, from whatever a real log holds.
|
|
704
|
+
*
|
|
705
|
+
* **Numbers are identifiers too.** A conversation id is an auto-incremented
|
|
706
|
+
* integer in half the databases in existence, and the string-only version
|
|
707
|
+
* dropped `session: 12345` silently and then printed "No call in this log
|
|
708
|
+
* carried a session" — a false claim about a log that carried one on every
|
|
709
|
+
* line. A finite number is taken by its decimal form; booleans, objects and
|
|
710
|
+
* non-finite numbers stay out, because `session: true` names nothing.
|
|
711
|
+
*
|
|
712
|
+
* Strings are trimmed and internally normalised — any whitespace run becomes
|
|
713
|
+
* one space — because labels are half of structured keys that split on a
|
|
714
|
+
* newline, and a label carrying one would mis-file every figure it touches.
|
|
715
|
+
*/
|
|
716
|
+
function nameOf(value: unknown): string | null {
|
|
717
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
718
|
+
if (typeof value !== 'string') return null;
|
|
719
|
+
const trimmed = value.trim().replace(/\s+/g, ' ');
|
|
720
|
+
return trimmed === '' ? null : trimmed;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The bucket unlabelled calls land in.
|
|
725
|
+
*
|
|
726
|
+
* The empty string, because it is the one value a parsed label can never be —
|
|
727
|
+
* `parseUsageLine` trims and rejects empty. The first version used the literal
|
|
728
|
+
* string `'unlabelled'`, and a workload somebody had actually named `unlabelled`
|
|
729
|
+
* merged silently into the missing-label bucket: 200 labelled calls and 200
|
|
730
|
+
* unlabelled ones reported as one row of 400, and the "none of these calls
|
|
731
|
+
* carried a label" warning fired over a log where half of them had.
|
|
732
|
+
*
|
|
733
|
+
* Presentation stays in the CLI, which translates this sentinel through the
|
|
734
|
+
* message catalogue; data consumers can tell `''` from any real label.
|
|
735
|
+
*/
|
|
736
|
+
export const UNLABELLED = '';
|
|
318
737
|
|
|
319
738
|
/** Token counts only. Used for both halves, because both need them. */
|
|
320
739
|
function countInto(into: UsageBreakdown, record: UsageRecord): void {
|
|
@@ -322,8 +741,18 @@ function countInto(into: UsageBreakdown, record: UsageRecord): void {
|
|
|
322
741
|
into.inputTokens += record.inputTokens;
|
|
323
742
|
into.cacheReadTokens += record.cacheReadTokens;
|
|
324
743
|
into.cacheWriteTokens += record.cacheWrite5mTokens + record.cacheWrite1hTokens;
|
|
744
|
+
into.cacheWrite5mTokens += record.cacheWrite5mTokens;
|
|
745
|
+
into.cacheWrite1hTokens += record.cacheWrite1hTokens;
|
|
325
746
|
if (!record.writeTtlKnown) into.assumedWriteTtlCalls += 1;
|
|
326
747
|
into.outputTokens += record.outputTokens;
|
|
748
|
+
into.maxCallInputTokens = Math.max(
|
|
749
|
+
into.maxCallInputTokens,
|
|
750
|
+
record.inputTokens + record.cacheReadTokens + record.cacheWrite5mTokens + record.cacheWrite1hTokens,
|
|
751
|
+
);
|
|
752
|
+
if (record.truncated !== null) {
|
|
753
|
+
into.stopReasonCalls += 1;
|
|
754
|
+
if (record.truncated) into.truncatedCalls += 1;
|
|
755
|
+
}
|
|
327
756
|
}
|
|
328
757
|
|
|
329
758
|
function add(into: UsageBreakdown, record: UsageRecord, catalogue: PricingCatalogue, on: Date): boolean {
|
|
@@ -357,7 +786,32 @@ function add(into: UsageBreakdown, record: UsageRecord, catalogue: PricingCatalo
|
|
|
357
786
|
*/
|
|
358
787
|
into.cacheWriteUsd += per(record.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m);
|
|
359
788
|
into.cacheWriteUsd += per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h);
|
|
789
|
+
/**
|
|
790
|
+
* The same writes with the assumption taken the other way.
|
|
791
|
+
*
|
|
792
|
+
* A record whose TTL the log did not state has all of its writes in the
|
|
793
|
+
* 5-minute bucket — `parseUsageLine` puts them there because one rate has to be
|
|
794
|
+
* chosen — so this prices exactly those at the 1-hour rate instead. Accumulated
|
|
795
|
+
* per call, and per model, because the ratio between the two rates is not a
|
|
796
|
+
* constant: 2.0/1.25 on Anthropic, 1.0/1.0 where a write costs what input
|
|
797
|
+
* costs. Scaling the total afterwards would invent a premium for providers that
|
|
798
|
+
* have none.
|
|
799
|
+
*/
|
|
800
|
+
const writeRateIfWrong = record.writeTtlKnown ? rates.cacheWrite5m : rates.cacheWrite1h;
|
|
801
|
+
into.cacheWriteUsdIfAssumed1h += per(record.cacheWrite5mTokens, inputPerMTok * writeRateIfWrong);
|
|
802
|
+
into.cacheWriteUsdIfAssumed1h += per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h);
|
|
360
803
|
into.outputUsd += per(record.outputTokens, outputPerMTok);
|
|
804
|
+
if (record.truncated === true) {
|
|
805
|
+
into.truncatedOutputUsd += per(record.outputTokens, outputPerMTok);
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* The same cache-touched tokens at the plain input rate, banked here because
|
|
809
|
+
* `inputPerMTok` is per model and is gone by the time anybody reads the total.
|
|
810
|
+
*/
|
|
811
|
+
into.cachedTokensAtInputRateUsd += per(
|
|
812
|
+
record.cacheReadTokens + record.cacheWrite5mTokens + record.cacheWrite1hTokens,
|
|
813
|
+
inputPerMTok,
|
|
814
|
+
);
|
|
361
815
|
into.totalUsd =
|
|
362
816
|
into.inputUsd + into.cacheReadUsd + into.cacheWriteUsd + into.outputUsd;
|
|
363
817
|
return true;
|
|
@@ -367,6 +821,28 @@ export interface UsageProfileOptions {
|
|
|
367
821
|
catalogue: PricingCatalogue;
|
|
368
822
|
/** Date the prices are read at, so a promotional rate resolves the same way. */
|
|
369
823
|
on?: Date;
|
|
824
|
+
/**
|
|
825
|
+
* Profile only the records carrying this label — the drill-down, once the
|
|
826
|
+
* full report has named a suspect. `UNLABELLED` (the empty string) selects
|
|
827
|
+
* the records with no label at all. Unreadable lines still land in
|
|
828
|
+
* `skippedLines` whatever they might have been labelled: a filter must not
|
|
829
|
+
* make corruption disappear.
|
|
830
|
+
*/
|
|
831
|
+
label?: string;
|
|
832
|
+
/**
|
|
833
|
+
* Profile only the records whose clock falls in `[sinceMs, untilMs)` — the
|
|
834
|
+
* drill-down in time, once the peak day or the span has named a period.
|
|
835
|
+
*
|
|
836
|
+
* Epoch milliseconds, half-open on the right so two adjacent windows share
|
|
837
|
+
* no record. Either bound alone works. **A record with no clock cannot be
|
|
838
|
+
* placed inside or outside a window**, so under a time filter it is
|
|
839
|
+
* excluded and counted in `timeWindow.undatedExcluded` — excluded, because
|
|
840
|
+
* including it would put unknown-time spend inside a window it may not
|
|
841
|
+
* belong to; counted, because dropping it silently would understate the
|
|
842
|
+
* period's bill by an invisible amount, which is the flattering direction.
|
|
843
|
+
*/
|
|
844
|
+
sinceMs?: number;
|
|
845
|
+
untilMs?: number;
|
|
370
846
|
}
|
|
371
847
|
|
|
372
848
|
/**
|
|
@@ -378,14 +854,47 @@ export interface UsageProfileOptions {
|
|
|
378
854
|
* lives, and it can chunk if it ever needs to.
|
|
379
855
|
*/
|
|
380
856
|
export function profileUsage(text: string, options: UsageProfileOptions): UsageProfileReport {
|
|
381
|
-
const { catalogue, on = new Date() } = options;
|
|
857
|
+
const { catalogue, on = new Date(), label: onlyLabel, sinceMs, untilMs } = options;
|
|
858
|
+
const windowed = sinceMs !== undefined || untilMs !== undefined;
|
|
859
|
+
let undatedExcluded = 0;
|
|
382
860
|
|
|
383
861
|
const total = EMPTY();
|
|
384
862
|
const unpriced = EMPTY();
|
|
385
863
|
const byLabel = new Map<string, UsageBreakdown>();
|
|
386
864
|
const byModel = new Map<string, UsageBreakdown>();
|
|
865
|
+
// Keyed on a pair, so the key carries a separator that cannot occur in either
|
|
866
|
+
// half. A model id is `[A-Za-z0-9._-]`, so a newline is safe in both.
|
|
867
|
+
const byPair = new Map<string, UsageBreakdown>();
|
|
387
868
|
const unpricedModels = new Set<string>();
|
|
388
869
|
const skippedLines: number[] = [];
|
|
870
|
+
/**
|
|
871
|
+
* Fed in the pass this function already makes, rather than by keeping the records
|
|
872
|
+
* for a second one: a usage log is measured in megabytes, and what this holds is
|
|
873
|
+
* bounded by the number of conversations instead.
|
|
874
|
+
*/
|
|
875
|
+
const conversations = createConversationTracker({ catalogue, on });
|
|
876
|
+
const output = createOutputShapeTracker({ catalogue, on });
|
|
877
|
+
const input = createInputShapeTracker({ catalogue, on });
|
|
878
|
+
const repeats = createRepeatsTracker({ catalogue, on });
|
|
879
|
+
const ttlFit = createTtlFitTracker({ catalogue, on });
|
|
880
|
+
const ledger = createSessionLedgerTracker({ catalogue, on });
|
|
881
|
+
const sessionCosts = createSessionCostTracker({ catalogue, on });
|
|
882
|
+
let hasSessions = false;
|
|
883
|
+
const coverage = { label: 0, session: 0, ts: 0, stopReason: 0, cacheTtl: 0, cacheWrites: 0, parsed: 0 };
|
|
884
|
+
/**
|
|
885
|
+
* Raw lines already seen, for the duplicate check. Bounded by the number of
|
|
886
|
+
* *timestamped* lines — the price of catching a doubled bill, paid only on
|
|
887
|
+
* logs that carry a clock.
|
|
888
|
+
*/
|
|
889
|
+
const seenLines = new Set<string>();
|
|
890
|
+
const duplicates = { count: 0, usd: 0 };
|
|
891
|
+
let spanFrom = Infinity;
|
|
892
|
+
let spanTo = -Infinity;
|
|
893
|
+
let spanCalls = 0;
|
|
894
|
+
/** Per UTC day: spend, calls, and spend per label. Bounded by days × labels. */
|
|
895
|
+
const days = new Map<string, { usd: number; calls: number; byLabel: Map<string, number> }>();
|
|
896
|
+
/** Per hour of the UTC day. Bounded by twenty-four entries, whatever the log. */
|
|
897
|
+
const hours = new Map<number, { usd: number; calls: number }>();
|
|
389
898
|
|
|
390
899
|
const lines = text.split('\n');
|
|
391
900
|
for (let i = 0; i < lines.length; i += 1) {
|
|
@@ -398,6 +907,56 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
|
|
|
398
907
|
continue;
|
|
399
908
|
}
|
|
400
909
|
|
|
910
|
+
// The drill-down: after the skip accounting, so a corrupt line is reported
|
|
911
|
+
// whatever it might have been labelled.
|
|
912
|
+
if (onlyLabel !== undefined && (record.label ?? UNLABELLED) !== onlyLabel) continue;
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* The time window, after the label filter so `undatedExcluded` counts only
|
|
916
|
+
* the selected workload's clockless calls — a count polluted by every other
|
|
917
|
+
* label's records would overstate how much of *this* answer is missing.
|
|
918
|
+
* Records outside the window are simply not the question; records with no
|
|
919
|
+
* clock are the question left unanswerable, so they are counted.
|
|
920
|
+
*/
|
|
921
|
+
if (windowed) {
|
|
922
|
+
if (record.ts === null) {
|
|
923
|
+
undatedExcluded += 1;
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (sinceMs !== undefined && record.ts < sinceMs) continue;
|
|
927
|
+
if (untilMs !== undefined && record.ts >= untilMs) continue;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Coverage is counted before pricing: whether a field is present is a
|
|
932
|
+
* property of the log, and a record on an unknown model still says
|
|
933
|
+
* whether somebody set `label`.
|
|
934
|
+
*/
|
|
935
|
+
coverage.parsed += 1;
|
|
936
|
+
if (record.label !== null) coverage.label += 1;
|
|
937
|
+
if (record.session !== null) coverage.session += 1;
|
|
938
|
+
if (record.ts !== null) coverage.ts += 1;
|
|
939
|
+
if (record.truncated !== null) coverage.stopReason += 1;
|
|
940
|
+
if (record.cacheWrite5mTokens + record.cacheWrite1hTokens > 0) {
|
|
941
|
+
coverage.cacheWrites += 1;
|
|
942
|
+
if (record.writeTtlKnown) coverage.cacheTtl += 1;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (record.session !== null) hasSessions = true;
|
|
946
|
+
if (record.ts !== null) {
|
|
947
|
+
spanFrom = Math.min(spanFrom, record.ts);
|
|
948
|
+
spanTo = Math.max(spanTo, record.ts);
|
|
949
|
+
spanCalls += 1;
|
|
950
|
+
}
|
|
951
|
+
conversations.add(record);
|
|
952
|
+
output.add(record);
|
|
953
|
+
input.add(record);
|
|
954
|
+
repeats.add(record);
|
|
955
|
+
ttlFit.add(record);
|
|
956
|
+
ledger.add(record);
|
|
957
|
+
sessionCosts.add(record);
|
|
958
|
+
|
|
959
|
+
const usdBefore = total.totalUsd;
|
|
401
960
|
if (!add(total, record, catalogue, on)) {
|
|
402
961
|
unpricedModels.add(record.model);
|
|
403
962
|
countInto(unpriced, record);
|
|
@@ -408,12 +967,59 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
|
|
|
408
967
|
continue;
|
|
409
968
|
}
|
|
410
969
|
|
|
970
|
+
/**
|
|
971
|
+
* A line identical to one already read, with a clock to make the claim
|
|
972
|
+
* safe. Counted after pricing so the money is the exact delta this line
|
|
973
|
+
* added, which is what a doubled bill is overstated by.
|
|
974
|
+
*/
|
|
975
|
+
if (record.ts !== null) {
|
|
976
|
+
if (seenLines.has(line)) {
|
|
977
|
+
duplicates.count += 1;
|
|
978
|
+
duplicates.usd += total.totalUsd - usdBefore;
|
|
979
|
+
} else {
|
|
980
|
+
seenLines.add(line);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* The day's spend, as the exact delta this record just added to the total —
|
|
986
|
+
* the one place the per-record dollar exists without re-deriving the rate
|
|
987
|
+
* arithmetic a second time, where the two could drift apart.
|
|
988
|
+
*/
|
|
989
|
+
if (record.ts !== null) {
|
|
990
|
+
const day = new Date(record.ts).toISOString().slice(0, 10);
|
|
991
|
+
const usd = total.totalUsd - usdBefore;
|
|
992
|
+
let entry = days.get(day);
|
|
993
|
+
if (!entry) {
|
|
994
|
+
entry = { usd: 0, calls: 0, byLabel: new Map() };
|
|
995
|
+
days.set(day, entry);
|
|
996
|
+
}
|
|
997
|
+
entry.usd += usd;
|
|
998
|
+
entry.calls += 1;
|
|
999
|
+
const labelKey = record.label ?? UNLABELLED;
|
|
1000
|
+
entry.byLabel.set(labelKey, (entry.byLabel.get(labelKey) ?? 0) + usd);
|
|
1001
|
+
|
|
1002
|
+
// The same exact per-record dollar, bucketed by hour of the UTC day.
|
|
1003
|
+
const hour = new Date(record.ts).getUTCHours();
|
|
1004
|
+
const hourEntry = hours.get(hour);
|
|
1005
|
+
if (hourEntry) {
|
|
1006
|
+
hourEntry.usd += usd;
|
|
1007
|
+
hourEntry.calls += 1;
|
|
1008
|
+
} else {
|
|
1009
|
+
hours.set(hour, { usd, calls: 1 });
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
411
1013
|
const labelKey = record.label ?? UNLABELLED;
|
|
412
1014
|
if (!byLabel.has(labelKey)) byLabel.set(labelKey, EMPTY());
|
|
413
1015
|
add(byLabel.get(labelKey)!, record, catalogue, on);
|
|
414
1016
|
|
|
415
1017
|
if (!byModel.has(record.model)) byModel.set(record.model, EMPTY());
|
|
416
1018
|
add(byModel.get(record.model)!, record, catalogue, on);
|
|
1019
|
+
|
|
1020
|
+
const pairKey = `${labelKey}\n${record.model}`;
|
|
1021
|
+
if (!byPair.has(pairKey)) byPair.set(pairKey, EMPTY());
|
|
1022
|
+
add(byPair.get(pairKey)!, record, catalogue, on);
|
|
417
1023
|
}
|
|
418
1024
|
|
|
419
1025
|
const sorted = <K extends string>(
|
|
@@ -430,9 +1036,45 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
|
|
|
430
1036
|
total,
|
|
431
1037
|
byLabel: sorted(byLabel, 'label'),
|
|
432
1038
|
byModel: sorted(byModel, 'model'),
|
|
1039
|
+
byLabelAndModel: [...byPair.entries()]
|
|
1040
|
+
.sort((a, b) => b[1].totalUsd - a[1].totalUsd || a[0].localeCompare(b[0]))
|
|
1041
|
+
.map(([key, breakdown]) => {
|
|
1042
|
+
const split = key.indexOf('\n');
|
|
1043
|
+
return { label: key.slice(0, split), model: key.slice(split + 1), breakdown };
|
|
1044
|
+
}),
|
|
433
1045
|
unpricedModels: [...unpricedModels].sort(),
|
|
434
1046
|
unpriced,
|
|
435
1047
|
skippedLines,
|
|
1048
|
+
conversations: conversations.finish(total.totalUsd),
|
|
1049
|
+
hasSessions,
|
|
1050
|
+
outputShapes: output.finish(total.totalUsd),
|
|
1051
|
+
inputShapes: input.finish(total.totalUsd),
|
|
1052
|
+
repeatedTurns: repeats.finish(),
|
|
1053
|
+
span: spanCalls > 0 ? { fromMs: spanFrom, toMs: spanTo, calls: spanCalls } : null,
|
|
1054
|
+
spendByDay: [...days.entries()]
|
|
1055
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
1056
|
+
.map(([day, entry]) => {
|
|
1057
|
+
let topLabel: string | null = null;
|
|
1058
|
+
let topLabelUsd = 0;
|
|
1059
|
+
for (const [label, usd] of entry.byLabel) {
|
|
1060
|
+
if (usd > topLabelUsd) {
|
|
1061
|
+
topLabel = label;
|
|
1062
|
+
topLabelUsd = usd;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return { day, usd: entry.usd, calls: entry.calls, topLabel, topLabelUsd };
|
|
1066
|
+
}),
|
|
1067
|
+
duplicateLines: duplicates,
|
|
1068
|
+
fieldCoverage: coverage,
|
|
1069
|
+
spendByHour: [...hours.entries()]
|
|
1070
|
+
.sort((a, b) => a[0] - b[0])
|
|
1071
|
+
.map(([hour, entry]) => ({ hour, usd: entry.usd, calls: entry.calls })),
|
|
1072
|
+
cacheTtlFit: ttlFit.finish(),
|
|
1073
|
+
timeWindow: windowed
|
|
1074
|
+
? { sinceMs: sinceMs ?? null, untilMs: untilMs ?? null, undatedExcluded }
|
|
1075
|
+
: null,
|
|
1076
|
+
singleTurnCacheWrites: ledger.finish(),
|
|
1077
|
+
sessionCosts: sessionCosts.finish(),
|
|
436
1078
|
};
|
|
437
1079
|
}
|
|
438
1080
|
|
|
@@ -477,3 +1119,149 @@ export function cacheHitRate(breakdown: UsageBreakdown): number | null {
|
|
|
477
1119
|
if (attempts === 0) return null;
|
|
478
1120
|
return breakdown.cacheReadTokens / attempts;
|
|
479
1121
|
}
|
|
1122
|
+
|
|
1123
|
+
/** What caching did to this bill, measured against the same tokens uncached. */
|
|
1124
|
+
export interface CacheEconomics {
|
|
1125
|
+
/** What the cache-touched tokens actually cost: reads plus writes. */
|
|
1126
|
+
spentUsd: number;
|
|
1127
|
+
/** What those same tokens would have cost billed as ordinary input. */
|
|
1128
|
+
withoutCachingUsd: number;
|
|
1129
|
+
/**
|
|
1130
|
+
* `spentUsd - withoutCachingUsd`.
|
|
1131
|
+
*
|
|
1132
|
+
* **Positive means caching cost more than it saved** — the opposite of the sign
|
|
1133
|
+
* convention everywhere else in Trazum, and deliberately so, because this is the
|
|
1134
|
+
* number nobody expects to come out positive and the one worth interrupting for.
|
|
1135
|
+
*/
|
|
1136
|
+
deltaUsd: number;
|
|
1137
|
+
/**
|
|
1138
|
+
* Read tokens per write token, or `null` when nothing was written.
|
|
1139
|
+
*
|
|
1140
|
+
* Context for the delta, not a verdict of its own: the delta already decides,
|
|
1141
|
+
* and it decides at the real per-model rates. This says *why* — a ratio near
|
|
1142
|
+
* zero on an Anthropic workload is a prefix being rebuilt faster than it is
|
|
1143
|
+
* reused, which is the shape of a cache that never gets to work.
|
|
1144
|
+
*/
|
|
1145
|
+
readsPerWrite: number | null;
|
|
1146
|
+
verdict: CacheVerdict;
|
|
1147
|
+
/**
|
|
1148
|
+
* `deltaUsd` with every write whose TTL the log did not state priced at the
|
|
1149
|
+
* 1-hour rate instead of the assumed 5-minute one.
|
|
1150
|
+
*
|
|
1151
|
+
* Equal to `deltaUsd` when every TTL was recorded, and never smaller: the
|
|
1152
|
+
* 1-hour multiplier is at or above the 5-minute one on every model in the
|
|
1153
|
+
* catalogue, so this is a genuine worst case rather than the other end of a
|
|
1154
|
+
* range.
|
|
1155
|
+
*/
|
|
1156
|
+
worstCaseDeltaUsd: number;
|
|
1157
|
+
/**
|
|
1158
|
+
* The verdict at that worst case.
|
|
1159
|
+
*
|
|
1160
|
+
* **When this differs from `verdict`, the log cannot settle the question** and
|
|
1161
|
+
* neither can any report built from it. That is not a rare shape: a workload
|
|
1162
|
+
* reading back between 0.28 and 1.11 tokens per token written flips between
|
|
1163
|
+
* `paid-off` and `lost-money` on the TTL alone, and a log carrying only the flat
|
|
1164
|
+
* `cache_creation_input_tokens` never says which. Measured on a million written
|
|
1165
|
+
* tokens against three hundred thousand read back, the difference was a $0.10
|
|
1166
|
+
* saving against a $3.65 loss — a $3.75 swing across the sign, and the assumed
|
|
1167
|
+
* half is the flattering one.
|
|
1168
|
+
*/
|
|
1169
|
+
worstCaseVerdict: CacheVerdict;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* - `paid-off` — caching took money off the bill.
|
|
1174
|
+
* - `lost-money` — caching added to it. Possible on Anthropic, where a write
|
|
1175
|
+
* costs 1.25x input (5-minute) or 2x (1-hour); a prefix that never gets read
|
|
1176
|
+
* back is billed at a premium for nothing.
|
|
1177
|
+
* - `no-difference` — the multipliers cancelled out. This is where automatic
|
|
1178
|
+
* caching with a 1x write rate lands when nothing was ever read.
|
|
1179
|
+
* - `not-attempted` — no cache tokens at all, in either direction.
|
|
1180
|
+
* - `unpriced` — cache tokens with no prices behind them, so there is no
|
|
1181
|
+
* comparison to make. Saying nothing is the only honest answer.
|
|
1182
|
+
*/
|
|
1183
|
+
export type CacheVerdict =
|
|
1184
|
+
| 'paid-off'
|
|
1185
|
+
| 'lost-money'
|
|
1186
|
+
| 'no-difference'
|
|
1187
|
+
| 'not-attempted'
|
|
1188
|
+
| 'unpriced';
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Floating-point noise, not a judgement threshold.
|
|
1192
|
+
*
|
|
1193
|
+
* Summing a million per-call doubles around a $100 bill accumulates roughly
|
|
1194
|
+
* `n · eps · magnitude` ≈ $2e-8 of drift, and a verdict that flipped on that would
|
|
1195
|
+
* be reporting arithmetic error as a finding. A millionth of a dollar is orders of
|
|
1196
|
+
* magnitude below anything this tool prints, so nothing real is being rounded away
|
|
1197
|
+
* — deliberately not a "too small to care about" cutoff, which would be a
|
|
1198
|
+
* judgement and would belong somewhere a reader can see it.
|
|
1199
|
+
*/
|
|
1200
|
+
const CACHE_DELTA_NOISE_USD = 1e-6;
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Did caching pay for itself?
|
|
1204
|
+
*
|
|
1205
|
+
* The question nothing else in this package can answer, and the one that decides
|
|
1206
|
+
* whether the advice the rest of it gives was right. Trazum tells people to cache;
|
|
1207
|
+
* on Anthropic a cache **write** costs 1.25x plain input at the 5-minute TTL and
|
|
1208
|
+
* **2x** at the 1-hour one, so a prefix that changes faster than it is reused is
|
|
1209
|
+
* billed at a premium and returns nothing. That workload would be cheaper with
|
|
1210
|
+
* caching switched off, and no other report in this repository would ever say so.
|
|
1211
|
+
*
|
|
1212
|
+
* The counterfactual is exact, which is why this is allowed to exist here at all:
|
|
1213
|
+
* caching changes the multiplier on a token, never the token. Had `cache_control`
|
|
1214
|
+
* not been set, the identical prefix would have gone up as ordinary input at 1x.
|
|
1215
|
+
* So `withoutCachingUsd` is not an estimate of a different call — it is the same
|
|
1216
|
+
* call, arithmetic away.
|
|
1217
|
+
*
|
|
1218
|
+
* Worth running per label as well as over the whole log. A profitable cache on one
|
|
1219
|
+
* workload and a bleeding one on another net out to a comfortable-looking total,
|
|
1220
|
+
* and the aggregate is exactly where a loss hides.
|
|
1221
|
+
*/
|
|
1222
|
+
export function cacheEconomics(breakdown: UsageBreakdown): CacheEconomics {
|
|
1223
|
+
const touchedTokens = breakdown.cacheReadTokens + breakdown.cacheWriteTokens;
|
|
1224
|
+
const spentUsd = breakdown.cacheReadUsd + breakdown.cacheWriteUsd;
|
|
1225
|
+
const withoutCachingUsd = breakdown.cachedTokensAtInputRateUsd;
|
|
1226
|
+
|
|
1227
|
+
const none = (verdict: CacheVerdict): CacheEconomics => ({
|
|
1228
|
+
spentUsd,
|
|
1229
|
+
withoutCachingUsd,
|
|
1230
|
+
deltaUsd: 0,
|
|
1231
|
+
readsPerWrite: null,
|
|
1232
|
+
verdict,
|
|
1233
|
+
worstCaseDeltaUsd: 0,
|
|
1234
|
+
worstCaseVerdict: verdict,
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
if (touchedTokens === 0) return none('not-attempted');
|
|
1238
|
+
/**
|
|
1239
|
+
* Tokens went through the cache and no money is attached to either side, so
|
|
1240
|
+
* there is nothing to compare. This is what an unpriced model looks like: the
|
|
1241
|
+
* counts accumulate through `countInto` and the dollars never do. Without this
|
|
1242
|
+
* guard the delta is `0 - 0` and the verdict comes out `no-difference` — a
|
|
1243
|
+
* confident claim about a bill that was never computed.
|
|
1244
|
+
*/
|
|
1245
|
+
if (spentUsd === 0 && withoutCachingUsd === 0) return none('unpriced');
|
|
1246
|
+
|
|
1247
|
+
const deltaUsd = spentUsd - withoutCachingUsd;
|
|
1248
|
+
const worstCaseDeltaUsd =
|
|
1249
|
+
breakdown.cacheReadUsd + breakdown.cacheWriteUsdIfAssumed1h - withoutCachingUsd;
|
|
1250
|
+
const readsPerWrite =
|
|
1251
|
+
breakdown.cacheWriteTokens === 0
|
|
1252
|
+
? null
|
|
1253
|
+
: breakdown.cacheReadTokens / breakdown.cacheWriteTokens;
|
|
1254
|
+
|
|
1255
|
+
const decide = (delta: number): CacheVerdict =>
|
|
1256
|
+
Math.abs(delta) < CACHE_DELTA_NOISE_USD ? 'no-difference' : delta > 0 ? 'lost-money' : 'paid-off';
|
|
1257
|
+
|
|
1258
|
+
return {
|
|
1259
|
+
spentUsd,
|
|
1260
|
+
withoutCachingUsd,
|
|
1261
|
+
deltaUsd,
|
|
1262
|
+
readsPerWrite,
|
|
1263
|
+
verdict: decide(deltaUsd),
|
|
1264
|
+
worstCaseDeltaUsd,
|
|
1265
|
+
worstCaseVerdict: decide(worstCaseDeltaUsd),
|
|
1266
|
+
};
|
|
1267
|
+
}
|