@bglocation/tune-context 2.3.1 → 2.5.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.
@@ -0,0 +1,461 @@
1
+ // pricing — dollars for the token components prompt-metrics.mjs emits.
2
+ // See tasks/TASK-086.
3
+ //
4
+ // ┌───────────────────────────────────────────────────────────────────────────┐
5
+ // │ THIS IS NOT AN INVOICE. It is API LIST PRICE for the same tokens. │
6
+ // │ │
7
+ // │ Claude Code on a Pro or Max subscription is NOT billed per token — you pay │
8
+ // │ the plan fee and consume plan limits. The published price table is API │
9
+ // │ pay-as-you-go pricing, so the figure below answers "what would this work │
10
+ // │ have cost through the API", not "what did you pay". On a $25 Pro plan you │
11
+ // │ paid $25 no matter what this says. │
12
+ // │ │
13
+ // │ It stays the right yardstick for EPIC-009 anyway: comparing two arms needs │
14
+ // │ a consumption measure that is proportional to work done, and list price is │
15
+ // │ exactly that. It must never be printed as money spent — the fields are │
16
+ // │ named list_cost_* so the qualifier travels with the data. │
17
+ // └───────────────────────────────────────────────────────────────────────────┘
18
+ //
19
+ // Why not a plain token sum: a random sample from a live transcript reads
20
+ // `cache_read 31 650`, `cache_creation 13 890`, `input_tokens 2`. Cache reads
21
+ // cost 0.1x base, so counting those as input inflates the figure ~10x — and
22
+ // prints like a fact (EPIC-009, error class 1).
23
+ //
24
+ // Two rules run through everything below:
25
+ // • The price table is DATA — one table, dated, with its source, kept apart
26
+ // from the code that multiplies. Prices change; the claude-sonnet-5
27
+ // introductory price ends 2026-08-31 and has to end in the table too.
28
+ // • An unknown model is never priced at zero. The row gets `list_cost_usd:
29
+ // null` and a reason, and the reason is reported. A silent zero is a
30
+ // fabricated saving. `<synthetic>` entries are NOT this case: prompt-metrics
31
+ // drops them earlier as unbillable, so they never reach here and never
32
+ // pollute the counter that is supposed to signal a gap in the table.
33
+ //
34
+ // Refusal is whole-row. A row mixing a priced and an unpriced model is not
35
+ // partially priced: a number that looks complete and covers half the turns is
36
+ // worse than an admitted gap.
37
+
38
+ /**
39
+ * Base prices per million tokens, USD. Verified line by line against the
40
+ * published table on 2026-07-30 (see PRICES.source).
41
+ *
42
+ * Only base input/output are stored. The published 5m-write, 1h-write and
43
+ * cache-hit columns are exactly base x 1.25 / x 2 / x 0.1 for every model in
44
+ * the table, so storing them would be storing the same fact twice — and two
45
+ * copies of a price are two prices that can disagree. `test/pricing.test.mjs`
46
+ * asserts the derivation reproduces the published columns.
47
+ *
48
+ * Keys are model ids as they appear in transcripts, with any -YYYYMMDD suffix
49
+ * stripped (see normalizeModel).
50
+ *
51
+ * `source` / `retrieved` at the top cover every entry that does not carry its
52
+ * own. Base and promo entries share one shape — `{ input, output, speeds? }` —
53
+ * so a promotional price can carry its own speed variants without a special
54
+ * case. `speeds` holds ABSOLUTE prices, not multipliers.
55
+ */
56
+ export const PRICES = {
57
+ source: 'https://platform.claude.com/docs/en/about-claude/pricing',
58
+ retrieved: '2026-07-30',
59
+ unit: 'USD per 1M tokens, API list price (NOT subscription billing)',
60
+ models: {
61
+ // Fast mode is a research preview on Opus 5 and Opus 4.8 only, at $10/$50.
62
+ 'claude-opus-5': { input: 5, output: 25, speeds: { fast: { input: 10, output: 50 } } },
63
+ 'claude-opus-4-8': { input: 5, output: 25, speeds: { fast: { input: 10, output: 50 } } },
64
+ // Opus 4.7 rejects speed:"fast" outright, so no variant — a fast turn on it
65
+ // cannot exist, and if one appears the refusal is the correct answer.
66
+ 'claude-opus-4-7': { input: 5, output: 25 },
67
+ // Opus 4.6 accepts speed:"fast" but runs and BILLS at standard rates, so the
68
+ // variant is deliberately equal to base rather than absent: refusing here
69
+ // would hide a turn we can price exactly.
70
+ 'claude-opus-4-6': { input: 5, output: 25, speeds: { fast: { input: 5, output: 25 } } },
71
+ 'claude-opus-4-5': { input: 5, output: 25 },
72
+ 'claude-opus-4-1': { input: 15, output: 75 },
73
+ 'claude-opus-4': { input: 15, output: 75 },
74
+ 'claude-fable-5': { input: 10, output: 50 },
75
+ 'claude-mythos-5': { input: 10, output: 50 },
76
+ 'claude-sonnet-5': { input: 3, output: 15, promo: { input: 2, output: 10, through: '2026-08-31' } },
77
+ 'claude-sonnet-4-6': { input: 3, output: 15 },
78
+ 'claude-sonnet-4-5': { input: 3, output: 15 },
79
+ 'claude-sonnet-4': { input: 3, output: 15 },
80
+ 'claude-haiku-4-5': { input: 1, output: 5 },
81
+ // Older naming order (claude-3-5-haiku-20241022), not claude-haiku-3-5.
82
+ 'claude-3-5-haiku': { input: 0.8, output: 4 },
83
+ },
84
+ };
85
+
86
+ /**
87
+ * Cache multipliers on the INPUT price. The 5m/1h split is in
88
+ * `usage.cache_creation`, so a write is priced exactly rather than approximated
89
+ * — which is why prompt-metrics keeps `cache_write_unsplit` separate instead of
90
+ * folding undated writes into 5m and inventing up to 60% of a cost line.
91
+ */
92
+ export const CACHE_MULTIPLIERS = { read: 0.1, write_5m: 1.25, write_1h: 2 };
93
+
94
+ /**
95
+ * Service tier. Batch is half base price on both input and output — a valuation
96
+ * that ignores this field doubles the figure the moment anyone uses it.
97
+ * Measured values so far are `standard` and absent.
98
+ *
99
+ * Absent is not the same as unknown: a field Claude Code did not emit means the
100
+ * API default (standard), while a tier string that is NOT in this table is a
101
+ * tier we cannot price and must refuse. That refusal also catches `mixed`,
102
+ * which prompt-metrics writes when one turn spans two tiers.
103
+ */
104
+ export const TIER_MULTIPLIERS = { standard: 1, batch: 0.5 };
105
+
106
+ /**
107
+ * Data residency. `inference_geo: "us"` costs 1.1x across every token class on
108
+ * Claude 4.6 and later. The field is in the transcript (17 089 entries locally,
109
+ * all `not_available`, which is what models predating the parameter report), so
110
+ * this is handled rather than assumed — the one measured value must not be the
111
+ * only value the code survives.
112
+ */
113
+ export const GEO_MULTIPLIERS = { global: 1, not_available: 1, us: 1.1 };
114
+
115
+ /** $10 per 1,000 searches. Web FETCH is free — only search is billed. */
116
+ export const WEB_SEARCH_USD = 0.01;
117
+
118
+ /**
119
+ * The five priced classes, paired with the field each one is read from on a
120
+ * prompt-metrics row. Two vocabularies for one fact — `input`/`output` are cost
121
+ * classes, `input_tokens`/`output_tokens` are row fields — and this is the one
122
+ * place that knows the translation, because costOfTokens() below is already its
123
+ * other reader. Anything that folds tokens up (eval/analyze.mjs) reads it from
124
+ * here rather than retyping the list: a sixth class added to the collector's
125
+ * emptyTokens() must not be able to fall out of a downstream total by being
126
+ * forgotten in a hand-written array.
127
+ *
128
+ * `cache_write_unsplit` is deliberately NOT here. It is not a priced class — it
129
+ * is the admission that a write's TTL is unknown, and a row carrying it is
130
+ * refused rather than priced.
131
+ */
132
+ export const TOKEN_CLASS_FIELDS = [
133
+ ['input', 'input_tokens'],
134
+ ['cache_read', 'cache_read'],
135
+ ['cache_write_5m', 'cache_write_5m'],
136
+ ['cache_write_1h', 'cache_write_1h'],
137
+ ['output', 'output_tokens'],
138
+ ];
139
+
140
+ const TOKEN_CLASSES = TOKEN_CLASS_FIELDS.map(([className]) => className);
141
+
142
+ function emptyCost() {
143
+ return Object.fromEntries(TOKEN_CLASSES.map((c) => [c, 0]));
144
+ }
145
+
146
+ /** Micro-dollar resolution. Enough for a per-prompt line, stable for tests. */
147
+ function round6(n) {
148
+ return Math.round(n * 1e6) / 1e6;
149
+ }
150
+
151
+ /**
152
+ * The published total is the sum of the published classes — rounded FIRST, then
153
+ * added. Rounding the total separately from its parts is how a figure ends up
154
+ * disagreeing with its own breakdown, which EPIC-009 criterion 2 forbids
155
+ * outright; TASK-085 already had to fix the identical bug in the token totals.
156
+ *
157
+ * A class that did not come out a real number is refused rather than published.
158
+ * NaN reads as a number to every check that asks "is there a price here?", so a
159
+ * single such row used to be counted as PRICED and drag the whole run's total to
160
+ * NaN — a figure that prints as `$NaN` and, one `??` away, as a saving. Same rule
161
+ * as an unknown model: an amount we cannot compute is admitted, never shown.
162
+ */
163
+ function priced(total) {
164
+ const list_cost_by_class = {};
165
+ for (const c of TOKEN_CLASSES) list_cost_by_class[c] = round6(total[c]);
166
+ if (!TOKEN_CLASSES.every((c) => Number.isFinite(list_cost_by_class[c]))) return unpriced('non-finite-cost');
167
+ return {
168
+ list_cost_usd: round6(TOKEN_CLASSES.reduce((s, c) => s + list_cost_by_class[c], 0)),
169
+ list_cost_by_class,
170
+ unpriced_reason: null,
171
+ unpriced_models: [],
172
+ };
173
+ }
174
+
175
+ /**
176
+ * `models` names only the models whose RATE could not be resolved — never the
177
+ * ones that merely rode along on a refused row. A mixed row is refused whole,
178
+ * so without this the report blames every model in it, and a reader is sent to
179
+ * add a price for a model that already has one. (Measured: 5 rows blamed
180
+ * claude-sonnet-5 and claude-fable-5 for claude-opus-4-8's absence.)
181
+ */
182
+ function unpriced(reason, models = []) {
183
+ return { list_cost_usd: null, list_cost_by_class: null, unpriced_reason: reason, unpriced_models: models };
184
+ }
185
+
186
+ /**
187
+ * Model ids may carry a release date (`claude-haiku-4-5-20251001`). The table
188
+ * is keyed by family, so the suffix is stripped — a dated id must not land in
189
+ * `unpriced` for a model whose price we know.
190
+ */
191
+ export function normalizeModel(model) {
192
+ return typeof model === 'string' ? model.replace(/-\d{8}$/, '') : '';
193
+ }
194
+
195
+ /**
196
+ * Table lookup that answers only about the table. A plain object also answers
197
+ * for everything on Object.prototype, so `PRICES.models['constructor']` hands
198
+ * back a function instead of undefined — and a function sails through an
199
+ * `if (!entry)` guard straight into the arithmetic. Model ids, tiers and geos
200
+ * all come from the transcript, i.e. from outside this file, so the lookups that
201
+ * decide whether we can price a row ask for OWN keys.
202
+ */
203
+ function lookup(table, key) {
204
+ return typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : undefined;
205
+ }
206
+
207
+ function promoActive(promo, atMs) {
208
+ return atMs <= Date.parse(`${promo.through}T23:59:59.999Z`);
209
+ }
210
+
211
+ /**
212
+ * Price per MTok for a model at a point in time.
213
+ *
214
+ * `at` is the moment the prompt RAN, not the moment the report runs: a prompt
215
+ * from July priced in September must still get July's price, or re-running the
216
+ * same report over the same transcripts would quietly produce different money.
217
+ *
218
+ * @returns {{rate: {input:number, output:number}|null, reason: string|null}}
219
+ */
220
+ export function rateFor(model, { speed = null, at = null } = {}) {
221
+ const entry = lookup(PRICES.models, normalizeModel(model));
222
+ if (!entry) return { rate: null, reason: 'unknown-model' };
223
+
224
+ let active = entry;
225
+ if (entry.promo) {
226
+ // Only date-dependent models need a date, so an undated row is refused
227
+ // exactly where the date would have changed the answer.
228
+ if (at === null || Number.isNaN(at)) return { rate: null, reason: 'unknown-date' };
229
+ if (promoActive(entry.promo, at)) active = entry.promo;
230
+ }
231
+
232
+ // Absent speed = standard, same reading as the tier and geo fields; an
233
+ // unrecognized speed (including `mixed`) is refused rather than guessed,
234
+ // because on an Opus model that guess is a factor of two.
235
+ const s = speed ?? 'standard';
236
+ const rate = s === 'standard' ? active : active.speeds && lookup(active.speeds, s);
237
+ if (!rate) return { rate: null, reason: 'unknown-speed' };
238
+ return { rate: { input: rate.input, output: rate.output }, reason: null };
239
+ }
240
+
241
+ /**
242
+ * Dollars for one token bucket, split by class. Never returns a single number
243
+ * on its own — EPIC-009 criterion 2 forbids a cost figure without its
244
+ * breakdown, and the cheapest way to honour that is to not produce one.
245
+ *
246
+ * Multipliers stack, as the published table says they do: cache class x
247
+ * service tier x inference geography.
248
+ */
249
+ export function costOfTokens(tokens, rate, multiplier = 1) {
250
+ const perInput = (rate.input / 1e6) * multiplier;
251
+ const perOutput = (rate.output / 1e6) * multiplier;
252
+ return {
253
+ input: (tokens.input_tokens || 0) * perInput,
254
+ cache_read: (tokens.cache_read || 0) * perInput * CACHE_MULTIPLIERS.read,
255
+ cache_write_5m: (tokens.cache_write_5m || 0) * perInput * CACHE_MULTIPLIERS.write_5m,
256
+ cache_write_1h: (tokens.cache_write_1h || 0) * perInput * CACHE_MULTIPLIERS.write_1h,
257
+ output: (tokens.output_tokens || 0) * perOutput,
258
+ };
259
+ }
260
+
261
+ function bucketIsEmpty(t) {
262
+ return (
263
+ !(t.input_tokens > 0) &&
264
+ !(t.cache_read > 0) &&
265
+ !(t.cache_write_5m > 0) &&
266
+ !(t.cache_write_1h > 0) &&
267
+ !(t.cache_write_unsplit > 0) &&
268
+ !(t.output_tokens > 0)
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Price one prompt-metrics row, at API list price.
274
+ *
275
+ * Priced per entry of `by_model`, not off the row's flat totals: a turn that
276
+ * switched models (measured — opus and sonnet inside one session) would
277
+ * otherwise be charged at one model's rate, and the opus/sonnet gap is 1.7x,
278
+ * larger than any effect this epic is looking for (error class 4).
279
+ *
280
+ * `speed`, `service_tier` and `inference_geo` stay row-level, so a turn that
281
+ * mixed them is refused rather than split on an assumption.
282
+ *
283
+ * A row with no billable tokens costs $0 and is NOT unpriced — a prompt with no
284
+ * API turn is free, not unknown, and must not dilute the counter that means
285
+ * "our table has a gap".
286
+ */
287
+ export function priceRow(row, { at } = {}) {
288
+ const buckets = Object.entries(row?.by_model ?? {}).filter(([, t]) => !bucketIsEmpty(t));
289
+ if (buckets.length === 0) return priced(emptyCost());
290
+
291
+ const tier = lookup(TIER_MULTIPLIERS, row.service_tier ?? 'standard');
292
+ if (tier === undefined) return unpriced('unknown-tier');
293
+ const geo = lookup(GEO_MULTIPLIERS, row.inference_geo ?? 'global');
294
+ if (geo === undefined) return unpriced('unknown-geo');
295
+
296
+ const when = at !== undefined ? at : Date.parse(row.ts_submit ?? row.ts_end ?? '');
297
+ const total = emptyCost();
298
+ let refusal = null;
299
+ const offenders = [];
300
+ // Every bucket is examined even after the first refusal: the row is still
301
+ // refused whole, but the report can then name ALL the models it could not
302
+ // price instead of only the alphabetically first one.
303
+ for (const [model, tokens] of buckets) {
304
+ // The field exists precisely so a TTL is never guessed: 1.25x vs 2x is up
305
+ // to 60% of the write line. Zero on every entry measured to date. The model
306
+ // is not at fault here, so it is not named as an offender.
307
+ if (tokens.cache_write_unsplit > 0) {
308
+ refusal ??= 'cache-write-unsplit';
309
+ continue;
310
+ }
311
+ const { rate, reason } = rateFor(model, { speed: row.speed ?? null, at: when });
312
+ if (rate === null) {
313
+ refusal ??= reason;
314
+ offenders.push(model);
315
+ continue;
316
+ }
317
+ const part = costOfTokens(tokens, rate, tier * geo);
318
+ for (const c of TOKEN_CLASSES) total[c] += part[c];
319
+ }
320
+ if (refusal !== null) return unpriced(refusal, offenders);
321
+ return priced(total);
322
+ }
323
+
324
+ /**
325
+ * Server-side tools are billed per REQUEST, not per token, so they sit outside
326
+ * list_cost_by_class and its total — folding them in would break the rule that
327
+ * the total equals its own token breakdown. Reported alongside, additive.
328
+ *
329
+ * Web fetch is free; only web search is billed. Counting both as billable was
330
+ * an error in the first cut of this module.
331
+ */
332
+ export function webSearchCost(requests) {
333
+ return round6((requests || 0) * WEB_SEARCH_USD);
334
+ }
335
+
336
+ /**
337
+ * The breakdown of a row that really is priced, or **null**.
338
+ *
339
+ * Keyed off the absence of a usable price, not off the reason: a row with no
340
+ * list_cost_usd is unpriced even if it forgot to say why, and the alternative
341
+ * reading — treating it as $0 — is the fabricated saving this file exists to
342
+ * prevent. A total or class that is not a finite number fails the same way and
343
+ * is refused for the same reason: it would be added to the run total, which is
344
+ * how one bad row makes every other row's money disappear into NaN.
345
+ */
346
+ function pricedClasses(row) {
347
+ const classes = row.list_cost_by_class;
348
+ if (!Number.isFinite(row.list_cost_usd) || !classes) return null;
349
+ return TOKEN_CLASSES.every((c) => Number.isFinite(classes[c])) ? classes : null;
350
+ }
351
+
352
+ /**
353
+ * Roll priced rows up. Keeps the unpriced ones visible BY MODEL: the whole
354
+ * point of the counter is to name the gap in the table, and "318 unpriced" says
355
+ * far less than "318 unpriced: claude-opus-4-8".
356
+ */
357
+ export function summarizeCost(rows) {
358
+ const cost = emptyCost();
359
+ // Null-prototype: the keys are model ids and reasons, i.e. strings from the
360
+ // transcript. On a plain object a model called `constructor` would read back
361
+ // as a function, and `fn ?? 0` then counts it as `fn1`.
362
+ const unpricedRows = { rows: 0, reasons: Object.create(null), models: Object.create(null) };
363
+ let pricedRows = 0;
364
+ let searches = 0;
365
+ for (const row of rows) {
366
+ searches += row.web_search_requests ?? 0;
367
+ const classes = pricedClasses(row);
368
+ if (classes === null) {
369
+ unpricedRows.rows++;
370
+ const r = row.unpriced_reason ?? 'unknown';
371
+ unpricedRows.reasons[r] = (unpricedRows.reasons[r] ?? 0) + 1;
372
+ for (const m of row.unpriced_models ?? []) unpricedRows.models[m] = (unpricedRows.models[m] ?? 0) + 1;
373
+ continue;
374
+ }
375
+ pricedRows++;
376
+ for (const c of TOKEN_CLASSES) cost[c] += classes[c];
377
+ }
378
+ // Same rule as one row: the run total is the fold of the published classes,
379
+ // never a parallel sum that could disagree with them.
380
+ const { list_cost_usd, list_cost_by_class } = priced(cost);
381
+ return {
382
+ list_cost_usd,
383
+ list_cost_by_class,
384
+ web_search_requests: searches,
385
+ web_search_cost_usd: webSearchCost(searches),
386
+ priced_rows: pricedRows,
387
+ total_rows: pricedRows + unpricedRows.rows,
388
+ unpriced: unpricedRows,
389
+ };
390
+ }
391
+
392
+ function usd(n) {
393
+ return `$${n.toFixed(4)}`;
394
+ }
395
+
396
+ /**
397
+ * An amount for human eyes, including the case where there is none. An
398
+ * uncomputable total prints as the word, never as `$0.0000` — the whole file
399
+ * exists to stop an unknown cost reading as a cheap one.
400
+ */
401
+ export function formatUsd(n) {
402
+ return Number.isFinite(n) ? usd(n) : 'unknown (NOT $0)';
403
+ }
404
+
405
+ function count(n) {
406
+ return n.toLocaleString('en-US');
407
+ }
408
+
409
+ /**
410
+ * Cost lines for a run summary. Always emits the per-class split next to the
411
+ * total — the one formatting rule EPIC-009 states outright (criterion 2), kept
412
+ * here rather than at the call site so it is testable on its own. And always
413
+ * says what the figure IS: a subscription user who reads it as their bill has
414
+ * been misled by us, not by the number.
415
+ */
416
+ export function formatCostLines(summary, tokens) {
417
+ const L = [];
418
+ if (tokens) {
419
+ // Labelled "all rows" because the cost line below covers only the priced
420
+ // ones: two figures side by side that quietly span different populations
421
+ // invite exactly the wrong division.
422
+ L.push(
423
+ ` tokens (all rows): input ${count(tokens.input_tokens)} | cache-read ${count(tokens.cache_read)}` +
424
+ ` | cache-write-5m ${count(tokens.cache_write_5m)} | cache-write-1h ${count(tokens.cache_write_1h)}` +
425
+ ` | output ${count(tokens.output_tokens)}` +
426
+ // Otherwise the classes would not add up to the tokens actually seen,
427
+ // and a breakdown that silently omits a class is not a breakdown.
428
+ (tokens.cache_write_unsplit > 0 ? ` | cache-write-UNSPLIT ${count(tokens.cache_write_unsplit)}` : ''),
429
+ );
430
+ }
431
+ const c = summary.list_cost_by_class;
432
+ L.push(
433
+ ` API list price: ${usd(summary.list_cost_usd)} over ${summary.priced_rows} of ${summary.total_rows} row(s)` +
434
+ ` = input ${usd(c.input)} + cache-read ${usd(c.cache_read)} + cache-write-5m ${usd(c.cache_write_5m)}` +
435
+ ` + cache-write-1h ${usd(c.cache_write_1h)} + output ${usd(c.output)}`,
436
+ );
437
+ L.push(
438
+ ' ^ what these tokens WOULD cost via the API. NOT a bill: a Pro/Max plan' +
439
+ ' charges its flat fee, not per token. Use it to compare arms, not to reconcile a statement.',
440
+ );
441
+ if (summary.web_search_requests > 0) {
442
+ L.push(
443
+ ` web searches: ${summary.web_search_requests} = ${usd(summary.web_search_cost_usd)}` +
444
+ ' — billed per request, additive to the figure above (web fetch is free)',
445
+ );
446
+ }
447
+ if (summary.unpriced.rows > 0) {
448
+ const reasons = Object.entries(summary.unpriced.reasons)
449
+ .sort((a, b) => b[1] - a[1])
450
+ .map(([r, n]) => `${r} ${n}`)
451
+ .join(', ');
452
+ const models = Object.entries(summary.unpriced.models)
453
+ .sort((a, b) => b[1] - a[1])
454
+ .map(([m, n]) => `${m} ${n}`)
455
+ .join(', ');
456
+ L.push(` UNPRICED rows: ${summary.unpriced.rows} (${reasons}) — their cost is unknown, NOT zero`);
457
+ if (models) L.push(` models seen on unpriced rows: ${models}`);
458
+ L.push(` price table: ${PRICES.source} (retrieved ${PRICES.retrieved})`);
459
+ }
460
+ return L;
461
+ }