@bglocation/tune-context 2.3.1 → 2.4.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,430 @@
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
+ const TOKEN_CLASSES = ['input', 'cache_read', 'cache_write_5m', 'cache_write_1h', 'output'];
119
+
120
+ function emptyCost() {
121
+ return Object.fromEntries(TOKEN_CLASSES.map((c) => [c, 0]));
122
+ }
123
+
124
+ /** Micro-dollar resolution. Enough for a per-prompt line, stable for tests. */
125
+ function round6(n) {
126
+ return Math.round(n * 1e6) / 1e6;
127
+ }
128
+
129
+ /**
130
+ * The published total is the sum of the published classes — rounded FIRST, then
131
+ * added. Rounding the total separately from its parts is how a figure ends up
132
+ * disagreeing with its own breakdown, which EPIC-009 criterion 2 forbids
133
+ * outright; TASK-085 already had to fix the identical bug in the token totals.
134
+ *
135
+ * A class that did not come out a real number is refused rather than published.
136
+ * NaN reads as a number to every check that asks "is there a price here?", so a
137
+ * single such row used to be counted as PRICED and drag the whole run's total to
138
+ * NaN — a figure that prints as `$NaN` and, one `??` away, as a saving. Same rule
139
+ * as an unknown model: an amount we cannot compute is admitted, never shown.
140
+ */
141
+ function priced(total) {
142
+ const list_cost_by_class = {};
143
+ for (const c of TOKEN_CLASSES) list_cost_by_class[c] = round6(total[c]);
144
+ if (!TOKEN_CLASSES.every((c) => Number.isFinite(list_cost_by_class[c]))) return unpriced('non-finite-cost');
145
+ return {
146
+ list_cost_usd: round6(TOKEN_CLASSES.reduce((s, c) => s + list_cost_by_class[c], 0)),
147
+ list_cost_by_class,
148
+ unpriced_reason: null,
149
+ unpriced_models: [],
150
+ };
151
+ }
152
+
153
+ /**
154
+ * `models` names only the models whose RATE could not be resolved — never the
155
+ * ones that merely rode along on a refused row. A mixed row is refused whole,
156
+ * so without this the report blames every model in it, and a reader is sent to
157
+ * add a price for a model that already has one. (Measured: 5 rows blamed
158
+ * claude-sonnet-5 and claude-fable-5 for claude-opus-4-8's absence.)
159
+ */
160
+ function unpriced(reason, models = []) {
161
+ return { list_cost_usd: null, list_cost_by_class: null, unpriced_reason: reason, unpriced_models: models };
162
+ }
163
+
164
+ /**
165
+ * Model ids may carry a release date (`claude-haiku-4-5-20251001`). The table
166
+ * is keyed by family, so the suffix is stripped — a dated id must not land in
167
+ * `unpriced` for a model whose price we know.
168
+ */
169
+ export function normalizeModel(model) {
170
+ return typeof model === 'string' ? model.replace(/-\d{8}$/, '') : '';
171
+ }
172
+
173
+ /**
174
+ * Table lookup that answers only about the table. A plain object also answers
175
+ * for everything on Object.prototype, so `PRICES.models['constructor']` hands
176
+ * back a function instead of undefined — and a function sails through an
177
+ * `if (!entry)` guard straight into the arithmetic. Model ids, tiers and geos
178
+ * all come from the transcript, i.e. from outside this file, so the lookups that
179
+ * decide whether we can price a row ask for OWN keys.
180
+ */
181
+ function lookup(table, key) {
182
+ return typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : undefined;
183
+ }
184
+
185
+ function promoActive(promo, atMs) {
186
+ return atMs <= Date.parse(`${promo.through}T23:59:59.999Z`);
187
+ }
188
+
189
+ /**
190
+ * Price per MTok for a model at a point in time.
191
+ *
192
+ * `at` is the moment the prompt RAN, not the moment the report runs: a prompt
193
+ * from July priced in September must still get July's price, or re-running the
194
+ * same report over the same transcripts would quietly produce different money.
195
+ *
196
+ * @returns {{rate: {input:number, output:number}|null, reason: string|null}}
197
+ */
198
+ export function rateFor(model, { speed = null, at = null } = {}) {
199
+ const entry = lookup(PRICES.models, normalizeModel(model));
200
+ if (!entry) return { rate: null, reason: 'unknown-model' };
201
+
202
+ let active = entry;
203
+ if (entry.promo) {
204
+ // Only date-dependent models need a date, so an undated row is refused
205
+ // exactly where the date would have changed the answer.
206
+ if (at === null || Number.isNaN(at)) return { rate: null, reason: 'unknown-date' };
207
+ if (promoActive(entry.promo, at)) active = entry.promo;
208
+ }
209
+
210
+ // Absent speed = standard, same reading as the tier and geo fields; an
211
+ // unrecognized speed (including `mixed`) is refused rather than guessed,
212
+ // because on an Opus model that guess is a factor of two.
213
+ const s = speed ?? 'standard';
214
+ const rate = s === 'standard' ? active : active.speeds && lookup(active.speeds, s);
215
+ if (!rate) return { rate: null, reason: 'unknown-speed' };
216
+ return { rate: { input: rate.input, output: rate.output }, reason: null };
217
+ }
218
+
219
+ /**
220
+ * Dollars for one token bucket, split by class. Never returns a single number
221
+ * on its own — EPIC-009 criterion 2 forbids a cost figure without its
222
+ * breakdown, and the cheapest way to honour that is to not produce one.
223
+ *
224
+ * Multipliers stack, as the published table says they do: cache class x
225
+ * service tier x inference geography.
226
+ */
227
+ export function costOfTokens(tokens, rate, multiplier = 1) {
228
+ const perInput = (rate.input / 1e6) * multiplier;
229
+ const perOutput = (rate.output / 1e6) * multiplier;
230
+ return {
231
+ input: (tokens.input_tokens || 0) * perInput,
232
+ cache_read: (tokens.cache_read || 0) * perInput * CACHE_MULTIPLIERS.read,
233
+ cache_write_5m: (tokens.cache_write_5m || 0) * perInput * CACHE_MULTIPLIERS.write_5m,
234
+ cache_write_1h: (tokens.cache_write_1h || 0) * perInput * CACHE_MULTIPLIERS.write_1h,
235
+ output: (tokens.output_tokens || 0) * perOutput,
236
+ };
237
+ }
238
+
239
+ function bucketIsEmpty(t) {
240
+ return (
241
+ !(t.input_tokens > 0) &&
242
+ !(t.cache_read > 0) &&
243
+ !(t.cache_write_5m > 0) &&
244
+ !(t.cache_write_1h > 0) &&
245
+ !(t.cache_write_unsplit > 0) &&
246
+ !(t.output_tokens > 0)
247
+ );
248
+ }
249
+
250
+ /**
251
+ * Price one prompt-metrics row, at API list price.
252
+ *
253
+ * Priced per entry of `by_model`, not off the row's flat totals: a turn that
254
+ * switched models (measured — opus and sonnet inside one session) would
255
+ * otherwise be charged at one model's rate, and the opus/sonnet gap is 1.7x,
256
+ * larger than any effect this epic is looking for (error class 4).
257
+ *
258
+ * `speed`, `service_tier` and `inference_geo` stay row-level, so a turn that
259
+ * mixed them is refused rather than split on an assumption.
260
+ *
261
+ * A row with no billable tokens costs $0 and is NOT unpriced — a prompt with no
262
+ * API turn is free, not unknown, and must not dilute the counter that means
263
+ * "our table has a gap".
264
+ */
265
+ export function priceRow(row, { at } = {}) {
266
+ const buckets = Object.entries(row?.by_model ?? {}).filter(([, t]) => !bucketIsEmpty(t));
267
+ if (buckets.length === 0) return priced(emptyCost());
268
+
269
+ const tier = lookup(TIER_MULTIPLIERS, row.service_tier ?? 'standard');
270
+ if (tier === undefined) return unpriced('unknown-tier');
271
+ const geo = lookup(GEO_MULTIPLIERS, row.inference_geo ?? 'global');
272
+ if (geo === undefined) return unpriced('unknown-geo');
273
+
274
+ const when = at !== undefined ? at : Date.parse(row.ts_submit ?? row.ts_end ?? '');
275
+ const total = emptyCost();
276
+ let refusal = null;
277
+ const offenders = [];
278
+ // Every bucket is examined even after the first refusal: the row is still
279
+ // refused whole, but the report can then name ALL the models it could not
280
+ // price instead of only the alphabetically first one.
281
+ for (const [model, tokens] of buckets) {
282
+ // The field exists precisely so a TTL is never guessed: 1.25x vs 2x is up
283
+ // to 60% of the write line. Zero on every entry measured to date. The model
284
+ // is not at fault here, so it is not named as an offender.
285
+ if (tokens.cache_write_unsplit > 0) {
286
+ refusal ??= 'cache-write-unsplit';
287
+ continue;
288
+ }
289
+ const { rate, reason } = rateFor(model, { speed: row.speed ?? null, at: when });
290
+ if (rate === null) {
291
+ refusal ??= reason;
292
+ offenders.push(model);
293
+ continue;
294
+ }
295
+ const part = costOfTokens(tokens, rate, tier * geo);
296
+ for (const c of TOKEN_CLASSES) total[c] += part[c];
297
+ }
298
+ if (refusal !== null) return unpriced(refusal, offenders);
299
+ return priced(total);
300
+ }
301
+
302
+ /**
303
+ * Server-side tools are billed per REQUEST, not per token, so they sit outside
304
+ * list_cost_by_class and its total — folding them in would break the rule that
305
+ * the total equals its own token breakdown. Reported alongside, additive.
306
+ *
307
+ * Web fetch is free; only web search is billed. Counting both as billable was
308
+ * an error in the first cut of this module.
309
+ */
310
+ export function webSearchCost(requests) {
311
+ return round6((requests || 0) * WEB_SEARCH_USD);
312
+ }
313
+
314
+ /**
315
+ * The breakdown of a row that really is priced, or **null**.
316
+ *
317
+ * Keyed off the absence of a usable price, not off the reason: a row with no
318
+ * list_cost_usd is unpriced even if it forgot to say why, and the alternative
319
+ * reading — treating it as $0 — is the fabricated saving this file exists to
320
+ * prevent. A total or class that is not a finite number fails the same way and
321
+ * is refused for the same reason: it would be added to the run total, which is
322
+ * how one bad row makes every other row's money disappear into NaN.
323
+ */
324
+ function pricedClasses(row) {
325
+ const classes = row.list_cost_by_class;
326
+ if (!Number.isFinite(row.list_cost_usd) || !classes) return null;
327
+ return TOKEN_CLASSES.every((c) => Number.isFinite(classes[c])) ? classes : null;
328
+ }
329
+
330
+ /**
331
+ * Roll priced rows up. Keeps the unpriced ones visible BY MODEL: the whole
332
+ * point of the counter is to name the gap in the table, and "318 unpriced" says
333
+ * far less than "318 unpriced: claude-opus-4-8".
334
+ */
335
+ export function summarizeCost(rows) {
336
+ const cost = emptyCost();
337
+ // Null-prototype: the keys are model ids and reasons, i.e. strings from the
338
+ // transcript. On a plain object a model called `constructor` would read back
339
+ // as a function, and `fn ?? 0` then counts it as `fn1`.
340
+ const unpricedRows = { rows: 0, reasons: Object.create(null), models: Object.create(null) };
341
+ let pricedRows = 0;
342
+ let searches = 0;
343
+ for (const row of rows) {
344
+ searches += row.web_search_requests ?? 0;
345
+ const classes = pricedClasses(row);
346
+ if (classes === null) {
347
+ unpricedRows.rows++;
348
+ const r = row.unpriced_reason ?? 'unknown';
349
+ unpricedRows.reasons[r] = (unpricedRows.reasons[r] ?? 0) + 1;
350
+ for (const m of row.unpriced_models ?? []) unpricedRows.models[m] = (unpricedRows.models[m] ?? 0) + 1;
351
+ continue;
352
+ }
353
+ pricedRows++;
354
+ for (const c of TOKEN_CLASSES) cost[c] += classes[c];
355
+ }
356
+ // Same rule as one row: the run total is the fold of the published classes,
357
+ // never a parallel sum that could disagree with them.
358
+ const { list_cost_usd, list_cost_by_class } = priced(cost);
359
+ return {
360
+ list_cost_usd,
361
+ list_cost_by_class,
362
+ web_search_requests: searches,
363
+ web_search_cost_usd: webSearchCost(searches),
364
+ priced_rows: pricedRows,
365
+ total_rows: pricedRows + unpricedRows.rows,
366
+ unpriced: unpricedRows,
367
+ };
368
+ }
369
+
370
+ function usd(n) {
371
+ return `$${n.toFixed(4)}`;
372
+ }
373
+
374
+ function count(n) {
375
+ return n.toLocaleString('en-US');
376
+ }
377
+
378
+ /**
379
+ * Cost lines for a run summary. Always emits the per-class split next to the
380
+ * total — the one formatting rule EPIC-009 states outright (criterion 2), kept
381
+ * here rather than at the call site so it is testable on its own. And always
382
+ * says what the figure IS: a subscription user who reads it as their bill has
383
+ * been misled by us, not by the number.
384
+ */
385
+ export function formatCostLines(summary, tokens) {
386
+ const L = [];
387
+ if (tokens) {
388
+ // Labelled "all rows" because the cost line below covers only the priced
389
+ // ones: two figures side by side that quietly span different populations
390
+ // invite exactly the wrong division.
391
+ L.push(
392
+ ` tokens (all rows): input ${count(tokens.input_tokens)} | cache-read ${count(tokens.cache_read)}` +
393
+ ` | cache-write-5m ${count(tokens.cache_write_5m)} | cache-write-1h ${count(tokens.cache_write_1h)}` +
394
+ ` | output ${count(tokens.output_tokens)}` +
395
+ // Otherwise the classes would not add up to the tokens actually seen,
396
+ // and a breakdown that silently omits a class is not a breakdown.
397
+ (tokens.cache_write_unsplit > 0 ? ` | cache-write-UNSPLIT ${count(tokens.cache_write_unsplit)}` : ''),
398
+ );
399
+ }
400
+ const c = summary.list_cost_by_class;
401
+ L.push(
402
+ ` API list price: ${usd(summary.list_cost_usd)} over ${summary.priced_rows} of ${summary.total_rows} row(s)` +
403
+ ` = input ${usd(c.input)} + cache-read ${usd(c.cache_read)} + cache-write-5m ${usd(c.cache_write_5m)}` +
404
+ ` + cache-write-1h ${usd(c.cache_write_1h)} + output ${usd(c.output)}`,
405
+ );
406
+ L.push(
407
+ ' ^ what these tokens WOULD cost via the API. NOT a bill: a Pro/Max plan' +
408
+ ' charges its flat fee, not per token. Use it to compare arms, not to reconcile a statement.',
409
+ );
410
+ if (summary.web_search_requests > 0) {
411
+ L.push(
412
+ ` web searches: ${summary.web_search_requests} = ${usd(summary.web_search_cost_usd)}` +
413
+ ' — billed per request, additive to the figure above (web fetch is free)',
414
+ );
415
+ }
416
+ if (summary.unpriced.rows > 0) {
417
+ const reasons = Object.entries(summary.unpriced.reasons)
418
+ .sort((a, b) => b[1] - a[1])
419
+ .map(([r, n]) => `${r} ${n}`)
420
+ .join(', ');
421
+ const models = Object.entries(summary.unpriced.models)
422
+ .sort((a, b) => b[1] - a[1])
423
+ .map(([m, n]) => `${m} ${n}`)
424
+ .join(', ');
425
+ L.push(` UNPRICED rows: ${summary.unpriced.rows} (${reasons}) — their cost is unknown, NOT zero`);
426
+ if (models) L.push(` models seen on unpriced rows: ${models}`);
427
+ L.push(` price table: ${PRICES.source} (retrieved ${PRICES.retrieved})`);
428
+ }
429
+ return L;
430
+ }