feat(pricing): monitor the rate table against two public sources - #129
feat(pricing): monitor the rate table against two public sources#129c-1k wants to merge 7 commits into
Conversation
PRICING_TABLE ships to customers in the published package and had no drift monitoring of any kind: PRICING_TABLE_VERSION is a hand-typed constant, and nothing fetched, compared, or expired. A stale rate does not merely overcharge — receipt.pricing.appliedRates plus tableVersion are what let a third party recompute a metered cost from the record alone, so a wrong rate produces receipts that are internally consistent, cryptographically sound, and externally wrong, and the hash chain preserves them faithfully. Adds a weekly checker that cross-checks all 26 models against LiteLLM and models.dev (both MIT). It REPORTS ONLY — `contents: read` means GitHub refuses a push, so no upstream value can reach pricing.ts through this path. Findings land in one deduped issue that closes when a run comes back clean. Measured on the shipped table: 16 exact four-tier agreements, 1 expected deviation, 2 cross-source conflicts, 7 models with no upstream source. Four rules carry the weight: - VENDOR PINNING. models.dev carries 190 providers, mostly resellers; an unpinned matcher answered claude-fable-5 at 30/185 against a true 100/500 and claude-opus-5 at a regional premium. Both sources are pinned to the vendor and a non-vendor row is treated as absent, never as an answer. Mapping is explicit per model — `command-a` would otherwise fuzzy-match a translation model's rate. - DEVIATIONS ONLY IN THE SAFE DIRECTION. Both sources publish claude-sonnet-5 at the introductory rate the table deliberately does not carry, so "differs from upstream" must not mean "we are wrong". An allowlist entry suppresses failure only while our rate is >= upstream; understatement is never allowlistable. That enforces the D1 invariant rather than restating it. - EXIT 2 IS NEVER 0. "Could not check" and "checked, found nothing" are different states. A coverage floor derived from the map (not a transcribed constant) turns a silent upstream schema change into a failure instead of a clean sweep of nothing. - ONLY `agree` IS SILENT. Every model is assigned exactly one outcome, the counts must sum to the table size, and every other outcome reports by construction. The suite carries a positive control — a rate mutated below upstream that MUST be reported — beside a negative control. An all-pass suite cannot detect an instrument that is disconnected. The unit conversion is pinned to a real table entry rather than a literal, because a test asserting convert(5) === 5 passes against a broken identity conversion. The pure decision-rule tests also run in ci.yml; the live comparison stays weekly so no network fetch can flake a PR. Signed-off-by: Cam <cam@camwhiteus.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Cam <cam@camwhiteus.com>
Review of the initial implementation found four P1s and a P2. Three of the P1s were one underlying mistake — electing a single source as "the" consensus — and the fourth was the monitor exhibiting, in its own instrument, the silent degradation it exists to detect. Comparison is now per-tier across EVERY source, and understatement is proven against the minimum upstream value. - Omitted cache tiers no longer assumed conservative. An absent field meters at inputPer1k via the D1 fallback, which is conservative for a cache-READ discount (~0.1x input) but not for a cache-WRITE premium (1.25x input), where it understates by 20%. Understatement is judged on the effective metered rate, never on field presence. No live instance in the table today; it would have passed silently the day one appeared. - Understatement survives cross-source conflict. Previously any conflicting tier skipped all comparison, so ours=50 against sources 60 and 70 exited 0 — low on either reading. Conflict now suppresses only the value proposal, never the understatement check. - Tiers merge across sources. sourcesAgree treated a missing tier as compatible while consensus came from one source, so a tier only the other source published was discarded entirely, and reversing source order changed the verdict. Tested for order-independence. - Schema sentinels validate the fetched corpora BY NAME at ingest. The old "schema pin" test compared a constant to a hardcoded copy of itself and never touched upstream data. A renamed optional field would leave input/output resolving, every row answered, coverage full, and the run exiting 0 with cache comparison silently disabled. Removed the tautological test rather than leave it reading as coverage. - Cache gaps render for agreeing models, which have no section of their own and so previously produced no row at all. Live verdict is unchanged (16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, exit 0): these close latent holes rather than change today's answer. 40 tests, still no network in the suite. Signed-off-by: Cam <cam@camwhiteus.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Cam <cam@camwhiteus.com>
Codex review — round 1Verdict: REVISE — 4 P1, 1 P2.
The review named the changed files (103 references), so this is a real pass, not a no-op against an empty diff. Findings
All five accepted as real and fixed in 86929ec. Three of the four P1s were one underlying mistake — electing a single source as "the" consensus rather than resolving each tier across all sources — so the fix was structural rather than five patches. The two worth calling outThe monitor had, in its own instrument, the defect it exists to detect. The "schema pin" test asserted An assumption carried over from the design was wrong. I had written that an omitted cache tier is always conservative, because it meters at Note Live verdict unchanged after the fixes: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, exit 0. Round 2 running against 86929ec. |
…trument Second review round: 3 P1, 4 P2, none of them repeats. The first P1 is the previous round's fix reappearing one abstraction higher. Round 1 stopped a source conflict from masking UNDERSTATEMENT but left it masking plain DISAGREEMENT: ours 75/250 against sources 50/200 and 50/250 returned a non-failing source-conflict while input was a unanimous, unallowlisted mismatch. Definitive diffs are now classified before conflict; conflict wins only when every diff is conflicted. - The workflow could fail silently. If `drift:test` failed, every later step was implicitly gated on success(), so the tracking-issue step was skipped and the monitor merely turned red — recording nothing, which is the option this design explicitly rejected. The reporting step is now `always()` gated, carries the self-test outcome, and says which stage broke. Noted in place why this is not the `always()` anti-pattern: that rule governs steps reporting SUCCESS, and here success() is what breaks it. - The schema sentinel counted property NAMES. An upstream keeping a deprecated key holding null while moving the numeric price elsewhere would clear the floor while the normalizer dropped every value. It now counts usable typed values. - A process death before run.mts writes its report yields status 1, 127 or 137 with no file; the drift branch would then cat a missing file and abort under set -e, producing no issue. Drift now requires exit 1 AND a non-empty report; everything else routes to the could-not-run body. - effectiveRate duplicated D1's fallback instead of calling resolveAppliedRates, and had already diverged: a negative finite cache rate meters at inputPer1k in the SDK but compared as negative here. AGENTS.md forbids a second resolution site, and a monitor that resolves rates differently from the thing it monitors measures the wrong quantity. - Tier consensus keeps min AND max. An omitted tier is benign only when it meters at or above EVERY source; between min and max it is above one and below another, so it is reported rather than filed as safe. Conflicted tiers render as a range. - --json now carries orphanDeviations and a merged failed flag, so an orphan-only failure is no longer a non-zero exit beside `failed: false`. Live verdict unchanged: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, exit 0. 44 tests, no network. Signed-off-by: Cam <cam@camwhiteus.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Cam <cam@camwhiteus.com>
Codex review — round 2Verdict: REVISE — 3 P1, 4 P2. No repeats from round 1.
211 references to the changed files, so a real pass. Findings
All seven accepted and fixed in 2cd264b. The one worth dwelling onThe first P1 is round 1's fix reappearing one abstraction higher. Round 1 stopped a source conflict from masking understatement. It did not stop a conflict from masking plain disagreement: ours 75/250 against sources 50/200 and 50/250 returned a non-failing Two others were the monitor failing to monitor itself. If The schema sentinel counted property names, so an upstream keeping a deprecated key holding
Live verdict unchanged across all three rounds: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, exit 0. 44 tests, no network. Round 3 running against 2cd264b. |
Third review round: 1 P1, 2 P2. Findings are converging (5 → 7 → 3). The P1 is the coverage floor at the wrong granularity. It counted MODELS with at least one answering source, so if LiteLLM renamed `gpt-4o` while models.dev still answered, the model stayed "corroborated", coverage held at 19/19, and the run came back clean — with one of the two independent checks silently gone. The whole point of two sources is that they are independent; a gate that cannot notice one disappearing is not guarding that. Coverage is now counted over (source, model) pairs: 36/36 today, where the model-level view showed 19/19. - The source-conflict note claimed our rate was "not below any of them". With sources at 50 and 70 and ours at 60 that is false, and since the outcome does not fail, the note actively reassured about a possible underestimate. It now says only that no definitive value can be selected, plus the one thing that is true: we are not below the lowest. - A conservative omitted tier on a CONFLICTED tier recorded only a cache gap, so the model was still classified source-conflict but rendered "—", hiding the values that caused the conflict. The range is now retained alongside the gap. Live verdict unchanged for the fourth consecutive round: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, exit 0. 48 tests, no network. Signed-off-by: Cam <cam@camwhiteus.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Cam <cam@camwhiteus.com>
…nels Fourth review round: 3 P1, 2 P2. The sharpest inverts a claim made two commits ago. The coverage floor was praised for being DERIVED from MODEL_MAP and therefore self-maintaining — but a gate computed from the thing it guards is not a gate. Setting one model's `litellm` to `null` lowers the actual and expected counts together, so 35/35 passes and an independent check is lost with nothing moving. Both checks now run: the derived one catches a source dropping a model, and checked-in MIN_MAPPINGS / MIN_CORROBORATED_MODELS catch the map being weakened. Lowering either is an explicit, reviewable edit, which is the point. Floors are passed in rather than hardcoded so compareTable stays drivable from fixtures. - A rate ABOVE every conflicting source is definite drift. Sources at 50 and 70 with our table at 100 marked every diff conflicted and exited 0, though no source supports 100. Only a rate INSIDE the range is undecidable. - Schema sentinels are scoped to mapped vendor rows. A corpus-wide count is satisfied by rows this tool never reads: a mapped provider could rename a cache field while a few unrelated or reseller rows kept the old name, and the sentinel would pass while every mapped row normalized that tier away. Counted over the 17 mapped LiteLLM rows instead. - Missing mappings are NAMED. The banner said 35/36 and, since agreeing rows are omitted, never identified which pair vanished. - gh commands set GH_REPO. A failed checkout leaves no git remote, so the always() reporting steps reached `gh issue list`, which could not infer the repository and exited under set -e — filing no issue for precisely the could-not-run case that most needs one. Live verdict unchanged for the fifth consecutive round: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, exit 0, 36/36 mappings. 52 tests, no network. Signed-off-by: Cam <cam@camwhiteus.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Cam <cam@camwhiteus.com>
Codex review — rounds 3 and 4Both passes named the changed files (113 and 213 references). All seven findings accepted and fixed — round 3 in 23009a8, round 4 in 4b36ce5. Round 3 — 1 P1, 2 P2
The P1 was the coverage floor at the wrong granularity. It counted models with at least one answering source, so if LiteLLM renamed Round 4 — 3 P1, 2 P2
The second P1 inverts a claim I made in the round-3 commit. I described the derived coverage floor as self-maintaining and treated that as a virtue. It is the weakness: a gate computed from the thing it guards is not a gate. Setting one model's The sentinel scoping is the same shape one level down: counting fields across the whole 3,040-row corpus is satisfied by rows this tool never reads, so a mapped provider could rename a cache field while a handful of reseller rows kept the old name. Now counted over the 17 mapped LiteLLM rows. Where this standsFive rounds, 20 findings, every one accepted as real. The live verdict has been byte-identical through all of them — 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, exit 0. Every fix closed a path by which a future run could report clean while something was wrong; none changed today's answer. That is the expected shape for this kind of tool, and it is why the review rounds were worth spending. 52 tests, no network in the suite. Round 5 running against 4b36ce5. Recurring theme worth recording: three separate rounds found the same defect at successively higher levels of abstraction — conflict masking understatement (r1), conflict masking disagreement (r2), then a rate above every source still classified as conflict (r4). And twice the monitor carried, in its own instrument, the failure mode it exists to detect: a schema test that compared a constant to itself, and a coverage floor derived from the map it was guarding. |
…o master Fifth review round: 1 P1, 3 P2. The P1 is the schema sentinel's floor being shared across fields. A single number is cleared by unaffected rows while a PARTIAL rename — one provider, or one field — strips the tier from every other mapped row. Those rows still answer, because input and output survive, so mapping coverage stays full and a changed cache rate can be classified `agree`. Worst on a one-source model like kimi-k3, where nothing else can contradict it. Replaced with per-source, per-field baselines measured against the mapped rows (litellm 17/17/17/17/8, models.dev 19/19/18/8). They are a floor, not an equality: upstream adding cache rates is fine, losing them is not, and a legitimate drop is a deliberate edit. Mutation-tested against the live feeds: raising a baseline produces exit 2 with the field named. - Range classification now uses the EFFECTIVE rate. A cacheReadPer1k of -5 resolves to inputPer1k under the canonical rule, so comparing raw -5 to conflicting sources at 5 and 10 read as in-range and passed while the SDK was charging 50. - Absolute floor breaches are carried on the report. Both counts can equal their derived expectation while sitting below the checked-in floor, with no missing mappings and every model showing `agree` — the workflow would then open a drift issue containing no failing reason. - Issue mutation is restricted to the default branch. workflow_dispatch accepts any ref and checkout honours it, but the tracking issue is repository-wide: a clean feature branch would close an issue describing drift on master, and a dirty one would overwrite it. Non-default refs still run the check and still fail the job; they no longer rewrite shared state. Live verdict unchanged for the sixth consecutive round: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, exit 0, 36/36 mappings. 55 tests, no network. Signed-off-by: Cam <cam@camwhiteus.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Cam <cam@camwhiteus.com>
Codex review — round 5
All four fixed in df416db. The P1 is the schema sentinel's floor being shared across fields. One number is cleared by unaffected rows while a partial rename — one provider, or one field — strips the tier from every other mapped row. Those rows still answer, because input and output survive, so coverage stays full and a changed cache rate reads as Now per-source, per-field baselines measured against the mapped rows (litellm Mutation-tested against the live feeds rather than only fixtures. Raising one baseline from 8 to 99 produces: That is a positive control on the real path, not just in the suite. Stopping pointSix rounds, 24 findings, every one accepted as real: 5 → 7 → 3 → 5 → 4. Round 6 is running against df416db. My intent is to treat it as the last gate: fix anything P1, and ledger any remaining P2/P3 as residuals in this PR rather than continue indefinitely. This is a report-only tool — The live verdict has been byte-identical through all six rounds — 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, exit 0, 36/36 mappings. Not one fix changed today's answer; each closed a path by which a future run could report clean while something was wrong. For a monitor, that is the whole product, and it is why the rounds were worth spending. Two patterns are worth recording beyond this PR: The same defect kept reappearing one abstraction higher. Conflict masking understatement (r1) → conflict masking disagreement (r2) → a rate above every source still classified as conflict (r4) → a malformed rate compared raw instead of effective (r5). Four rounds, one idea: a comparison that cannot conclude something must not thereby conclude nothing is wrong. Twice the monitor carried the exact failure mode it exists to detect. A schema test that compared a constant to itself, and a coverage floor derived from the map it was guarding. Both read as coverage while providing none — which is the definition of the thing this tool was built to catch. |
Sixth review round: 1 P1, 2 P2. The first two attempts at this gate died without a verdict (one on a malformed shell command of its own, one cut off mid-investigation); neither was read as clean. The third produced findings. The P1 is the third narrowing of the same check, and the previous two were both still too coarse. A shared floor is cleared by unaffected rows. Per-field totals are only a LOWER BOUND, so an upstream ADDITION creates headroom: once one row gains cache_write, a later loss on a different row keeps the total at the baseline and passes, while that row still answers from input/output and the comparison silently skips its missing tier. Replaced with a per-(source, model, tier) expectation — 51 entries measured against the live feeds. A specific model losing a specific tier is now a specific, named failure. Mutation-tested end to end: removing kimi-k3's cache_write expectation-holder yields exit 2 with "kimi-k3 lost cache_write" rather than a count that still sums correctly. - A non-finite required rate reported `agree`. rawTier rejects Infinity and NaN, so inputPer1k: Infinity fell through the cache-absence path — required tiers are only excluded from cacheGaps — and emitted no diff at all, for a model whose metered cost is not a number. Now a `malformed-rate` outcome, which also rejects negatives: a negative CACHE tier has a defined meaning (it resolves to inputPer1k), but a negative input or output has nothing to fall back to. - The default-branch guard admitted a tag. github.ref_name omits the ref namespace, so a workflow_dispatch on a tag sharing master's short name could close or overwrite master's tracking issue using tagged code. Both issue steps now also require github.ref_type == 'branch'. Live verdict unchanged for the seventh consecutive round: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, 0 malformed, exit 0, 36/36 mappings. 60 tests, no network. Biome back to the 39-warning master baseline. Signed-off-by: Cam <cam@camwhiteus.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Cam <cam@camwhiteus.com>
Codex review — round 6, and the gate is now blocked
All three fixed in 99afd36. This round took three attempts. The first died on a malformed shell command Codex generated for itself; the second was cut off mid-investigation. Neither produced a findings block or a verdict sentence, and neither was read as clean — an absent verdict is a dead review, not a passing one. Only the third attempt concluded. The P1 is the third narrowing of the same check, and the first two were both still too coarse
Now a per- A named model and a named tier, where the aggregate version returned a total that still summed correctly. Also: a non-finite required rate reported
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 99afd36efb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const row = table[entry.litellm.key]; | ||
| if (typeof row !== "object" || row === null) continue; | ||
| const r = row as Record<string, unknown>; | ||
| // Vendor pin: a reseller row keeping an old field name proves nothing. | ||
| if (r.litellm_provider !== entry.litellm.provider) continue; |
There was a problem hiding this comment.
Reject wholesale disappearance of mapped source rows
When LiteLLM returns an object but its table shape changes (for example, {} or a new wrapper around all rows), every mapped lookup reaches this continue, problems remains empty, and the schema sentinel accepts the response. The run then classifies the resulting coverage loss as ordinary drift with exit 1, despite main() reserving exit 2 and the “could not run” issue path for schema mismatches. Record missing/provider-mismatched mapped rows as schema problems, at least when the response yields no usable mapped rows; assertModelsDevSchema has the same skip.
Useful? React with 👍 / 👎.
| const answered: { name: string; rates: SourceRates }[] = []; | ||
| for (const [name, normalized] of Object.entries(sources)) { | ||
| const r = normalized[model]; | ||
| if (r) answered.push({ name, rates: r }); | ||
| } | ||
| const sourceNames = answered.map((a) => a.name); |
There was a problem hiding this comment.
Attribute each tier difference only to its publishing sources
When both sources answer a model's required input/output rates but only one publishes the cache tier that differs, sourceNames still contains both sources and the Markdown renders both beside that cache comparison. This falsely attributes the displayed upstream value to a source that supplied no value for that tier, which can mislead the human expected to adjudicate the rate change. Preserve source names per TierDiff rather than attaching every model-level responder to every displayed difference.
Useful? React with 👍 / 👎.
Round 7 re-attempted — still blocked, and a live finding from the sweepGate: re-ran It now dies in 1.3 KB within seconds, where earlier deaths produced 250–350 KB before dropping — the API rejects at connect rather than mid-stream. Diagnosed before reporting, since auth misrouting reads almost identically to a spend limit:
🔴 Live billing exposure in
|
| model | meters at | actual | underbill |
|---|---|---|---|
gpt-4o-mini-tts |
1.5 / 6 | 25 / 100 | 16.7× |
gpt-5.4-pro |
25 / 150 | 300 / 1800 | 12× |
o3-pro |
20 / 80 | 200 / 800 | 10× |
gpt-4o-mini-transcribe |
1.5 / 6 | 12.5 / 50 | 8.3× |
o3-deep-research |
20 / 80 | 100 / 400 | 5× |
gpt-4o-realtime-preview |
25 / 100 | 50 / 200 | 2× |
o4-mini-deep-research |
11 / 44 | 20 / 80 | 1.8× |
Longest-prefix-first means any suffix that raises the price collapses onto the cheaper base. Every model above returns rateSource: "table" — indistinguishable on a receipt from an exact match, so the receipt is internally consistent, chain-verifiable, and wrong.
Models landing on FALLBACK_RATE are excluded and are fine: that path sets unknown: true and warns. The bug is specifically that these do not.
20 is a floor. The sweep only sees models published upstream as distinct ids, so claude-opus-5-fast is not counted — models.dev carries fast mode under experimental.modes.fast.cost rather than as its own id. The suffix family that first surfaced the class is invisible to the sweep that confirms it.
Fix direction: an allowlist of suffixes known to preserve pricing, everything else routed to the loud unknown fallback. A denylist would enumerate -fast, -pro, -tts, -realtime-preview, … and fail open on whatever ships next — and this class is defined by "a suffix we have not seen yet", so failing open is the one behaviour it must not have.
Not fixed in this PR. It is packages/core money-path (tier 0/1) and belongs in its own change with its own gates, not folded into a CI script. Raised here because this PR is what found it.
What
PRICING_TABLEships to customers in the publishedusertrustpackage and had no drift monitoring of any kind.PRICING_TABLE_VERSIONis a hand-typed constant; nothing fetched, compared, or expired.This matters beyond hygiene.
receipt.pricing.appliedRates+tableVersionare what let a third party recompute a metered cost from the record alone — check out the stamped table version, recomputeceil(sum(counts × rates / 1000)), floored at 1. That the table is Apache-2.0 public source is what makes independent verifiability real rather than rhetorical. So a stale rate produces receipts that are internally consistent, cryptographically sound, and externally wrong — and the hash chain preserves them faithfully.Measured against the shipped table
claude-sonnet-5deepseek-chat,deepseek-reasonerCurrent exit status is 0. Nothing in the table is wrong today.
Four rules carry the weight
Vendor pinning. models.dev carries 190 providers, most of them resellers. An unpinned matcher answered
claude-fable-5at 30/185 against a true 100/500, andclaude-opus-5at 55/275 (a regional premium) — every one well-formed and wrong. Both sources are pinned to the vendor; a non-vendor row is treated as absent, never as an answer. Mapping is explicit per model, becausecommand-awould otherwise fuzzy-matchcommand-a-translate-08-2025.Deviations only in the safe direction. Both sources publish
claude-sonnet-5at $2/$10 — the introductory rate through 2026-08-31 thatpricing.tsdeliberately does not carry. So "differs from upstream" must not mean "we are wrong", or the monitor's first act would be to undo a deliberate conservative choice. An allowlist entry suppresses failure only while our rate is ≥ upstream; if upstream ever rises above ours it fails despite the entry. Understatement is never allowlistable — which enforces the D1 invariant rather than restating it.Exit 2 is never 0. "Could not check" and "checked, found nothing" are different states. A coverage floor derived from the map (not a transcribed constant, so it self-maintains) turns a silent upstream schema change into a failure instead of a clean sweep of nothing.
Only
agreeis silent. Every model gets exactly one outcome, the counts must sum to the table size, and every other outcome reports by construction — including the 7 models with no external check, which is the most useful thing the report says.Testing
27 tests, no network. The suite carries a positive control (a rate mutated below upstream that must be reported) beside a negative control — an all-pass suite cannot detect an instrument that is disconnected. The exit-2 path is tested by stubbing
fetch, not by blocking the network: Node'sfetchignoreshttps_proxy, so a proxy-based probe succeeds and proves nothing.The unit conversion is pinned to a real table entry (
PRICING_TABLE["claude-opus-5"].inputPer1k) rather than a literal, becauseassert.equal(convert(5), 5)passes against a broken identity conversion and locks the error in.Scope
Report-only:
contents: readmeans GitHub refuses a push, so no upstream value can reachpricing.tsthrough this path. Does not touch the receipt shape,TrustReceipt, orshared/types.ts. No cross-repo token.Pure decision-rule tests run in
ci.yml; the live comparison stays weekly so no network fetch can flake a PR.Deferred findings (not in this PR)
resolveRatesprefix-matching understates silently.claude-opus-5-fastmatchesclaude-opus-5, prices at standard rates (2× understatement — fast is $10/$50), and returnsrateSource: "table", indistinguishable on the receipt from an exact match. Regional variants are safe only by luck:us.anthropic.…doesn't prefix-match, so it lands onFALLBACK_RATEwithunknown: true— visible, and it firesunknownModelPolicy. The suffix case is the silent one.us./eu./au./jp.at a 10% premium.