From a575d9591e5ae10878b85acd57d11d4472da5a65 Mon Sep 17 00:00:00 2001 From: Junyong Lee Date: Wed, 23 Sep 2026 02:09:40 -0400 Subject: [PATCH 1/9] Price and classify claude-opus-5-5 (Claude Opus 5.5) - tools/rates.json: add the wire id, ordered before claude-opus-5 so a future dated 5.5 snapshot resolves via cost-report's prefix match rather than falling through to Opus 5's rate. - tools/update-rates.mjs: map the display name, require the wire id on every refresh, accept the pricing page's footnote marker on the cache-read cell, and validate that cell against its documented 0.05x discount instead of the standard 0.1x (a per-model override, not a loosened guard for every other row). - proxy/model-families.mjs: classify bare claude-opus-5 and claude-sonnet-5 ids (and by prefix, claude-opus-5-5) so served-model divergence detection stops reporting them unknown. - Tests: the fixture gains the live Opus 5.5 row: cost-report priced a 1M/1M/1M call at $30.5 (Opus 5's rate) before this change, $24.2 after. Co-Authored-By: Claude --- CHANGELOG.md | 2 ++ proxy/model-families.mjs | 2 ++ test/cost-report.test.mjs | 31 ++++++++++++++++++++++ test/fixtures/pricing-page-2026-07-27.html | 2 +- test/model-families.test.mjs | 6 +++++ test/tools-update-rates.test.mjs | 21 +++++++++++++-- tools/rates.json | 7 +++++ tools/update-rates.mjs | 21 ++++++++++++--- 8 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 test/cost-report.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c56f87ad..e1999816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- **`claude-opus-5-5` is now priced, tracked for divergence, and no longer billed as `claude-opus-5`.** The model tables (`tools/rates.json`, `tools/update-rates.mjs`, `proxy/model-families.mjs`) had no entry for it, so `tools/cost-report.mjs` fell back to its longest-matching neighbor — `claude-opus-5`'s rates, 25% too high on input/output and off by more than 2x on the cache-read discount the pricing page documents for this model — and `cache-telemetry.mjs`'s served-model divergence check classified it as `"unknown"` rather than `"opus"`. + - **A refused fd-3 handover no longer makes the proxy claim it handed the socket on.** `inheritedSocket` was computed from "handover was attempted", not "handover succeeded", so a proxy that was refused fd 3 and fell back to binding its own port still advertised an inherited socket. On `SIGTERM` it then spawned a successor pointed at the same unservable descriptor and exited `75` — telling the supervisor a successor holds the socket — while the port it actually served was released with nobody on it. Exits `0` now, spawns nothing, and leaves no orphan. - **A dead log reader no longer kills the supervisor or the relay.** The proxy gained an EPIPE swallower after a measured 27-minute outage; the launcher's holder and the gap relay share the same pipe and never got one, so the same killed reader took down the process whose whole job is to put the proxy back. Both now install stream `'error'` listeners, as does the proxy in reverse mode, where its own guard had been attached only when forward mode was active. The interactive wrapper deliberately keeps the old behaviour — a foreground producer whose consumer dies should end. diff --git a/proxy/model-families.mjs b/proxy/model-families.mjs index 64c87f19..45a67f7a 100644 --- a/proxy/model-families.mjs +++ b/proxy/model-families.mjs @@ -18,8 +18,10 @@ export const MODEL_FAMILIES = [ { root: "mythos", family: "mythos" }, { root: "claude-opus-4-7", family: "opus" }, { root: "claude-opus-4-8", family: "opus" }, + { root: "claude-opus-5", family: "opus" }, { root: "claude-sonnet-4-6", family: "sonnet" }, { root: "claude-sonnet-4-7", family: "sonnet" }, + { root: "claude-sonnet-5", family: "sonnet" }, { root: "claude-haiku-4-5", family: "haiku" }, ]; diff --git a/test/cost-report.test.mjs b/test/cost-report.test.mjs new file mode 100644 index 00000000..879f70b4 --- /dev/null +++ b/test/cost-report.test.mjs @@ -0,0 +1,31 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// cost-report.mjs runs main() at import and does not export lookupRates(), so +// the only way to exercise its rates lookup end-to-end is to run it as a +// subprocess with usage telemetry piped on stdin. + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = join(__dirname, "..", "tools", "cost-report.mjs"); + +test("prices a claude-opus-5-5 call from the bundled rates.json", () => { + const usage = JSON.stringify({ + model: "claude-opus-5-5", + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cache_read_input_tokens: 1_000_000, + }) + "\n"; + + const result = spawnSync(process.execPath, [SCRIPT, "--format", "json"], { + input: usage, + encoding: "utf8", + }); + + assert.equal(result.status, 0, `cost-report exited ${result.status}: ${result.stderr}`); + const report = JSON.parse(result.stdout); + assert.equal(report.calls[0].cost, 24.2, + "1M input + 1M output + 1M cache-read at Opus 5.5's own rates (4/20/0.2 $ per MTok)"); +}); diff --git a/test/fixtures/pricing-page-2026-07-27.html b/test/fixtures/pricing-page-2026-07-27.html index e32c54e0..eb0f392a 100644 --- a/test/fixtures/pricing-page-2026-07-27.html +++ b/test/fixtures/pricing-page-2026-07-27.html @@ -1,4 +1,4 @@ -
ModelBase Input Tokens5m Cache Writes1h Cache WritesCache Hits & RefreshesOutput Tokens
Claude Fable 5$10 / MTok$12.50 / MTok$20 / MTok$1 / MTok$50 / MTok
Claude Mythos 5 (limited availability)$10 / MTok$12.50 / MTok$20 / MTok$1 / MTok$50 / MTok
Claude Opus 5$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.8$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.7$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.6$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.5$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.1 (deprecated)$15 / MTok$18.75 / MTok$30 / MTok$1.50 / MTok$75 / MTok
Claude Opus 4 (retired, except on Google Cloud)$15 / MTok$18.75 / MTok$30 / MTok$1.50 / MTok$75 / MTok
Claude Sonnet 5
through August 31, 2026
$2 / MTok$2.50 / MTok$4 / MTok$0.20 / MTok$10 / MTok
Claude Sonnet 5
starting September 1, 2026
$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Sonnet 4.6$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Sonnet 4.5$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Sonnet 4 (retired, except on Bedrock and Google Cloud)$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Haiku 4.5$1 / MTok$1.25 / MTok$2 / MTok$0.10 / MTok$5 / MTok
Claude Haiku 3.5 (retired, except on Bedrock and Google Cloud)$0.80 / MTok$1 / MTok$1.60 / MTok$0.08 / MTok$4 / MTok
+
ModelBase Input Tokens5m Cache Writes1h Cache WritesCache Hits & RefreshesOutput Tokens
Claude Fable 5$10 / MTok$12.50 / MTok$20 / MTok$1 / MTok$50 / MTok
Claude Mythos 5 (limited availability)$10 / MTok$12.50 / MTok$20 / MTok$1 / MTok$50 / MTok
Claude Opus 5$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 5.5$4 / MTok$5 / MTok$8 / MTok$0.20 / MTok2$20 / MTok
Claude Opus 4.8$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.7$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.6$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.5$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.1 (deprecated)$15 / MTok$18.75 / MTok$30 / MTok$1.50 / MTok$75 / MTok
Claude Opus 4 (retired, except on Google Cloud)$15 / MTok$18.75 / MTok$30 / MTok$1.50 / MTok$75 / MTok
Claude Sonnet 5
through August 31, 2026
$2 / MTok$2.50 / MTok$4 / MTok$0.20 / MTok$10 / MTok
Claude Sonnet 5
starting September 1, 2026
$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Sonnet 4.6$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Sonnet 4.5$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Sonnet 4 (retired, except on Bedrock and Google Cloud)$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Haiku 4.5$1 / MTok$1.25 / MTok$2 / MTok$0.10 / MTok$5 / MTok
Claude Haiku 3.5 (retired, except on Bedrock and Google Cloud)$0.80 / MTok$1 / MTok$1.60 / MTok$0.08 / MTok$4 / MTok
ConceptDetails
Billing unitClaude Consumption Unit (CCU)
CCU price$0.01 per CCU (fixed; discounts apply at token-to-CCU conversion, not to the CCU price)
ConversionToken usage rated in USD at standard per-model, per-feature rates (same as Claude API pricing), then converted to CCUs at $0.01 per CCU
Billing cadenceHourly metering to AWS Marketplace; monthly invoices
Payment modelArrears only (postpaid); no prepaid credits
DiscountsApplied as fewer CCUs metered
TaxPre-tax metering; AWS Marketplace handles tax
Cost visibilityReal-time breakdown in the Claude Console (access through the AWS Console); AWS Cost Explorer shows aggregated CCU
ConceptDetails
Billing unitClaude Consumption Unit (CCU)
CCU price$0.01 per CCU (fixed; discounts apply at token-to-CCU conversion, not to the CCU price)
ConversionToken usage rated in USD at standard per-model, per-feature rates (same as Claude API pricing), then converted to CCUs at $0.01 per CCU
Billing cadenceHourly metering to the Azure Marketplace; monthly invoices
Payment modelArrears only (postpaid); no prepaid credits
DiscountsApplied as fewer CCUs metered
TaxPre-tax metering; Azure Marketplace handles tax
Cost visibilityAzure Cost Management shows aggregated CCU
Cache operationMultiplierDuration
5-minute cache write1.25x base input priceCache valid for 5 minutes
1-hour cache write2x base input priceCache valid for 1 hour
Cache read (hit)0.1x base input priceSame duration as the preceding write
diff --git a/test/model-families.test.mjs b/test/model-families.test.mjs index 8f6ceaa9..4c26f26b 100644 --- a/test/model-families.test.mjs +++ b/test/model-families.test.mjs @@ -47,6 +47,12 @@ test("modelFamily: returns 'unknown' for unmatched / empty / non-string", () => assert.equal(modelFamily(123), "unknown"); }); +test("modelFamily: bare Opus 5 and Sonnet 5 ids, and the 5.5 point release, classify correctly", () => { + assert.equal(modelFamily("claude-opus-5"), "opus"); + assert.equal(modelFamily("claude-opus-5-5"), "opus"); + assert.equal(modelFamily("claude-sonnet-5"), "sonnet"); +}); + // --- Back-compat: cache-telemetry re-exports modelFamily --- test("back-compat: `modelFamily` is also exported from cache-telemetry.mjs (one indirection)", async () => { diff --git a/test/tools-update-rates.test.mjs b/test/tools-update-rates.test.mjs index d7b7796f..344b11ad 100644 --- a/test/tools-update-rates.test.mjs +++ b/test/tools-update-rates.test.mjs @@ -37,6 +37,14 @@ test("prices the models live traffic actually uses", () => { "alias and dated snapshot must price identically"); }); +test("prices Claude Opus 5.5, footnoted cache-read discount included", () => { + // The pricing page marks Opus 5.5's cache-read cell with a footnote + // (2, "0.05x on Claude Opus 5.5") instead of the usual 0.1x. + const { rates } = parsePricing(FIXTURE, AUG); + assert.deepEqual(rates["claude-opus-5-5"], + { input: 4, output: 20, cache_read: 0.2, cache_write_5m: 5, cache_write_1h: 8 }); +}); + // --- dated-variant disambiguation (Codex r1 blocker 1) --- test("Claude Sonnet 5 resolves to the window in effect on the fetch date", () => { @@ -113,7 +121,7 @@ test("a partial parse aborts instead of writing a plausible-looking file", () => test("markup with no parseable rows at all reports every required model missing", () => { const { rates, errors } = parsePricing("

pricing moved

", AUG); assert.deepEqual(rates, {}); - assert.equal(errors.length, 7, "one error per required wire id"); + assert.equal(errors.length, 8, "one error per required wire id"); }); // --- silent-corruption guards --- @@ -136,6 +144,15 @@ test("cache prices that contradict the documented multipliers are rejected", () assert.ok(errors.some((e) => /contradicts the documented/.test(e)), JSON.stringify(errors)); }); +test("the Opus 5.5 cache-read override does not loosen the guard for other models", () => { + // Claude Fable 5 at the 0.05x rate Opus 5.5 uses would be $0.20/MTok, not the + // documented 0.1x ($0.40/MTok) — must still be rejected as a misparse. + const html = `
Claude Fable 5$4 / MTok$5 / MTok$8 / MTok$0.20 / MTok$20 / MTok
`; + const { rates, errors } = parsePricing(html, AUG); + assert.equal(rates["claude-fable-5"], undefined); + assert.ok(errors.some((e) => /contradicts the documented/.test(e)), JSON.stringify(errors)); +}); + test("published cent-rounding still passes the multiplier check", () => { // Haiku 3.5: input $0.80 → 1.25x = $1.00, 2x = $1.60, 0.1x = $0.08. Exact // here, but the epsilon must be wide enough that rounded rows aren't rejected. @@ -182,7 +199,7 @@ test("parsing is deterministic for a fixed input and date", () => { test("checked-in rates.json agrees with the fixture parse for required models", () => { const shipped = JSON.parse(readFileSync(join(__dirname, "..", "tools", "rates.json"), "utf8")).models; const { rates } = parsePricing(FIXTURE, Date.parse("2026-07-27T12:00:00Z")); - for (const id of ["claude-fable-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", + for (const id of ["claude-fable-5", "claude-opus-5", "claude-opus-5-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-5", "claude-haiku-4-5"]) { assert.ok(shipped[id], `rates.json is missing ${id} — the cost lever prices it at zero`); const { note, ...s } = shipped[id]; diff --git a/tools/rates.json b/tools/rates.json index fde65704..6ba76150 100644 --- a/tools/rates.json +++ b/tools/rates.json @@ -129,6 +129,13 @@ "cache_write_5m": 1.25, "cache_write_1h": 2 }, + "claude-opus-5-5": { + "input": 4, + "output": 20, + "cache_read": 0.2, + "cache_write_5m": 5, + "cache_write_1h": 8 + }, "claude-opus-5": { "input": 5, "output": 25, diff --git a/tools/update-rates.mjs b/tools/update-rates.mjs index a5693aa5..2a64f371 100644 --- a/tools/update-rates.mjs +++ b/tools/update-rates.mjs @@ -44,6 +44,7 @@ const MAX_BODY_BYTES = 8 * 1024 * 1024; const NAME_TO_WIRE = { "Claude Fable 5": ["claude-fable-5"], "Claude Mythos 5": ["claude-mythos-5"], + "Claude Opus 5.5": ["claude-opus-5-5"], "Claude Opus 5": ["claude-opus-5"], "Claude Opus 4.8": ["claude-opus-4-8"], "Claude Opus 4.7": ["claude-opus-4-7"], @@ -77,6 +78,7 @@ const NAME_TO_WIRE = { const REQUIRED_WIRE_IDS = [ "claude-fable-5", "claude-opus-5", + "claude-opus-5-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", @@ -101,6 +103,13 @@ const MULTIPLIERS = { cache_write_5m: 1.25, cache_write_1h: 2, cache_read: 0.1 } // reject legitimate rows. Allow a cent of slack either way. const MULTIPLIER_EPSILON = 0.011; +// Per-model cache_read multiplier overrides, for the rare row that documents a +// different discount than the standard 0.1x (Claude Opus 5.5 prices cache +// reads at 0.05x input, per the pricing page's footnote). Only the named field +// on the named wire id is affected; every other row and every other field +// still goes through the standard MULTIPLIERS check above. +const CACHE_READ_MULTIPLIER_OVERRIDES = { "claude-opus-5-5": 0.05 }; + const MONTHS = { january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, july: 6, august: 7, september: 8, october: 9, november: 10, december: 11, @@ -163,7 +172,10 @@ function tableRows(html) { return rows; } -const PRICE_CELL = /^\$([0-9][0-9,]*(?:\.[0-9]+)?)\s*\/\s*MTok$/; +// A trailing footnote marker (e.g. "$0.20 / MTok 2" once the 2 tag +// is stripped to plain text by tableRows()) is part of the page's own markup, +// not a second price — allow and ignore it. +const PRICE_CELL = /^\$([0-9][0-9,]*(?:\.[0-9]+)?)\s*\/\s*MTok(?:\s*\d+)?$/; // A model-pricing row is exactly: name cell + the five price columns // (input | 5m write | 1h write | cache read | output). This shape requirement is @@ -199,9 +211,12 @@ function validateRates(wireId, r) { if (v < MIN_PRICE || v > MAX_PRICE) return `${wireId}.${field}=${v} outside sane range $${MIN_PRICE}–$${MAX_PRICE}`; } for (const [field, mult] of Object.entries(MULTIPLIERS)) { - const expected = r.input * mult; + const effectiveMult = field === "cache_read" && wireId in CACHE_READ_MULTIPLIER_OVERRIDES + ? CACHE_READ_MULTIPLIER_OVERRIDES[wireId] + : mult; + const expected = r.input * effectiveMult; if (Math.abs(r[field] - expected) > MULTIPLIER_EPSILON) { - return `${wireId}.${field}=${r[field]} contradicts the documented ${mult}x input multiplier ` + + return `${wireId}.${field}=${r[field]} contradicts the documented ${effectiveMult}x input multiplier ` + `(input=${r.input} → expected ${expected.toFixed(4)})`; } } From 10f2f78e484f221a833c31edb29a192f3ccccb0f Mon Sep 17 00:00:00 2001 From: Junyong Lee Date: Wed, 23 Sep 2026 02:18:53 -0400 Subject: [PATCH 2/9] fixup(T0988): correctness+minor review findings on opus-5-5 model tables - CHANGELOG.md: replace the false "fell back to its longest-matching neighbor" with what lookupRates() actually does (first rates.json key in file order where either id prefixes the other); fold in the model_divergence_sticky latch-timing fix (first divergent turn, not third) as the same bullet. - tools/update-rates.mjs PRICE_CELL: narrow the footnote tolerance to exactly one space + 1-2 digits, matching what tableRows() actually leaves behind, so junk like "$4 / MTok2026" fails closed. - test/tools-update-rates.test.mjs: red-first case for the narrowed regex, and note the Opus 5.5 fixture row was copied in from the later live page. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- test/tools-update-rates.test.mjs | 14 +++++++++++++- tools/update-rates.mjs | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1999816..307d6d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- **`claude-opus-5-5` is now priced, tracked for divergence, and no longer billed as `claude-opus-5`.** The model tables (`tools/rates.json`, `tools/update-rates.mjs`, `proxy/model-families.mjs`) had no entry for it, so `tools/cost-report.mjs` fell back to its longest-matching neighbor — `claude-opus-5`'s rates, 25% too high on input/output and off by more than 2x on the cache-read discount the pricing page documents for this model — and `cache-telemetry.mjs`'s served-model divergence check classified it as `"unknown"` rather than `"opus"`. +- **`claude-opus-5-5` is now priced, tracked for divergence, and no longer billed as `claude-opus-5`.** The model tables (`tools/rates.json`, `tools/update-rates.mjs`, `proxy/model-families.mjs`) had no entry for it, so `tools/cost-report.mjs`'s `lookupRates()` matched the first `rates.json` key in file order where either id prefixed the other — `claude-opus-5`'s rates, 25% too high on input/output and off by more than 2x on the cache-read discount the pricing page documents for this model — and bare `claude-opus-5` / `claude-sonnet-5` read as `"unknown"` in `proxy/model-families.mjs`, so a request served by a different family only latched `model_divergence_sticky` on the third divergent turn instead of the first. - **A refused fd-3 handover no longer makes the proxy claim it handed the socket on.** `inheritedSocket` was computed from "handover was attempted", not "handover succeeded", so a proxy that was refused fd 3 and fell back to binding its own port still advertised an inherited socket. On `SIGTERM` it then spawned a successor pointed at the same unservable descriptor and exited `75` — telling the supervisor a successor holds the socket — while the port it actually served was released with nobody on it. Exits `0` now, spawns nothing, and leaves no orphan. diff --git a/test/tools-update-rates.test.mjs b/test/tools-update-rates.test.mjs index 344b11ad..603aab3d 100644 --- a/test/tools-update-rates.test.mjs +++ b/test/tools-update-rates.test.mjs @@ -9,7 +9,8 @@ import { parsePricing } from "../tools/update-rates.mjs"; // direct-API-key users is a literal dollar ceiling. The failure mode that // matters is a plausible-looking WRONG number, so these tests are mostly about // what the parser REFUSES to emit. Fixture is the blocks of the real -// pricing page as of 2026-07-27. +// pricing page as of 2026-07-27, with the Claude Opus 5.5 row copied in from +// the later live page — it had not shipped on 2026-07-27. const __dirname = dirname(fileURLToPath(import.meta.url)); const FIXTURE = readFileSync(join(__dirname, "fixtures", "pricing-page-2026-07-27.html"), "utf8"); @@ -153,6 +154,17 @@ test("the Opus 5.5 cache-read override does not loosen the guard for other model assert.ok(errors.some((e) => /contradicts the documented/.test(e)), JSON.stringify(errors)); }); +test("a footnote-shaped suffix with no space or too many digits fails closed", () => { + // tableRows() always leaves exactly one literal space where a stripped tag + // was, so a real footnote marker reads "$4 / MTok 2" (one space, 1-2 + // digits). "$4 / MTok2026" has no such space — not a footnote, a misparse — + // and must be dropped rather than read as a $4 price. + const html = `
Claude Fable 5$4 / MTok2026$5 / MTok$8 / MTok$0.40 / MTok$20 / MTok
`; + const { rates, errors } = parsePricing(html, AUG); + assert.equal(rates["claude-fable-5"], undefined); + assert.ok(errors.some((e) => /required model "claude-fable-5" missing/.test(e)), JSON.stringify(errors)); +}); + test("published cent-rounding still passes the multiplier check", () => { // Haiku 3.5: input $0.80 → 1.25x = $1.00, 2x = $1.60, 0.1x = $0.08. Exact // here, but the epsilon must be wide enough that rounded rows aren't rejected. diff --git a/tools/update-rates.mjs b/tools/update-rates.mjs index 2a64f371..36b13096 100644 --- a/tools/update-rates.mjs +++ b/tools/update-rates.mjs @@ -175,7 +175,7 @@ function tableRows(html) { // A trailing footnote marker (e.g. "$0.20 / MTok 2" once the 2 tag // is stripped to plain text by tableRows()) is part of the page's own markup, // not a second price — allow and ignore it. -const PRICE_CELL = /^\$([0-9][0-9,]*(?:\.[0-9]+)?)\s*\/\s*MTok(?:\s*\d+)?$/; +const PRICE_CELL = /^\$([0-9][0-9,]*(?:\.[0-9]+)?)\s*\/\s*MTok(?:\s\d{1,2})?$/; // A model-pricing row is exactly: name cell + the five price columns // (input | 5m write | 1h write | cache read | output). This shape requirement is From 5a6f01ecc18f9668db8a82ca5a908723c9983d59 Mon Sep 17 00:00:00 2001 From: Junyong Lee Date: Mon, 28 Sep 2026 17:04:03 -0400 Subject: [PATCH 3/9] Price and classify claude-sonnet-5-5 (Claude Sonnet 5.5) Claude Code 2.1.284's default Sonnet is claude-sonnet-5-5. update-rates never wrote it (no NAME_TO_WIRE / REQUIRED_WIRE_IDS entry), rates.json had no row so the session-budget-breaker's exact-id lookup counted no cost for it, and the tool-addition probe still named claude-sonnet-5. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- test/fixtures/pricing-page-2026-07-27.html | 2 +- test/model-families.test.mjs | 1 + test/proxy-session-budget-breaker.test.mjs | 8 ++++++++ test/tools-update-rates.test.mjs | 17 +++++++++++++---- tools/probe-tool-addition.mjs | 2 +- tools/rates.json | 7 +++++++ tools/update-rates.mjs | 2 ++ 8 files changed, 34 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 307d6d01..3cdf1b54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- **`claude-opus-5-5` is now priced, tracked for divergence, and no longer billed as `claude-opus-5`.** The model tables (`tools/rates.json`, `tools/update-rates.mjs`, `proxy/model-families.mjs`) had no entry for it, so `tools/cost-report.mjs`'s `lookupRates()` matched the first `rates.json` key in file order where either id prefixed the other — `claude-opus-5`'s rates, 25% too high on input/output and off by more than 2x on the cache-read discount the pricing page documents for this model — and bare `claude-opus-5` / `claude-sonnet-5` read as `"unknown"` in `proxy/model-families.mjs`, so a request served by a different family only latched `model_divergence_sticky` on the third divergent turn instead of the first. +- **`claude-opus-5-5` is now priced, tracked for divergence, and no longer billed as `claude-opus-5`; `claude-sonnet-5-5` is now priced too.** The model tables (`tools/rates.json`, `tools/update-rates.mjs`, `proxy/model-families.mjs`) had no entry for it, so `tools/cost-report.mjs`'s `lookupRates()` matched the first `rates.json` key in file order where either id prefixed the other — `claude-opus-5`'s rates, 25% too high on input/output and off by more than 2x on the cache-read discount the pricing page documents for this model — and bare `claude-opus-5` / `claude-sonnet-5` read as `"unknown"` in `proxy/model-families.mjs`, so a request served by a different family only latched `model_divergence_sticky` on the third divergent turn instead of the first. `claude-sonnet-5-5` (Claude Code's default Sonnet since 2.1.284) had no row in `tools/rates.json` or `tools/update-rates.mjs` either, so a refresh never wrote it and the session-budget-breaker's exact-id lookup counted no cost for it; both carry it now, and `tools/probe-tool-addition.mjs` probes it in place of `claude-sonnet-5`. - **A refused fd-3 handover no longer makes the proxy claim it handed the socket on.** `inheritedSocket` was computed from "handover was attempted", not "handover succeeded", so a proxy that was refused fd 3 and fell back to binding its own port still advertised an inherited socket. On `SIGTERM` it then spawned a successor pointed at the same unservable descriptor and exited `75` — telling the supervisor a successor holds the socket — while the port it actually served was released with nobody on it. Exits `0` now, spawns nothing, and leaves no orphan. diff --git a/test/fixtures/pricing-page-2026-07-27.html b/test/fixtures/pricing-page-2026-07-27.html index eb0f392a..8d847b48 100644 --- a/test/fixtures/pricing-page-2026-07-27.html +++ b/test/fixtures/pricing-page-2026-07-27.html @@ -1,4 +1,4 @@ -
ModelBase Input Tokens5m Cache Writes1h Cache WritesCache Hits & RefreshesOutput Tokens
Claude Fable 5$10 / MTok$12.50 / MTok$20 / MTok$1 / MTok$50 / MTok
Claude Mythos 5 (limited availability)$10 / MTok$12.50 / MTok$20 / MTok$1 / MTok$50 / MTok
Claude Opus 5$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 5.5$4 / MTok$5 / MTok$8 / MTok$0.20 / MTok2$20 / MTok
Claude Opus 4.8$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.7$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.6$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.5$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.1 (deprecated)$15 / MTok$18.75 / MTok$30 / MTok$1.50 / MTok$75 / MTok
Claude Opus 4 (retired, except on Google Cloud)$15 / MTok$18.75 / MTok$30 / MTok$1.50 / MTok$75 / MTok
Claude Sonnet 5
through August 31, 2026
$2 / MTok$2.50 / MTok$4 / MTok$0.20 / MTok$10 / MTok
Claude Sonnet 5
starting September 1, 2026
$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Sonnet 4.6$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Sonnet 4.5$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Sonnet 4 (retired, except on Bedrock and Google Cloud)$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Haiku 4.5$1 / MTok$1.25 / MTok$2 / MTok$0.10 / MTok$5 / MTok
Claude Haiku 3.5 (retired, except on Bedrock and Google Cloud)$0.80 / MTok$1 / MTok$1.60 / MTok$0.08 / MTok$4 / MTok
+
ModelBase Input Tokens5m Cache Writes1h Cache WritesCache Hits & RefreshesOutput Tokens
Claude Fable 5$10 / MTok$12.50 / MTok$20 / MTok$1 / MTok$50 / MTok
Claude Mythos 5 (limited availability)$10 / MTok$12.50 / MTok$20 / MTok$1 / MTok$50 / MTok
Claude Opus 5$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 5.5$4 / MTok$5 / MTok$8 / MTok$0.20 / MTok2$20 / MTok
Claude Opus 4.8$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.7$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.6$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.5$5 / MTok$6.25 / MTok$10 / MTok$0.50 / MTok$25 / MTok
Claude Opus 4.1 (deprecated)$15 / MTok$18.75 / MTok$30 / MTok$1.50 / MTok$75 / MTok
Claude Opus 4 (retired, except on Google Cloud)$15 / MTok$18.75 / MTok$30 / MTok$1.50 / MTok$75 / MTok
Claude Sonnet 5.5$2 / MTok$2.50 / MTok$4 / MTok$0.20 / MTok$10 / MTok
Claude Sonnet 5
through August 31, 2026
$2 / MTok$2.50 / MTok$4 / MTok$0.20 / MTok$10 / MTok
Claude Sonnet 5
starting September 1, 2026
$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Sonnet 4.6$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Sonnet 4.5$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Sonnet 4 (retired, except on Bedrock and Google Cloud)$3 / MTok$3.75 / MTok$6 / MTok$0.30 / MTok$15 / MTok
Claude Haiku 4.5$1 / MTok$1.25 / MTok$2 / MTok$0.10 / MTok$5 / MTok
Claude Haiku 3.5 (retired, except on Bedrock and Google Cloud)$0.80 / MTok$1 / MTok$1.60 / MTok$0.08 / MTok$4 / MTok
ConceptDetails
Billing unitClaude Consumption Unit (CCU)
CCU price$0.01 per CCU (fixed; discounts apply at token-to-CCU conversion, not to the CCU price)
ConversionToken usage rated in USD at standard per-model, per-feature rates (same as Claude API pricing), then converted to CCUs at $0.01 per CCU
Billing cadenceHourly metering to AWS Marketplace; monthly invoices
Payment modelArrears only (postpaid); no prepaid credits
DiscountsApplied as fewer CCUs metered
TaxPre-tax metering; AWS Marketplace handles tax
Cost visibilityReal-time breakdown in the Claude Console (access through the AWS Console); AWS Cost Explorer shows aggregated CCU
ConceptDetails
Billing unitClaude Consumption Unit (CCU)
CCU price$0.01 per CCU (fixed; discounts apply at token-to-CCU conversion, not to the CCU price)
ConversionToken usage rated in USD at standard per-model, per-feature rates (same as Claude API pricing), then converted to CCUs at $0.01 per CCU
Billing cadenceHourly metering to the Azure Marketplace; monthly invoices
Payment modelArrears only (postpaid); no prepaid credits
DiscountsApplied as fewer CCUs metered
TaxPre-tax metering; Azure Marketplace handles tax
Cost visibilityAzure Cost Management shows aggregated CCU
Cache operationMultiplierDuration
5-minute cache write1.25x base input priceCache valid for 5 minutes
1-hour cache write2x base input priceCache valid for 1 hour
Cache read (hit)0.1x base input priceSame duration as the preceding write
diff --git a/test/model-families.test.mjs b/test/model-families.test.mjs index 4c26f26b..d6790648 100644 --- a/test/model-families.test.mjs +++ b/test/model-families.test.mjs @@ -51,6 +51,7 @@ test("modelFamily: bare Opus 5 and Sonnet 5 ids, and the 5.5 point release, clas assert.equal(modelFamily("claude-opus-5"), "opus"); assert.equal(modelFamily("claude-opus-5-5"), "opus"); assert.equal(modelFamily("claude-sonnet-5"), "sonnet"); + assert.equal(modelFamily("claude-sonnet-5-5"), "sonnet"); }); // --- Back-compat: cache-telemetry re-exports modelFamily --- diff --git a/test/proxy-session-budget-breaker.test.mjs b/test/proxy-session-budget-breaker.test.mjs index 524c2228..cc7633bb 100644 --- a/test/proxy-session-budget-breaker.test.mjs +++ b/test/proxy-session-budget-breaker.test.mjs @@ -204,6 +204,14 @@ test("cost lever prices cache_creation at the higher cache_write_1h rate", async assert.ok(Math.abs(ext.__testOnly.costUsd(SID) - 1.0) < 1e-9, `expected ~$1.00, got ${ext.__testOnly.costUsd(SID)}`); }); +test("cost lever prices claude-sonnet-5-5, the default Sonnet, instead of failing open", async () => { + process.env.CACHE_FIX_SESSION_BUDGET = "on"; + process.env.CACHE_FIX_SESSION_BUDGET_COST_USD = "1"; + // 100k input tokens at Sonnet 5.5's $2/M = $0.20; an unpriced id would read $0. + await ext.onStreamEvent(start(100000, 0, "claude-sonnet-5-5")); + assert.ok(Math.abs(ext.__testOnly.costUsd(SID) - 0.2) < 1e-9, `expected ~$0.20, got ${ext.__testOnly.costUsd(SID)}`); +}); + test("cost lever UNDER the ceiling → forward", async () => { process.env.CACHE_FIX_SESSION_BUDGET = "on"; process.env.CACHE_FIX_SESSION_BUDGET_COST_USD = "1"; diff --git a/test/tools-update-rates.test.mjs b/test/tools-update-rates.test.mjs index 603aab3d..da5d4d99 100644 --- a/test/tools-update-rates.test.mjs +++ b/test/tools-update-rates.test.mjs @@ -9,8 +9,8 @@ import { parsePricing } from "../tools/update-rates.mjs"; // direct-API-key users is a literal dollar ceiling. The failure mode that // matters is a plausible-looking WRONG number, so these tests are mostly about // what the parser REFUSES to emit. Fixture is the blocks of the real -// pricing page as of 2026-07-27, with the Claude Opus 5.5 row copied in from -// the later live page — it had not shipped on 2026-07-27. +// pricing page as of 2026-07-27, with the Claude Opus 5.5 and Claude Sonnet 5.5 +// rows copied in from the later live page — neither had shipped on 2026-07-27. const __dirname = dirname(fileURLToPath(import.meta.url)); const FIXTURE = readFileSync(join(__dirname, "fixtures", "pricing-page-2026-07-27.html"), "utf8"); @@ -46,6 +46,15 @@ test("prices Claude Opus 5.5, footnoted cache-read discount included", () => { { input: 4, output: 20, cache_read: 0.2, cache_write_5m: 5, cache_write_1h: 8 }); }); +test("prices Claude Sonnet 5.5 as its own row, not as Claude Sonnet 5's", () => { + // In the September window Sonnet 5 stands at $3/$15 while Sonnet 5.5 stays at + // $2/$10, so a 5.5 id resolved onto Sonnet 5's dated rows would read wrong. + const { rates } = parsePricing(FIXTURE, SEP); + assert.deepEqual(rates["claude-sonnet-5-5"], + { input: 2, output: 10, cache_read: 0.2, cache_write_5m: 2.5, cache_write_1h: 4 }); + assert.equal(rates["claude-sonnet-5"].input, 3); +}); + // --- dated-variant disambiguation (Codex r1 blocker 1) --- test("Claude Sonnet 5 resolves to the window in effect on the fetch date", () => { @@ -122,7 +131,7 @@ test("a partial parse aborts instead of writing a plausible-looking file", () => test("markup with no parseable rows at all reports every required model missing", () => { const { rates, errors } = parsePricing("

pricing moved

", AUG); assert.deepEqual(rates, {}); - assert.equal(errors.length, 8, "one error per required wire id"); + assert.equal(errors.length, 9, "one error per required wire id"); }); // --- silent-corruption guards --- @@ -212,7 +221,7 @@ test("checked-in rates.json agrees with the fixture parse for required models", const shipped = JSON.parse(readFileSync(join(__dirname, "..", "tools", "rates.json"), "utf8")).models; const { rates } = parsePricing(FIXTURE, Date.parse("2026-07-27T12:00:00Z")); for (const id of ["claude-fable-5", "claude-opus-5", "claude-opus-5-5", "claude-opus-4-8", "claude-opus-4-7", - "claude-opus-4-6", "claude-sonnet-5", "claude-haiku-4-5"]) { + "claude-opus-4-6", "claude-sonnet-5", "claude-sonnet-5-5", "claude-haiku-4-5"]) { assert.ok(shipped[id], `rates.json is missing ${id} — the cost lever prices it at zero`); const { note, ...s } = shipped[id]; assert.deepEqual(s, rates[id], `rates.json ${id} disagrees with the published table`); diff --git a/tools/probe-tool-addition.mjs b/tools/probe-tool-addition.mjs index fe6cd42b..09575f5d 100644 --- a/tools/probe-tool-addition.mjs +++ b/tools/probe-tool-addition.mjs @@ -64,7 +64,7 @@ const API = "https://api.anthropic.com/v1/messages"; const DEFAULT_MODELS = [ "claude-opus-5", "claude-fable-5", - "claude-sonnet-5", + "claude-sonnet-5-5", "claude-haiku-4-5-20251001", ]; diff --git a/tools/rates.json b/tools/rates.json index 6ba76150..d1ddb4a9 100644 --- a/tools/rates.json +++ b/tools/rates.json @@ -115,6 +115,13 @@ "cache_write_5m": 6.25, "cache_write_1h": 10 }, + "claude-sonnet-5-5": { + "input": 2, + "output": 10, + "cache_read": 0.2, + "cache_write_5m": 2.5, + "cache_write_1h": 4 + }, "claude-sonnet-5": { "input": 2, "output": 10, diff --git a/tools/update-rates.mjs b/tools/update-rates.mjs index 36b13096..dcf87c68 100644 --- a/tools/update-rates.mjs +++ b/tools/update-rates.mjs @@ -52,6 +52,7 @@ const NAME_TO_WIRE = { "Claude Opus 4.5": ["claude-opus-4-5-20251101"], "Claude Opus 4.1": ["claude-opus-4-1-20250805"], "Claude Opus 4": ["claude-opus-4-20250514"], + "Claude Sonnet 5.5": ["claude-sonnet-5-5"], "Claude Sonnet 5": ["claude-sonnet-5"], "Claude Sonnet 4.6": ["claude-sonnet-4-6"], "Claude Sonnet 4.5": ["claude-sonnet-4-5-20250929"], @@ -83,6 +84,7 @@ const REQUIRED_WIRE_IDS = [ "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-5", + "claude-sonnet-5-5", "claude-haiku-4-5", ]; From 68c6585a957017b7c135e2b71aa669cccb3fc0f2 Mon Sep 17 00:00:00 2001 From: Junyong Lee Date: Mon, 28 Sep 2026 18:23:57 -0400 Subject: [PATCH 4/9] fix(update-rates): read pricing columns by the table header, not by position The live pricing page now has two header rows and orders the columns Input | Output | 5m writes | 1h writes | Hits and refreshes. priceRow() read them by fixed position, so every real refresh parsed 0 rates and 27 errors and failed closed. Take the column order from the header row that names Input/Output/5m/1h/Hits, per table; a header that does not name each once yields no rows (fails closed), never a guessed order. Fixture: the model-pricing table of the 2026-09-28 live page. Inline test tables gain a header row, since a table without one now yields no rows. Co-Authored-By: Claude --- test/fixtures/pricing-page-2026-09-28.html | 1 + test/tools-update-rates.test.mjs | 52 +++++++++++++++++++--- tools/update-rates.mjs | 49 ++++++++++++++++---- 3 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 test/fixtures/pricing-page-2026-09-28.html diff --git a/test/fixtures/pricing-page-2026-09-28.html b/test/fixtures/pricing-page-2026-09-28.html new file mode 100644 index 00000000..05f1cb22 --- /dev/null +++ b/test/fixtures/pricing-page-2026-09-28.html @@ -0,0 +1 @@ +
ModelBase tokensPrompt caching
NameInputOutput
Claude Fable 5.1For demanding reasoning and long-horizon agentic work
$10 /
$50 / MTok
$12.50 / MTok
$20 / MTok
/ MTok
Claude Opus 5.5For long-running agentic coding and knowledge work
$4 / MTok
$20 / MTok
$5 / MTok
$8 / MTok
/ MTok
Claude Sonnet 5.5The best combination of speed and intelligence
$2 / MTok
$10 / MTok
$2.50 / MTok
$4 / MTok
$0.20 / MTok
Claude Haiku 4.5The fastest model with near-frontier intelligence
$1 / MTok
$5 / MTok
$1.25 / MTok
$2 / MTok
$0.10 / MTok
$10 / MTok
$50 / MTok
$12.50 / MTok
$20 / MTok
/ MTok
$10 / MTok
$50 / MTok
$12.50 / MTok
$20 / MTok
$1 / MTok
$10 / MTok
$50 / MTok
$12.50 / MTok
$20 / MTok
$1 / MTok
$5 / MTok
$25 / MTok
$6.25 / MTok
$10 / MTok
$0.50 / MTok
$5 / MTok
$25 / MTok
$6.25 / MTok
$10 / MTok
$0.50 / MTok
$5 / MTok
$25 / MTok
$6.25 / MTok
$10 / MTok
$0.50 / MTok
$5 / MTok
$25 / MTok
$6.25 / MTok
$10 / MTok
$0.50 / MTok
$5 / MTok
$25 / MTok
$6.25 / MTok
$10 / MTok
$0.50 / MTok
Claude Opus 4.1
$15 / MTok
$75 / MTok
$18.75 / MTok
$30 / MTok
$1.50 / MTok
Claude Opus 4
$15 / MTok
$75 / MTok
$18.75 / MTok
$30 / MTok
$1.50 / MTok
/ MTok
/ MTok
$2.50 / MTok
$4 / MTok
$0.20 / MTok
$3 / MTok
$15 / MTok
$3.75 / MTok
$6 / MTok
$0.30 / MTok
$3 / MTok
$15 / MTok
$3.75 / MTok
$6 / MTok
$0.30 / MTok
Claude Sonnet 4
$3 / MTok
$15 / MTok
$3.75 / MTok
$6 / MTok
$0.30 / MTok
Claude Haiku 3.5
$0.80 / MTok
$4 / MTok
$1 / MTok
$1.60 / MTok
$0.08 / MTok
diff --git a/test/tools-update-rates.test.mjs b/test/tools-update-rates.test.mjs index da5d4d99..cb1a3e24 100644 --- a/test/tools-update-rates.test.mjs +++ b/test/tools-update-rates.test.mjs @@ -11,9 +11,21 @@ import { parsePricing } from "../tools/update-rates.mjs"; // what the parser REFUSES to emit. Fixture is the blocks of the real // pricing page as of 2026-07-27, with the Claude Opus 5.5 and Claude Sonnet 5.5 // rows copied in from the later live page — neither had shipped on 2026-07-27. +// +// pricing-page-2026-09-28.html is the model-pricing
of the live page as +// of 2026-09-28, fetched through update-rates' own fetchPricing (817995 bytes, +// sha256 94e6c8aca38da16741bb160c8339b34d6164b8de954a6f4225927da22f5bc91a) with +// every other block dropped. It has TWO header rows and a different column +// order from the 2026-07-27 page (Output right after Input, before the cache +// columns). const __dirname = dirname(fileURLToPath(import.meta.url)); const FIXTURE = readFileSync(join(__dirname, "fixtures", "pricing-page-2026-07-27.html"), "utf8"); +const LIVE_0928 = readFileSync(join(__dirname, "fixtures", "pricing-page-2026-09-28.html"), "utf8"); + +// Prices are read in the column order the table's own header names, so every +// inline table below carries a header row (a table without one yields no rows). +const HEAD = ""; const AUG = Date.parse("2026-08-15T12:00:00Z"); // introductory Sonnet 5 window const SEP = Date.parse("2026-09-15T12:00:00Z"); // standard Sonnet 5 window @@ -55,6 +67,34 @@ test("prices Claude Sonnet 5.5 as its own row, not as Claude Sonnet 5's", () => assert.equal(rates["claude-sonnet-5"].input, 3); }); +// --- column order comes from the header, not from a fixed position --- + +test("the two-header-row page of 2026-09-28 parses every required model with no errors", () => { + const { rates, errors } = parsePricing(LIVE_0928, Date.parse("2026-09-28T12:00:00Z")); + assert.deepEqual(errors, [], "a name-cell tagline must not read as a date qualifier either"); + // Output follows Input on this page; read by position it landed in cache_write_5m. + assert.deepEqual(rates["claude-opus-5-5"], + { input: 4, output: 20, cache_read: 0.2, cache_write_5m: 5, cache_write_1h: 8 }); + const shipped = JSON.parse(readFileSync(join(__dirname, "..", "tools", "rates.json"), "utf8")).models; + for (const id of ["claude-fable-5", "claude-opus-5", "claude-opus-5-5", "claude-opus-4-8", "claude-opus-4-7", + "claude-opus-4-6", "claude-sonnet-5", "claude-sonnet-5-5", "claude-haiku-4-5"]) { + const { note, ...s } = shipped[id]; + // Compared as JSON so key order counts: an unchanged page must stay a byte-identical no-op. + assert.equal(JSON.stringify(rates[id]), JSON.stringify(s), `${id} disagrees with the 2026-09-28 table`); + } +}); + +test("a header that does not name each price column once is refused, not read by position", () => { + // Old-order prices that the pre-header parser accepted whatever the header said. + const row = ``; + for (const cols of [["A", "B", "C", "D", "E"], ["Input", "Input", "5m", "1h", "Hits"]]) { + const head = `${cols.map((c) => ``).join("")}`; + const { rates, errors } = parsePricing(`
ModelInput5m1hHitsOutput
Claude Fable 5$10 / MTok$12.50 / MTok$20 / MTok$1 / MTok$50 / MTok
Model${c}
${head}${row}
`, AUG); + assert.equal(rates["claude-fable-5"], undefined, `header ${cols} must not be guessed`); + assert.ok(errors.some((e) => /required model "claude-fable-5" missing/.test(e)), JSON.stringify(errors)); + } +}); + // --- dated-variant disambiguation (Codex r1 blocker 1) --- test("Claude Sonnet 5 resolves to the window in effect on the fetch date", () => { @@ -139,7 +179,7 @@ test("markup with no parseable rows at all reports every required model missing" test("a price outside the sane range is rejected", () => { // Multipliers are internally consistent, so only the range check catches this // 1000x transcription error. - const html = `
Claude Fable 5$10000 / MTok$12500 / MTok$20000 / MTok$1000 / MTok$50000 / MTok
`; + const html = `${HEAD}
Claude Fable 5$10000 / MTok$12500 / MTok$20000 / MTok$1000 / MTok$50000 / MTok
`; const { rates, errors } = parsePricing(html, AUG); assert.equal(rates["claude-fable-5"], undefined); assert.ok(errors.some((e) => /outside sane range/.test(e)), JSON.stringify(errors)); @@ -148,7 +188,7 @@ test("a price outside the sane range is rejected", () => { test("cache prices that contradict the documented multipliers are rejected", () => { // Plausible-looking numbers, wrong relationship — the exact class of quiet // corruption a column-order change would produce. - const html = `
Claude Fable 5$10 / MTok$11 / MTok$13 / MTok$4 / MTok$50 / MTok
`; + const html = `${HEAD}
Claude Fable 5$10 / MTok$11 / MTok$13 / MTok$4 / MTok$50 / MTok
`; const { rates, errors } = parsePricing(html, AUG); assert.equal(rates["claude-fable-5"], undefined); assert.ok(errors.some((e) => /contradicts the documented/.test(e)), JSON.stringify(errors)); @@ -157,7 +197,7 @@ test("cache prices that contradict the documented multipliers are rejected", () test("the Opus 5.5 cache-read override does not loosen the guard for other models", () => { // Claude Fable 5 at the 0.05x rate Opus 5.5 uses would be $0.20/MTok, not the // documented 0.1x ($0.40/MTok) — must still be rejected as a misparse. - const html = `
Claude Fable 5$4 / MTok$5 / MTok$8 / MTok$0.20 / MTok$20 / MTok
`; + const html = `${HEAD}
Claude Fable 5$4 / MTok$5 / MTok$8 / MTok$0.20 / MTok$20 / MTok
`; const { rates, errors } = parsePricing(html, AUG); assert.equal(rates["claude-fable-5"], undefined); assert.ok(errors.some((e) => /contradicts the documented/.test(e)), JSON.stringify(errors)); @@ -168,7 +208,7 @@ test("a footnote-shaped suffix with no space or too many digits fails closed", ( // was, so a real footnote marker reads "$4 / MTok 2" (one space, 1-2 // digits). "$4 / MTok2026" has no such space — not a footnote, a misparse — // and must be dropped rather than read as a $4 price. - const html = `
Claude Fable 5$4 / MTok2026$5 / MTok$8 / MTok$0.40 / MTok$20 / MTok
`; + const html = `${HEAD}
Claude Fable 5$4 / MTok2026$5 / MTok$8 / MTok$0.40 / MTok$20 / MTok
`; const { rates, errors } = parsePricing(html, AUG); assert.equal(rates["claude-fable-5"], undefined); assert.ok(errors.some((e) => /required model "claude-fable-5" missing/.test(e)), JSON.stringify(errors)); @@ -234,7 +274,7 @@ test("checked-in rates.json agrees with the fixture parse for required models", // "starting September 1, 2026" row read in August is not today's price. const soleRow = (name, input) => - `` + + `
${name}$${input} / MTok$${input * 1.25} / MTok
${HEAD}` + `
${name}$${input} / MTok$${input * 1.25} / MTok$${input * 2} / MTok$${(input * 0.1).toFixed(2)} / MTok$${input * 5} / MTok
`; test("a sole not-yet-effective row is refused, not adopted", () => { @@ -270,7 +310,7 @@ test("a qualifier in an unrecognized date format is refused, not ignored", () => test("a model mixing a dated and an undated row is refused", () => { const two = - `` + + `
Claude Fable 5 starting September 1, 2026$20 / MTok$25 / MTok$40 / MTok$2 / MTok$100 / MTok
${HEAD}` + `
Claude Fable 5 starting September 1, 2026$20 / MTok$25 / MTok$40 / MTok$2 / MTok$100 / MTok
Claude Fable 5$10 / MTok$12.50 / MTok$20 / MTok$1 / MTok$50 / MTok
`; const { rates, errors } = parsePricing(two, AUG); assert.equal(rates["claude-fable-5"], undefined, "cannot tell which row is authoritative"); diff --git a/tools/update-rates.mjs b/tools/update-rates.mjs index dcf87c68..01f6567b 100644 --- a/tools/update-rates.mjs +++ b/tools/update-rates.mjs @@ -179,21 +179,52 @@ function tableRows(html) { // not a second price — allow and ignore it. const PRICE_CELL = /^\$([0-9][0-9,]*(?:\.[0-9]+)?)\s*\/\s*MTok(?:\s\d{1,2})?$/; -// A model-pricing row is exactly: name cell + the five price columns -// (input | 5m write | 1h write | cache read | output). This shape requirement is +// Keywords that name each price column in the pricing table's header row +// ("Base Input Tokens", "Input", "5m Cache Writes", "Hits and refreshes", ...). +const COLUMNS = { input: /input/i, output: /output/i, cache_write_5m: /5m/i, cache_write_1h: /1h/i, cache_read: /hits/i }; + +// The price fields, in column order, when this row is the pricing table's header +// row: name cell + five cells that each name exactly one price field, every field +// named once. Anything else (the page's first, colSpan header row; another +// table's header; a renamed column) is null, so a header this cannot map yields +// no rows and the run fails closed instead of reading prices by position. +function headerCols(cells) { + const fields = cells.slice(1).map((c) => Object.keys(COLUMNS).filter((f) => COLUMNS[f].test(c))); + return fields.length === 5 && fields.every((f) => f.length === 1) && new Set(fields.flat()).size === 5 + ? fields.flat() : null; +} + +// A model-pricing row is exactly: name cell + the five price columns, in the +// order `cols` (from the header row) names them. This shape requirement is // what keeps the batch-pricing table (two price cells) and the tool-use table // (token counts, not prices) from being mistaken for pricing rows — the old // first-match-anywhere scan had no such guard. -function priceRow(cells) { - if (cells.length !== 6) return null; - const vals = []; +function priceRow(cells, cols) { + if (!cols || cells.length !== 6) return null; + const v = {}; for (let i = 1; i < 6; i++) { const m = PRICE_CELL.exec(cells[i]); if (!m) return null; - vals.push(parseFloat(m[1].replace(/,/g, ""))); + v[cols[i - 1]] = parseFloat(m[1].replace(/,/g, "")); } - const [input, w5m, w1h, read, output] = vals; - return { name: cells[0], input, output, cache_read: read, cache_write_5m: w5m, cache_write_1h: w1h }; + // Fixed key order (not column order): an unchanged page must stay a byte-identical no-op. + const { input, output, cache_read, cache_write_5m, cache_write_1h } = v; + return { name: cells[0], input, output, cache_read, cache_write_5m, cache_write_1h }; +} + +// Price rows of every table, each read with the column order its OWN header +// row names (the mapping never carries over into the next table). +function priceRows(html) { + const rows = []; + for (const [table] of html.matchAll(//g)) { + let cols = null; + for (const cells of tableRows(table)) { + cols = headerCols(cells) ?? cols; + const r = priceRow(cells, cols); + if (r) rows.push(r); + } + } + return rows; } // Does this row's name cell name this model — as opposed to a longer model whose @@ -229,7 +260,7 @@ function validateRates(wireId, r) { // Parse the model-pricing table. `atMs` is the effective date used to pick // between dated variants of the same model. Returns { rates, errors }. export function parsePricing(html, atMs = Date.now()) { - const rows = tableRows(html).map(priceRow).filter(Boolean); + const rows = priceRows(html); const rates = {}; const errors = []; From 49b3eb6ff748ed4413c691e3ddcae9f0467b1c38 Mon Sep 17 00:00:00 2001 From: Junyong Lee Date: Mon, 28 Sep 2026 18:41:29 -0400 Subject: [PATCH 5/9] fix(session-budget-breaker): accrue streamed turns from the sid onRequest stashed on meta onStreamEvent read the session id from ctx.headers, but the real stream ctx (proxy/stream.mjs) has no request headers, so no streamed request accrued and the token, cost and rate levers never fired on real traffic. Read ctx.meta._sbbSessionId, as onResponse does. Stream fixtures now model the real ctx, and one test drives the shipped streamResponse. The sim's stream legs carried the same wrong shape and are corrected. Co-Authored-By: Claude --- CHANGELOG.md | 2 ++ proxy/extensions/session-budget-breaker.mjs | 5 +-- test/proxy-session-budget-breaker.test.mjs | 37 +++++++++++++++++---- tools/sim-session-budget-breaker.mjs | 10 +++--- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cdf1b54..81a536c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- **`session-budget-breaker` now accrues streamed turns, so its ceilings can fire on real traffic.** `onStreamEvent` read the session id from `ctx.headers`, but the stream ctx built in `proxy/stream.mjs` carries `{event, meta, telemetry, responseHeaders, drop}` and no request headers, so no streamed request ever accrued: the token, cost and rate levers all stayed at zero, and Claude Code streams every turn. It now reads the id `onRequest` stashed on the shared `meta`, as `onResponse` already did for `stream:false`. The suite had passed only because every stream fixture handed in `headers`; they now model the real ctx, and one test drives the shipped `streamResponse` end to end. + - **`claude-opus-5-5` is now priced, tracked for divergence, and no longer billed as `claude-opus-5`; `claude-sonnet-5-5` is now priced too.** The model tables (`tools/rates.json`, `tools/update-rates.mjs`, `proxy/model-families.mjs`) had no entry for it, so `tools/cost-report.mjs`'s `lookupRates()` matched the first `rates.json` key in file order where either id prefixed the other — `claude-opus-5`'s rates, 25% too high on input/output and off by more than 2x on the cache-read discount the pricing page documents for this model — and bare `claude-opus-5` / `claude-sonnet-5` read as `"unknown"` in `proxy/model-families.mjs`, so a request served by a different family only latched `model_divergence_sticky` on the third divergent turn instead of the first. `claude-sonnet-5-5` (Claude Code's default Sonnet since 2.1.284) had no row in `tools/rates.json` or `tools/update-rates.mjs` either, so a refresh never wrote it and the session-budget-breaker's exact-id lookup counted no cost for it; both carry it now, and `tools/probe-tool-addition.mjs` probes it in place of `claude-sonnet-5`. - **A refused fd-3 handover no longer makes the proxy claim it handed the socket on.** `inheritedSocket` was computed from "handover was attempted", not "handover succeeded", so a proxy that was refused fd 3 and fell back to binding its own port still advertised an inherited socket. On `SIGTERM` it then spawned a successor pointed at the same unservable descriptor and exited `75` — telling the supervisor a successor holds the socket — while the port it actually served was released with nobody on it. Exits `0` now, spawns nothing, and leaves no orphan. diff --git a/proxy/extensions/session-budget-breaker.mjs b/proxy/extensions/session-budget-breaker.mjs index de011fbd..3c39e50f 100644 --- a/proxy/extensions/session-budget-breaker.mjs +++ b/proxy/extensions/session-budget-breaker.mjs @@ -346,8 +346,9 @@ export default { if (!ev || ev.type !== "message_start") return; const split = usageInputTokens(ev.message && ev.message.usage); if (split === null) return; // unparseable/missing → don't update (fail-open) - let sid; - try { sid = resolveSessionId(ctx.headers); } catch { return; } + // The stream ctx (proxy/stream.mjs) carries no request headers, so read + // the sid onRequest stashed on the shared meta, as onResponse does. + const sid = ctx.meta && ctx.meta._sbbSessionId; if (!sid) return; accrue(sid, split, ev.message && ev.message.model, ctx.responseHeaders); } catch { /* fail-open: never let accounting throw affect the request */ } diff --git a/test/proxy-session-budget-breaker.test.mjs b/test/proxy-session-budget-breaker.test.mjs index cc7633bb..40019265 100644 --- a/test/proxy-session-budget-breaker.test.mjs +++ b/test/proxy-session-budget-breaker.test.mjs @@ -3,7 +3,9 @@ import assert from "node:assert/strict"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readFileSync, rmSync } from "node:fs"; +import { Readable, Writable } from "node:stream"; import ext from "../proxy/extensions/session-budget-breaker.mjs"; +import { streamResponse, createTelemetryRecord } from "../proxy/stream.mjs"; // Directive: docs/directives/proxy-subagent-budget-circuit-breaker.md // Highest-value class = the fail-open table. A block requires gate `on` + a @@ -12,9 +14,12 @@ import ext from "../proxy/extensions/session-budget-breaker.mjs"; const SID = "sess-abc"; const H = (sid = SID) => ({ "x-claude-code-session-id": sid }); // A message_start stream event carrying input+cache_creation tokens. `model` is -// optional (cost lever prices per-model; token/rate levers ignore it). +// optional (cost lever prices per-model; token/rate levers ignore it). The ctx +// mirrors proxy/stream.mjs processLine, {event, meta, telemetry, responseHeaders, +// drop}: NO request headers, so the session id rides on meta, where onRequest +// stashed it. const start = (inp, cc = 0, model = undefined, sid = SID, responseHeaders = undefined) => ({ - headers: H(sid), + meta: { _sbbSessionId: sid }, responseHeaders, event: { type: "message_start", message: { model, usage: { input_tokens: inp, cache_creation_input_tokens: cc, output_tokens: 0, cache_read_input_tokens: 0 } } }, }); @@ -92,7 +97,7 @@ test("gate on, ceiling set, first request (no tally yet) → forward", async () test("unparseable usage does not update tally (metric-local fail-open)", async () => { process.env.CACHE_FIX_SESSION_BUDGET = "on"; process.env.CACHE_FIX_SESSION_BUDGET_TOKENS = "10"; - await ext.onStreamEvent({ headers: H(), event: { type: "message_start", message: { usage: { input_tokens: "not-a-number" } } } }); + await ext.onStreamEvent({ meta: { _sbbSessionId: SID }, event: { type: "message_start", message: { usage: { input_tokens: "not-a-number" } } } }); const r = await ext.onRequest(req()); assert.equal(r, undefined, "unparseable input_tokens → tally not updated → forward"); assert.equal(ext.__testOnly.tally(SID), undefined); @@ -101,7 +106,7 @@ test("unparseable usage does not update tally (metric-local fail-open)", async ( test("missing usage block → no update, forward", async () => { process.env.CACHE_FIX_SESSION_BUDGET = "on"; process.env.CACHE_FIX_SESSION_BUDGET_TOKENS = "10"; - await ext.onStreamEvent({ headers: H(), event: { type: "message_start", message: {} } }); + await ext.onStreamEvent({ meta: { _sbbSessionId: SID }, event: { type: "message_start", message: {} } }); const r = await ext.onRequest(req()); assert.equal(r, undefined); }); @@ -151,7 +156,7 @@ test("input + cache_creation both count toward the tally", async () => { test("tally is per-session: session B is unaffected by session A's burn", async () => { process.env.CACHE_FIX_SESSION_BUDGET = "on"; process.env.CACHE_FIX_SESSION_BUDGET_TOKENS = "100"; - await ext.onStreamEvent({ headers: H("A"), event: { type: "message_start", message: { usage: { input_tokens: 500 } } } }); + await ext.onStreamEvent({ meta: { _sbbSessionId: "A" }, event: { type: "message_start", message: { usage: { input_tokens: 500 } } } }); const rA = await ext.onRequest(req("A")); const rB = await ext.onRequest(req("B")); assert.ok(rA && rA.skip === true, "A over → blocked"); @@ -319,11 +324,31 @@ test("a throw in onStreamEvent never propagates (fail-open)", async () => { process.env.CACHE_FIX_SESSION_BUDGET = "on"; process.env.CACHE_FIX_SESSION_BUDGET_TOKENS = "100"; // event undefined → internal guards return; must not throw - await assert.doesNotReject(() => ext.onStreamEvent({ headers: H(), event: undefined })); + await assert.doesNotReject(() => ext.onStreamEvent({ meta: { _sbbSessionId: SID }, event: undefined })); const r = await ext.onRequest(req()); assert.equal(r, undefined); }); +// --- Streaming accrual through the REAL stream.mjs ctx --- +// A hand-spelled onStreamEvent ctx once carried request `headers` that the real +// one never does, so every streamed turn accrued nothing while this suite stayed +// green. Drive the shipped streamResponse so the ctx shape is stream.mjs's own. + +test("a streamed response accrues through the real stream ctx and trips the ceiling", async () => { + process.env.CACHE_FIX_SESSION_BUDGET = "on"; + process.env.CACHE_FIX_SESSION_BUDGET_TOKENS = "100"; + process.env.CACHE_FIX_SESSION_BUDGET_COST_USD = "1000"; + const meta = { route: "messages" }; + assert.equal(await ext.onRequest(req(SID, true, meta)), undefined); + const sse = `data: ${JSON.stringify(start(150, 0, "claude-opus-4-6").event)}\n\n`; + const sink = new Writable({ write(_c, _e, cb) { cb(); } }); + await streamResponse(Readable.from([Buffer.from(sse)]), sink, createTelemetryRecord(), [ext], meta, {}); + assert.equal(ext.__testOnly.tally(SID).tokens, 150, "token lever accrues"); + assert.ok(ext.__testOnly.costUsd(SID) > 0, "cost lever accrues"); + const r = await ext.onRequest(req(SID, true, { route: "messages" })); + assert.ok(r && r.skip === true, "the next streamed request must block"); +}); + // --- Non-streaming (stream:false) accrual: onResponse path --- // Codex review r1 blocker: server.mjs routes non-streaming /v1/messages // responses through onResponse only, so a breaker that learns spend from diff --git a/tools/sim-session-budget-breaker.mjs b/tools/sim-session-budget-breaker.mjs index 279a7193..f165301f 100644 --- a/tools/sim-session-budget-breaker.mjs +++ b/tools/sim-session-budget-breaker.mjs @@ -69,8 +69,10 @@ const reqCtx = (stream = true) => ({ body: { model: MODEL, stream }, meta: { route: "messages" }, }); -const startEvent = () => ({ - headers: { "x-claude-code-session-id": SID }, +// The stream ctx (proxy/stream.mjs) has no request headers: the sid rides on the +// meta the request's onRequest stashed it on. +const startEvent = (meta) => ({ + meta, responseHeaders: {}, event: { type: "message_start", message: { model: MODEL, usage: legUsage() } }, }); @@ -139,7 +141,7 @@ async function runFanout({ concurrency, gate = "on", env = {}, stream = true }) push(VCLOCK + LATENCY_MS, "ACCRUE", ctx.meta); } } else { - if (stream) await ext.onStreamEvent(startEvent()); + if (stream) await ext.onStreamEvent(startEvent(ev.meta)); else await ext.onResponse(jsonResCtx(ev.meta)); inflight--; const e = ext.__testOnly.tally(SID); @@ -228,7 +230,7 @@ async function main() { VCLOCK = 0; let foForwarded = 0, foBlocked = 0; for (let i = 0; i < N_LEGS; i++) { - await ext.onStreamEvent({ headers: { "x-claude-code-session-id": SID }, + await ext.onStreamEvent({ meta: { _sbbSessionId: SID }, event: { type: "message_start", message: { model: MODEL, usage: { input_tokens: "not-a-number" } } } }); const r = await ext.onRequest(reqCtx()); if (r && r.skip) foBlocked++; else foForwarded++; From 8c4f3316dce56b338f202b8295a5eea17e4f65fe Mon Sep 17 00:00:00 2001 From: Junyong Lee Date: Wed, 7 Oct 2026 17:38:14 -0400 Subject: [PATCH 6/9] fix(model-tables): classify and price claude-haiku-5-5 (Claude Haiku 5.5) modelFamily() read claude-haiku-5-5 as unknown (the claude-haiku-4-5 root cannot match), and rates.json had no entry, so the session-budget-breaker cost lever counted $0 for the CC 2.1.293 default Haiku. Add the family root and the lower-tier (up to 100,000 tokens) rates. The live page tiers this row, so the parser reads no row for it and a refresh keeps the hand-written entry (measured against the live page); NAME_TO_WIRE prices it should the page go flat. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- proxy/model-families.mjs | 1 + test/model-families.test.mjs | 4 +++- test/proxy-session-budget-breaker.test.mjs | 16 +++++++++------- test/tools-update-rates.test.mjs | 16 ++++++++++++++++ tools/rates.json | 7 +++++++ tools/update-rates.mjs | 2 ++ 7 files changed, 39 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81a536c7..0ecfbc39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - **`session-budget-breaker` now accrues streamed turns, so its ceilings can fire on real traffic.** `onStreamEvent` read the session id from `ctx.headers`, but the stream ctx built in `proxy/stream.mjs` carries `{event, meta, telemetry, responseHeaders, drop}` and no request headers, so no streamed request ever accrued: the token, cost and rate levers all stayed at zero, and Claude Code streams every turn. It now reads the id `onRequest` stashed on the shared `meta`, as `onResponse` already did for `stream:false`. The suite had passed only because every stream fixture handed in `headers`; they now model the real ctx, and one test drives the shipped `streamResponse` end to end. -- **`claude-opus-5-5` is now priced, tracked for divergence, and no longer billed as `claude-opus-5`; `claude-sonnet-5-5` is now priced too.** The model tables (`tools/rates.json`, `tools/update-rates.mjs`, `proxy/model-families.mjs`) had no entry for it, so `tools/cost-report.mjs`'s `lookupRates()` matched the first `rates.json` key in file order where either id prefixed the other — `claude-opus-5`'s rates, 25% too high on input/output and off by more than 2x on the cache-read discount the pricing page documents for this model — and bare `claude-opus-5` / `claude-sonnet-5` read as `"unknown"` in `proxy/model-families.mjs`, so a request served by a different family only latched `model_divergence_sticky` on the third divergent turn instead of the first. `claude-sonnet-5-5` (Claude Code's default Sonnet since 2.1.284) had no row in `tools/rates.json` or `tools/update-rates.mjs` either, so a refresh never wrote it and the session-budget-breaker's exact-id lookup counted no cost for it; both carry it now, and `tools/probe-tool-addition.mjs` probes it in place of `claude-sonnet-5`. +- **`claude-opus-5-5` is now priced, tracked for divergence, and no longer billed as `claude-opus-5`; `claude-sonnet-5-5` and `claude-haiku-5-5` are now priced too.** The model tables (`tools/rates.json`, `tools/update-rates.mjs`, `proxy/model-families.mjs`) had no entry for it, so `tools/cost-report.mjs`'s `lookupRates()` matched the first `rates.json` key in file order where either id prefixed the other — `claude-opus-5`'s rates, 25% too high on input/output and off by more than 2x on the cache-read discount the pricing page documents for this model — and bare `claude-opus-5` / `claude-sonnet-5` read as `"unknown"` in `proxy/model-families.mjs`, so a request served by a different family only latched `model_divergence_sticky` on the third divergent turn instead of the first. `claude-sonnet-5-5` (Claude Code's default Sonnet since 2.1.284) had no row in `tools/rates.json` or `tools/update-rates.mjs` either, so a refresh never wrote it and the session-budget-breaker's exact-id lookup counted no cost for it; both carry it now, and `tools/probe-tool-addition.mjs` probes it in place of `claude-sonnet-5`. `claude-haiku-5-5` (Claude Code 2.1.293's default Haiku) had no row either, and bare `claude-haiku-5` read as `"unknown"` in `proxy/model-families.mjs`. It is priced at the lower tier of the page's two ($0.10 input / $0.50 output per MTok for prompts up to 100,000 tokens), hand-written in `tools/rates.json` because the tiered row does not parse and a refresh keeps entries the parse does not find, so prompts over 100,000 tokens (which the page prices at $0.50 / $2.50) are under-counted. - **A refused fd-3 handover no longer makes the proxy claim it handed the socket on.** `inheritedSocket` was computed from "handover was attempted", not "handover succeeded", so a proxy that was refused fd 3 and fell back to binding its own port still advertised an inherited socket. On `SIGTERM` it then spawned a successor pointed at the same unservable descriptor and exited `75` — telling the supervisor a successor holds the socket — while the port it actually served was released with nobody on it. Exits `0` now, spawns nothing, and leaves no orphan. diff --git a/proxy/model-families.mjs b/proxy/model-families.mjs index 45a67f7a..6271a55b 100644 --- a/proxy/model-families.mjs +++ b/proxy/model-families.mjs @@ -23,6 +23,7 @@ export const MODEL_FAMILIES = [ { root: "claude-sonnet-4-7", family: "sonnet" }, { root: "claude-sonnet-5", family: "sonnet" }, { root: "claude-haiku-4-5", family: "haiku" }, + { root: "claude-haiku-5", family: "haiku" }, ]; // Family classification — substring-matched against the lowercased model diff --git a/test/model-families.test.mjs b/test/model-families.test.mjs index d6790648..f47953a9 100644 --- a/test/model-families.test.mjs +++ b/test/model-families.test.mjs @@ -47,11 +47,13 @@ test("modelFamily: returns 'unknown' for unmatched / empty / non-string", () => assert.equal(modelFamily(123), "unknown"); }); -test("modelFamily: bare Opus 5 and Sonnet 5 ids, and the 5.5 point release, classify correctly", () => { +test("modelFamily: bare Opus 5, Sonnet 5 and Haiku 5 ids, and the 5.5 point release, classify correctly", () => { assert.equal(modelFamily("claude-opus-5"), "opus"); assert.equal(modelFamily("claude-opus-5-5"), "opus"); assert.equal(modelFamily("claude-sonnet-5"), "sonnet"); assert.equal(modelFamily("claude-sonnet-5-5"), "sonnet"); + assert.equal(modelFamily("claude-haiku-5"), "haiku"); + assert.equal(modelFamily("claude-haiku-5-5"), "haiku"); }); // --- Back-compat: cache-telemetry re-exports modelFamily --- diff --git a/test/proxy-session-budget-breaker.test.mjs b/test/proxy-session-budget-breaker.test.mjs index 40019265..6c60b105 100644 --- a/test/proxy-session-budget-breaker.test.mjs +++ b/test/proxy-session-budget-breaker.test.mjs @@ -209,13 +209,15 @@ test("cost lever prices cache_creation at the higher cache_write_1h rate", async assert.ok(Math.abs(ext.__testOnly.costUsd(SID) - 1.0) < 1e-9, `expected ~$1.00, got ${ext.__testOnly.costUsd(SID)}`); }); -test("cost lever prices claude-sonnet-5-5, the default Sonnet, instead of failing open", async () => { - process.env.CACHE_FIX_SESSION_BUDGET = "on"; - process.env.CACHE_FIX_SESSION_BUDGET_COST_USD = "1"; - // 100k input tokens at Sonnet 5.5's $2/M = $0.20; an unpriced id would read $0. - await ext.onStreamEvent(start(100000, 0, "claude-sonnet-5-5")); - assert.ok(Math.abs(ext.__testOnly.costUsd(SID) - 0.2) < 1e-9, `expected ~$0.20, got ${ext.__testOnly.costUsd(SID)}`); -}); +// 100k input tokens at the 5.5 model's $/M (Sonnet $2, Haiku $0.10); an unpriced id would read $0. +for (const [model, usd] of [["claude-sonnet-5-5", 0.2], ["claude-haiku-5-5", 0.01]]) { + test(`cost lever prices ${model}, a default model, instead of failing open`, async () => { + process.env.CACHE_FIX_SESSION_BUDGET = "on"; + process.env.CACHE_FIX_SESSION_BUDGET_COST_USD = "1"; + await ext.onStreamEvent(start(100000, 0, model)); + assert.ok(Math.abs(ext.__testOnly.costUsd(SID) - usd) < 1e-9, `expected ~$${usd}, got ${ext.__testOnly.costUsd(SID)}`); + }); +} test("cost lever UNDER the ceiling → forward", async () => { process.env.CACHE_FIX_SESSION_BUDGET = "on"; diff --git a/test/tools-update-rates.test.mjs b/test/tools-update-rates.test.mjs index cb1a3e24..e0d6ceb2 100644 --- a/test/tools-update-rates.test.mjs +++ b/test/tools-update-rates.test.mjs @@ -67,6 +67,22 @@ test("prices Claude Sonnet 5.5 as its own row, not as Claude Sonnet 5's", () => assert.equal(rates["claude-sonnet-5"].input, 3); }); +test("Claude Haiku 5.5: a flat row prices to the shipped entry, the live tiered row is not read", () => { + // The live page tiers Haiku 5.5 (a "for prompts up to 100,000 tokens" price cell, then a nameless + // continuation row for the over-100K tier), so the parser sees no row and update-rates keeps the + // hand-written rates.json entry. A flat row (the page dropping the tier) must price identically. + const row = (...c) => `${c.map((x) => `${x}`).join("")}`; + const prices = ["$0.125 / MTok", "$0.20 / MTok", "$0.01 / MTok", "$0.50 / MTok"]; + const flat = `${HEAD}${row("Claude Haiku 5.5", "$0.10 / MTok", ...prices)}
`; + const tiered = `${HEAD}${row("Claude Haiku 5.5", "$0.10 / MTok for prompts up to 100,000 tokens", ...prices)}` + + `${row("$0.50 / MTok for prompts over 100,000 tokens", "$0.625 / MTok", "$1 / MTok", "$0.05 / MTok", "$2.50 / MTok")}
`; + const shipped = JSON.parse(readFileSync(join(__dirname, "..", "tools", "rates.json"), "utf8")).models["claude-haiku-5-5"]; + assert.equal(JSON.stringify(parsePricing(flat, AUG).rates["claude-haiku-5-5"]), JSON.stringify(shipped)); + const { rates, errors } = parsePricing(tiered, AUG); + assert.equal(rates["claude-haiku-5-5"], undefined, "a tiered price is not the flat price"); + assert.ok(!errors.some((e) => /haiku-5-5/.test(e)), JSON.stringify(errors)); +}); + // --- column order comes from the header, not from a fixed position --- test("the two-header-row page of 2026-09-28 parses every required model with no errors", () => { diff --git a/tools/rates.json b/tools/rates.json index d1ddb4a9..36e3dc87 100644 --- a/tools/rates.json +++ b/tools/rates.json @@ -136,6 +136,13 @@ "cache_write_5m": 1.25, "cache_write_1h": 2 }, + "claude-haiku-5-5": { + "input": 0.1, + "output": 0.5, + "cache_read": 0.01, + "cache_write_5m": 0.125, + "cache_write_1h": 0.2 + }, "claude-opus-5-5": { "input": 4, "output": 20, diff --git a/tools/update-rates.mjs b/tools/update-rates.mjs index 01f6567b..b78229ab 100644 --- a/tools/update-rates.mjs +++ b/tools/update-rates.mjs @@ -57,6 +57,8 @@ const NAME_TO_WIRE = { "Claude Sonnet 4.6": ["claude-sonnet-4-6"], "Claude Sonnet 4.5": ["claude-sonnet-4-5-20250929"], "Claude Sonnet 4": ["claude-sonnet-4-20250514"], + // Tiered on the live page, so no row parses and rates.json's lower-tier entry is hand-written; this only fires if the tier is dropped. + "Claude Haiku 5.5": ["claude-haiku-5-5"], "Claude Haiku 4.5": ["claude-haiku-4-5", "claude-haiku-4-5-20251001"], "Claude Haiku 3.5": ["claude-haiku-3-5-20241022"], "Claude 3 Opus": ["claude-3-opus-20240229"], From cb89d3238da2772966aa8a59bfaef37ca63b0417 Mon Sep 17 00:00:00 2001 From: Junyong Lee Date: Wed, 7 Oct 2026 17:42:53 -0400 Subject: [PATCH 7/9] test(update-rates): pin Haiku 5.5 to a literal so a missing entry fails The shipped-entry comparison read undefined == undefined on a tree with no claude-haiku-5-5 entry and passed. Compare both the flat-row parse and rates.json to the literal rates; carry the ponytail marker for the lower-tier ceiling on the NAME_TO_WIRE row. Co-Authored-By: Claude --- test/tools-update-rates.test.mjs | 4 +++- tools/update-rates.mjs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/tools-update-rates.test.mjs b/test/tools-update-rates.test.mjs index e0d6ceb2..4d1cf27f 100644 --- a/test/tools-update-rates.test.mjs +++ b/test/tools-update-rates.test.mjs @@ -77,7 +77,9 @@ test("Claude Haiku 5.5: a flat row prices to the shipped entry, the live tiered const tiered = `${HEAD}${row("Claude Haiku 5.5", "$0.10 / MTok for prompts up to 100,000 tokens", ...prices)}` + `${row("$0.50 / MTok for prompts over 100,000 tokens", "$0.625 / MTok", "$1 / MTok", "$0.05 / MTok", "$2.50 / MTok")}
`; const shipped = JSON.parse(readFileSync(join(__dirname, "..", "tools", "rates.json"), "utf8")).models["claude-haiku-5-5"]; - assert.equal(JSON.stringify(parsePricing(flat, AUG).rates["claude-haiku-5-5"]), JSON.stringify(shipped)); + const want = JSON.stringify({ input: 0.1, output: 0.5, cache_read: 0.01, cache_write_5m: 0.125, cache_write_1h: 0.2 }); + assert.equal(JSON.stringify(parsePricing(flat, AUG).rates["claude-haiku-5-5"]), want); + assert.equal(JSON.stringify(shipped), want, "rates.json carries the hand-written lower-tier entry"); const { rates, errors } = parsePricing(tiered, AUG); assert.equal(rates["claude-haiku-5-5"], undefined, "a tiered price is not the flat price"); assert.ok(!errors.some((e) => /haiku-5-5/.test(e)), JSON.stringify(errors)); diff --git a/tools/update-rates.mjs b/tools/update-rates.mjs index b78229ab..2ccd7d35 100644 --- a/tools/update-rates.mjs +++ b/tools/update-rates.mjs @@ -57,7 +57,7 @@ const NAME_TO_WIRE = { "Claude Sonnet 4.6": ["claude-sonnet-4-6"], "Claude Sonnet 4.5": ["claude-sonnet-4-5-20250929"], "Claude Sonnet 4": ["claude-sonnet-4-20250514"], - // Tiered on the live page, so no row parses and rates.json's lower-tier entry is hand-written; this only fires if the tier is dropped. + // ponytail: tiered on the live page, so no row parses; rates.json's up-to-100K-token entry is hand-written (over-100K is under-counted, the schema has no tiers). This row only fires if the tier is dropped. "Claude Haiku 5.5": ["claude-haiku-5-5"], "Claude Haiku 4.5": ["claude-haiku-4-5", "claude-haiku-4-5-20251001"], "Claude Haiku 3.5": ["claude-haiku-3-5-20241022"], From 1c547d5c2a33e8f7db05265fea27d5c6b62beeb8 Mon Sep 17 00:00:00 2001 From: Junyong Lee Date: Wed, 7 Oct 2026 18:23:01 -0400 Subject: [PATCH 8/9] test(gap-relay-chain): dead hops are unbindable ports, not freed ones The 'direct is the LAST resort' case counted two refusal traces for two dead hops but took both ports from freePort(), which gives the number back before returning. The kernel can return the same number to the next call (the relay folds a repeated hop into one) or to the relay's carrier (the relay drops its own address as a hop), and a neighbouring process's listener on a freed port answers as a live hop. Each leaves one trace: '1 refusals traced, want one per hop ... 1 !== 2', measured at ~1% of runs with 40 concurrent runners and reproduced deterministically by forcing either collision. Use ports 1 and 2, as proxy-forward-attach-fallback.test.mjs already does for its dead hop: an unprivileged runner cannot bind below 1024 and a connect there refuses at once. The two other cases in the file that dialled a freePort() hop (the second-hop fall-through, whose trace assertion fails the same way, and REQUIRE_HOP, which passed while silently measuring a no-hop chain) take the same constants. Co-Authored-By: Claude --- test/gap-relay-chain.test.mjs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/gap-relay-chain.test.mjs b/test/gap-relay-chain.test.mjs index a8307c56..8c7f5198 100644 --- a/test/gap-relay-chain.test.mjs +++ b/test/gap-relay-chain.test.mjs @@ -19,10 +19,15 @@ import net from "node:net"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { freePort } from "./proc-helpers.mjs"; const relayPath = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "gap-relay.mjs"); +// A DEAD HOP IS A PORT NOBODY CAN BIND, not a freed one: the kernel can hand that +// number to the next call or the relay's carrier, and the relay folds a repeated +// hop into one and drops its own address, so two refusals traced as one (1 !== 2 +// on CI). Below 1024 an unprivileged runner cannot bind; connecting refuses at once. +const DEAD1 = 1, DEAD2 = 2; + // An endpoint that records being reached and answers a CONNECT. Every one of // them records, so a failure names which was touched instead of leaving an // empty set — an assertion that fires on `[]` has already discarded the @@ -144,9 +149,8 @@ const connectThrough = (port, target) => new Promise((resolve) => { test("refuses rather than dialling direct when CACHE_FIX_REQUIRE_HOP=1", async () => { const touched = []; const origin = await endpoint("ORIGIN", touched); - const dead = await freePort(); try { - await withRelay(`http://127.0.0.1:${dead}`, async ({ port, stderr }) => { + await withRelay(`http://127.0.0.1:${DEAD1}`, async ({ port, stderr }) => { const reply = await connectThrough(port, `127.0.0.1:${origin.port}`); await new Promise((r) => setTimeout(r, 400)); assert.deepEqual(touched, [], @@ -259,9 +263,8 @@ test("a refused first hop falls to the SECOND, not straight to a direct dial", a const touched = []; const origin = await endpoint("ORIGIN", touched); const hop2 = await endpoint("HOP2", touched); - const dead = await freePort(); try { - await withRelay(`http://127.0.0.1:${dead},http://127.0.0.1:${hop2.port}`, async ({ port, stderr }) => { + await withRelay(`http://127.0.0.1:${DEAD1},http://127.0.0.1:${hop2.port}`, async ({ port, stderr }) => { const reply = await connectThrough(port, `127.0.0.1:${origin.port}`); await new Promise((r) => setTimeout(r, 300)); // Carrying VIA A HOP: the hop's own reply is piped straight back, so the @@ -284,9 +287,8 @@ test("a refused first hop falls to the SECOND, not straight to a direct dial", a test("direct is the LAST resort, reached only when no hop will carry", async () => { const touched = []; const origin = await endpoint("ORIGIN", touched); - const dead1 = await freePort(), dead2 = await freePort(); try { - await withRelay(`http://127.0.0.1:${dead1},http://127.0.0.1:${dead2}`, async ({ port, stderr }) => { + await withRelay(`http://127.0.0.1:${DEAD1},http://127.0.0.1:${DEAD2}`, async ({ port, stderr }) => { const reply = await connectThrough(port, `127.0.0.1:${origin.port}`); await new Promise((r) => setTimeout(r, 300)); // AND ON THE DIRECT PATH TOO, where the 200 is ours to write rather than From b86c73ccf5747eaca04731b37a7339ee91c8a33d Mon Sep 17 00:00:00 2001 From: Junyong Lee Date: Sat, 10 Oct 2026 02:24:53 -0400 Subject: [PATCH 9/9] fix(model-tables): price claude-sonnet-5-5 cache reads at $0.10/MTok Claude Code 2.1.296 and the pricing page as of 2026-10-10 both price Sonnet 5.5 cache reads at $0.10 per MTok (was $0.20), 0.05x its $2 input. rates.json still carried $0.20, so tools/cost-report.mjs over-counted a 1M-read call by $0.10, and update-rates.mjs refused the live row ("contradicts the documented 0.1x input multiplier"), leaving rates.json untouched on every refresh. rates.json takes $0.10. update-rates.mjs adds the 0.05x override Opus 5.5 already uses, gated to pages dated 2026-10-10 or later: the 2026-09-28 page and the 2026-07-27 fixture still say $0.20 and keep parsing at their own dates. New fixture pricing-page-2026-10-10.html (the model-pricing table of the live page). The "rates.json agrees with the page" check now reads it, since it is the newest page; the older fixtures' own parse assertions are unchanged. cost-report gains a Sonnet 5.5 case beside the Opus 5.5 one. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- test/cost-report.test.mjs | 17 +++++++++--- test/fixtures/pricing-page-2026-10-10.html | 1 + test/tools-update-rates.test.mjs | 32 +++++++++++++++------- tools/rates.json | 2 +- tools/update-rates.mjs | 12 +++++--- 6 files changed, 46 insertions(+), 20 deletions(-) create mode 100644 test/fixtures/pricing-page-2026-10-10.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ecfbc39..6a5f28ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - **`session-budget-breaker` now accrues streamed turns, so its ceilings can fire on real traffic.** `onStreamEvent` read the session id from `ctx.headers`, but the stream ctx built in `proxy/stream.mjs` carries `{event, meta, telemetry, responseHeaders, drop}` and no request headers, so no streamed request ever accrued: the token, cost and rate levers all stayed at zero, and Claude Code streams every turn. It now reads the id `onRequest` stashed on the shared `meta`, as `onResponse` already did for `stream:false`. The suite had passed only because every stream fixture handed in `headers`; they now model the real ctx, and one test drives the shipped `streamResponse` end to end. -- **`claude-opus-5-5` is now priced, tracked for divergence, and no longer billed as `claude-opus-5`; `claude-sonnet-5-5` and `claude-haiku-5-5` are now priced too.** The model tables (`tools/rates.json`, `tools/update-rates.mjs`, `proxy/model-families.mjs`) had no entry for it, so `tools/cost-report.mjs`'s `lookupRates()` matched the first `rates.json` key in file order where either id prefixed the other — `claude-opus-5`'s rates, 25% too high on input/output and off by more than 2x on the cache-read discount the pricing page documents for this model — and bare `claude-opus-5` / `claude-sonnet-5` read as `"unknown"` in `proxy/model-families.mjs`, so a request served by a different family only latched `model_divergence_sticky` on the third divergent turn instead of the first. `claude-sonnet-5-5` (Claude Code's default Sonnet since 2.1.284) had no row in `tools/rates.json` or `tools/update-rates.mjs` either, so a refresh never wrote it and the session-budget-breaker's exact-id lookup counted no cost for it; both carry it now, and `tools/probe-tool-addition.mjs` probes it in place of `claude-sonnet-5`. `claude-haiku-5-5` (Claude Code 2.1.293's default Haiku) had no row either, and bare `claude-haiku-5` read as `"unknown"` in `proxy/model-families.mjs`. It is priced at the lower tier of the page's two ($0.10 input / $0.50 output per MTok for prompts up to 100,000 tokens), hand-written in `tools/rates.json` because the tiered row does not parse and a refresh keeps entries the parse does not find, so prompts over 100,000 tokens (which the page prices at $0.50 / $2.50) are under-counted. +- **`claude-opus-5-5` is now priced, tracked for divergence, and no longer billed as `claude-opus-5`; `claude-sonnet-5-5` and `claude-haiku-5-5` are now priced too.** The model tables (`tools/rates.json`, `tools/update-rates.mjs`, `proxy/model-families.mjs`) had no entry for it, so `tools/cost-report.mjs`'s `lookupRates()` matched the first `rates.json` key in file order where either id prefixed the other — `claude-opus-5`'s rates, 25% too high on input/output and off by more than 2x on the cache-read discount the pricing page documents for this model — and bare `claude-opus-5` / `claude-sonnet-5` read as `"unknown"` in `proxy/model-families.mjs`, so a request served by a different family only latched `model_divergence_sticky` on the third divergent turn instead of the first. `claude-sonnet-5-5` (Claude Code's default Sonnet since 2.1.284) had no row in `tools/rates.json` or `tools/update-rates.mjs` either, so a refresh never wrote it and the session-budget-breaker's exact-id lookup counted no cost for it; both carry it now, and `tools/probe-tool-addition.mjs` probes it in place of `claude-sonnet-5`. `claude-haiku-5-5` (Claude Code 2.1.293's default Haiku) had no row either, and bare `claude-haiku-5` read as `"unknown"` in `proxy/model-families.mjs`. It is priced at the lower tier of the page's two ($0.10 input / $0.50 output per MTok for prompts up to 100,000 tokens), hand-written in `tools/rates.json` because the tiered row does not parse and a refresh keeps entries the parse does not find, so prompts over 100,000 tokens (which the page prices at $0.50 / $2.50) are under-counted. `claude-sonnet-5-5` cache reads are priced at $0.10 per MTok (0.05x input, as `claude-opus-5-5`'s are), not $0.20: Claude Code 2.1.296 and the pricing page as of 2026-10-10 both say so. `tools/update-rates.mjs` accepts that ratio for it from that page's date on, so a refresh no longer refuses the live row, while an older page's $0.20 still parses at its own date. - **A refused fd-3 handover no longer makes the proxy claim it handed the socket on.** `inheritedSocket` was computed from "handover was attempted", not "handover succeeded", so a proxy that was refused fd 3 and fell back to binding its own port still advertised an inherited socket. On `SIGTERM` it then spawned a successor pointed at the same unservable descriptor and exited `75` — telling the supervisor a successor holds the socket — while the port it actually served was released with nobody on it. Exits `0` now, spawns nothing, and leaves no orphan. diff --git a/test/cost-report.test.mjs b/test/cost-report.test.mjs index 879f70b4..deb21df1 100644 --- a/test/cost-report.test.mjs +++ b/test/cost-report.test.mjs @@ -11,9 +11,10 @@ import { dirname, join } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCRIPT = join(__dirname, "..", "tools", "cost-report.mjs"); -test("prices a claude-opus-5-5 call from the bundled rates.json", () => { +// Cost of 1M input + 1M output + 1M cache-read tokens on `model`, from the bundled rates.json. +function costOf(model) { const usage = JSON.stringify({ - model: "claude-opus-5-5", + model, input_tokens: 1_000_000, output_tokens: 1_000_000, cache_read_input_tokens: 1_000_000, @@ -25,7 +26,15 @@ test("prices a claude-opus-5-5 call from the bundled rates.json", () => { }); assert.equal(result.status, 0, `cost-report exited ${result.status}: ${result.stderr}`); - const report = JSON.parse(result.stdout); - assert.equal(report.calls[0].cost, 24.2, + return JSON.parse(result.stdout).calls[0].cost; +} + +test("prices a claude-opus-5-5 call from the bundled rates.json", () => { + assert.equal(costOf("claude-opus-5-5"), 24.2, "1M input + 1M output + 1M cache-read at Opus 5.5's own rates (4/20/0.2 $ per MTok)"); }); + +test("prices a claude-sonnet-5-5 call from the bundled rates.json", () => { + assert.equal(costOf("claude-sonnet-5-5"), 12.1, + "1M input + 1M output + 1M cache-read at Sonnet 5.5's own rates (2/10/0.1 $ per MTok)"); +}); diff --git a/test/fixtures/pricing-page-2026-10-10.html b/test/fixtures/pricing-page-2026-10-10.html new file mode 100644 index 00000000..000eeb15 --- /dev/null +++ b/test/fixtures/pricing-page-2026-10-10.html @@ -0,0 +1 @@ +
ModelBase tokensPrompt caching
NameInputOutput
Claude Fable 5.1For demanding reasoning and long-horizon agentic work
$10 /
$50 / MTok
$12.50 / MTok
$20 / MTok
/ MTok
Claude Opus 5.5For long-running agentic coding and knowledge work
$4 / MTok
$20 / MTok
$5 / MTok
$8 / MTok
/ MTok
Claude Sonnet 5.5The best combination of speed and intelligence
$2 / MTok
$10 / MTok
$2.50 / MTok
$4 / MTok
/ MTok
Claude Haiku 5.5For high-volume, latency-sensitive tasks such as classification, extraction, and routing
$0.10 / MTokfor prompts up to 100,000 tokens
$0.50 / MTok
$0.125 / MTok
$0.20 / MTok
$0.01 / MTok
$0.50 / MTokfor prompts over 100,000 tokens
$2.50 / MTok
$0.625 / MTok
$1 / MTok
$0.05 / MTok
$10 / MTok
$50 / MTok
$12.50 / MTok
$20 / MTok
/ MTok
$10 / MTok
$50 / MTok
$12.50 / MTok
$20 / MTok
$1 / MTok
$10 / MTok
$50 / MTok
$12.50 / MTok
$20 / MTok
$1 / MTok
$5 / MTok
$25 / MTok
$6.25 / MTok
$10 / MTok
$0.50 / MTok
$5 / MTok
$25 / MTok
$6.25 / MTok
$10 / MTok
$0.50 / MTok
$5 / MTok
$25 / MTok
$6.25 / MTok
$10 / MTok
$0.50 / MTok
$5 / MTok
$25 / MTok
$6.25 / MTok
$10 / MTok
$0.50 / MTok
$5 / MTok
$25 / MTok
$6.25 / MTok
$10 / MTok
$0.50 / MTok
Claude Opus 4.1
$15 / MTok
$75 / MTok
$18.75 / MTok
$30 / MTok
$1.50 / MTok
Claude Opus 4
$15 / MTok
$75 / MTok
$18.75 / MTok
$30 / MTok
$1.50 / MTok
/ MTok
/ MTok
$2.50 / MTok
$4 / MTok
$0.20 / MTok
$3 / MTok
$15 / MTok
$3.75 / MTok
$6 / MTok
$0.30 / MTok
$3 / MTok
$15 / MTok
$3.75 / MTok
$6 / MTok
$0.30 / MTok
Claude Sonnet 4
$3 / MTok
$15 / MTok
$3.75 / MTok
$6 / MTok
$0.30 / MTok
$1 / MTok
$5 / MTok
$1.25 / MTok
$2 / MTok
$0.10 / MTok
Claude Haiku 3.5
$0.80 / MTok
$4 / MTok
$1 / MTok
$1.60 / MTok
$0.08 / MTok
diff --git a/test/tools-update-rates.test.mjs b/test/tools-update-rates.test.mjs index 4d1cf27f..60b81846 100644 --- a/test/tools-update-rates.test.mjs +++ b/test/tools-update-rates.test.mjs @@ -18,10 +18,19 @@ import { parsePricing } from "../tools/update-rates.mjs"; // every other block dropped. It has TWO header rows and a different column // order from the 2026-07-27 page (Output right after Input, before the cache // columns). +// +// pricing-page-2026-10-10.html is the model-pricing of the live page as +// of 2026-10-10, fetched through the proxy (1135336 bytes, sha256 +// 919f8fcf0e1d280c2a0df977ab7319c0b71d7e110cf9f01f1c2cb074e7e3fa6e) with every +// other block dropped. It is the newest page, so the checked-in rates.json is +// compared against it. The older fixtures keep what their pages said: Sonnet +// 5.5 cache reads at $0.20/MTok there, $0.10/MTok here. const __dirname = dirname(fileURLToPath(import.meta.url)); const FIXTURE = readFileSync(join(__dirname, "fixtures", "pricing-page-2026-07-27.html"), "utf8"); const LIVE_0928 = readFileSync(join(__dirname, "fixtures", "pricing-page-2026-09-28.html"), "utf8"); +const LIVE_1010 = readFileSync(join(__dirname, "fixtures", "pricing-page-2026-10-10.html"), "utf8"); +const D1010 = Date.parse("2026-10-10T12:00:00Z"); // Prices are read in the column order the table's own header names, so every // inline table below carries a header row (a table without one yields no rows). @@ -93,13 +102,15 @@ test("the two-header-row page of 2026-09-28 parses every required model with no // Output follows Input on this page; read by position it landed in cache_write_5m. assert.deepEqual(rates["claude-opus-5-5"], { input: 4, output: 20, cache_read: 0.2, cache_write_5m: 5, cache_write_1h: 8 }); - const shipped = JSON.parse(readFileSync(join(__dirname, "..", "tools", "rates.json"), "utf8")).models; - for (const id of ["claude-fable-5", "claude-opus-5", "claude-opus-5-5", "claude-opus-4-8", "claude-opus-4-7", - "claude-opus-4-6", "claude-sonnet-5", "claude-sonnet-5-5", "claude-haiku-4-5"]) { - const { note, ...s } = shipped[id]; - // Compared as JSON so key order counts: an unchanged page must stay a byte-identical no-op. - assert.equal(JSON.stringify(rates[id]), JSON.stringify(s), `${id} disagrees with the 2026-09-28 table`); - } +}); + +test("the 2026-10-10 page prices Claude Sonnet 5.5 cache reads at $0.10, 0.05x input, with no errors", () => { + const { rates, errors } = parsePricing(LIVE_1010, D1010); + assert.deepEqual(errors, []); + assert.deepEqual(rates["claude-sonnet-5-5"], + { input: 2, output: 10, cache_read: 0.1, cache_write_5m: 2.5, cache_write_1h: 4 }); + // From that date the 2026-09-28 page's $0.20 is a stale price, not a 0.1x row to accept. + assert.ok(parsePricing(LIVE_0928, D1010).errors.some((e) => /claude-sonnet-5-5\.cache_read=0\.2 /.test(e))); }); test("a header that does not name each price column once is refused, not read by position", () => { @@ -275,14 +286,15 @@ test("parsing is deterministic for a fixed input and date", () => { // --- the shipped file matches the parser --- -test("checked-in rates.json agrees with the fixture parse for required models", () => { +test("checked-in rates.json agrees with the newest page's parse for required models", () => { const shipped = JSON.parse(readFileSync(join(__dirname, "..", "tools", "rates.json"), "utf8")).models; - const { rates } = parsePricing(FIXTURE, Date.parse("2026-07-27T12:00:00Z")); + const { rates } = parsePricing(LIVE_1010, D1010); for (const id of ["claude-fable-5", "claude-opus-5", "claude-opus-5-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-5", "claude-sonnet-5-5", "claude-haiku-4-5"]) { assert.ok(shipped[id], `rates.json is missing ${id} — the cost lever prices it at zero`); const { note, ...s } = shipped[id]; - assert.deepEqual(s, rates[id], `rates.json ${id} disagrees with the published table`); + // Compared as JSON so key order counts: an unchanged page must stay a byte-identical no-op. + assert.equal(JSON.stringify(rates[id]), JSON.stringify(s), `rates.json ${id} disagrees with the 2026-10-10 table`); } }); diff --git a/tools/rates.json b/tools/rates.json index 36e3dc87..96bf9ce9 100644 --- a/tools/rates.json +++ b/tools/rates.json @@ -118,7 +118,7 @@ "claude-sonnet-5-5": { "input": 2, "output": 10, - "cache_read": 0.2, + "cache_read": 0.1, "cache_write_5m": 2.5, "cache_write_1h": 4 }, diff --git a/tools/update-rates.mjs b/tools/update-rates.mjs index 2ccd7d35..cf6666a8 100644 --- a/tools/update-rates.mjs +++ b/tools/update-rates.mjs @@ -112,7 +112,10 @@ const MULTIPLIER_EPSILON = 0.011; // reads at 0.05x input, per the pricing page's footnote). Only the named field // on the named wire id is affected; every other row and every other field // still goes through the standard MULTIPLIERS check above. -const CACHE_READ_MULTIPLIER_OVERRIDES = { "claude-opus-5-5": 0.05 }; +const CACHE_READ_MULTIPLIER_OVERRIDES = { "claude-opus-5-5": 0.05, "claude-sonnet-5-5": 0.05 }; +// Sonnet 5.5's cache-read cut ($0.20 → $0.10, Claude Code 2.1.296) is first seen on the +// 2026-10-10 page. A page dated before that is still read at the standard 0.1x. +const CACHE_READ_OVERRIDE_SINCE = { "claude-sonnet-5-5": Date.UTC(2026, 9, 10) }; const MONTHS = { january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, @@ -240,13 +243,14 @@ function namesModel(nameCell, display) { // Validate one row's numbers before it can reach rates.json. Returns an error // string (fail closed) or null when the row is trustworthy. -function validateRates(wireId, r) { +function validateRates(wireId, r, atMs) { for (const [field, v] of Object.entries(r)) { if (typeof v !== "number" || !Number.isFinite(v)) return `${wireId}.${field} is not a finite number`; if (v < MIN_PRICE || v > MAX_PRICE) return `${wireId}.${field}=${v} outside sane range $${MIN_PRICE}–$${MAX_PRICE}`; } for (const [field, mult] of Object.entries(MULTIPLIERS)) { - const effectiveMult = field === "cache_read" && wireId in CACHE_READ_MULTIPLIER_OVERRIDES + const effectiveMult = field === "cache_read" && wireId in CACHE_READ_MULTIPLIER_OVERRIDES && + atMs >= (CACHE_READ_OVERRIDE_SINCE[wireId] ?? 0) ? CACHE_READ_MULTIPLIER_OVERRIDES[wireId] : mult; const expected = r.input * effectiveMult; @@ -317,7 +321,7 @@ export function parsePricing(html, atMs = Date.now()) { const { name, ...r } = chosen; for (const id of wireIds) { - const err = validateRates(id, r); + const err = validateRates(id, r, atMs); if (err) { errors.push(err); continue; } rates[id] = { ...r }; }