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 20 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".
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.
Seventh review round: 2 P1, 3 P2. Both P1s are the workflow believing a clean result it did not actually receive. - npm or tsx can exit 0 having written nothing — for instance if run.mts's direct-entry guard stops matching and main() never runs. The upload step only warns on a missing file and the failure predicate is false, so the clean step would CLOSE a live tracking issue on the strength of a report that does not exist. A non-empty report is now part of the zero-exit contract; its absence is re-coded to 2 rather than believed. - The default-branch predicates proved only that the event ref was NAMED after the default branch, not that the checked-out SHA was still its head. Re-running an old scheduled run, or master advancing mid-run, could close or overwrite the tracking issue with a stale verdict. Both mutation steps now require the checked-out SHA to equal the current default-branch head, resolved via the API, and warn instead of mutating when it does not. - Unit conversion rejects overflow. A FINITE upstream value can overflow to Infinity (1e308 * 10); the row stayed non-null and satisfied mapping coverage while resolveTier filtered the value back out, leaving a one-source model `agree` with neither required tier compared. Absence created by arithmetic is still absence read as agreement. - Malformed-rate findings keep their answered sources. The branch hardcoded `sources: []`, and since coverage is derived from finding sources it invented a source-coverage breach with an empty missingMappings — a fabricated second failure reason attached to a real one. - An allowlist can no longer absorb a conflicted tier. For ours 75/250 against sources 50/200 and 50/300, input is a definitive conservative deviation but output straddles our rate; labelling the whole model `deviation-expected` claimed "ours is higher" while one source prices output above us. An allowlist excuses a deviation we understand, never a tier nobody can adjudicate. Live verdict unchanged for the eighth consecutive round: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, 0 malformed, exit 0, 36/36 mappings. 66 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>
Eighth review round: 2 P1, 2 P2. One P1 is a regression the SEVENTH round's fix introduced, which is the pattern this branch keeps producing: the guard added to prevent one silent failure created another. The freshness step sat after checkout/setup-node/npm ci, so it implicitly required their success. A `npm ci` failure skipped it, the reporting step then found no `fresh` output and skipped too — leaving a red scheduled run and NO could-not-run issue. Round 2 established that a failure must always be reported; round 7's guard quietly took it back. The probe is API-only, so it now runs FIRST and with always(), needing neither the checkout nor node. The rule that resolves the tension, stated at the site: Filing a failure issue does NOT require verified freshness — if freshness cannot be established, reporting anyway is the safe error. CLOSING an issue requires it positively, because closing on unknown state is how a live drift issue disappears. "We could not tell" must never resolve toward silence. - Freshness is now sampled on BOTH sides of the comparison. One sample taken before the self-test and a live network fetch proves nothing about the moment of mutation; master can advance in that window while `fresh` still reads true. Closing requires both samples to match the checked-out SHA. - Schema sentinels distinguish structural loss from absence. An absent row is not a schema change — the model may have left the feed, which coverage reports as a named missing mapping (exit 1). A row that still exists but has LOST `litellm_provider`, or a models.dev model whose `cost` object vanished, is the structure this tool pins on disappearing: exit 2, not drift. - run.mts sets `process.exitCode` instead of calling `process.exit()`. A forced exit can terminate the process with stdout still buffered, truncating the Markdown or leaving `--json` invalid — a successful check reporting a corrupt result. Live verdict unchanged for the ninth consecutive round: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, 0 malformed, exit 0, 36/36 mappings. 70 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>
Ninth review round: 1 P1, and findings are converging (5, 7, 3, 5, 4, 3, 5, 4, 1). Round 8 established that CLOSING an issue requires positively verified freshness while FILING one does not. It left the third case open: a run that is clean AND stale. The self-test passes, the comparison passes, freshness is false — so the close step is skipped, the failure predicate ignores freshness entirely, and the workflow finishes GREEN having compared a tree that is not the one master serves. The current table was never examined and nothing said so. That is this branch's recurring defect in its last hiding place: a state nobody classified, resolving toward silence. - A stale or unverified head now routes to the could-not-check body and fails the job, whatever the comparison returned. - A stale run COMMENTS on an existing issue instead of editing it. Overwriting a live drift issue's body with "could not run" would erase findings a fresh run established — worse than the staleness being reported. - The freshness state is printed in the failure step and carried in the issue body, so the reason is legible without reading the workflow. Live verdict unchanged for the tenth consecutive round: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, 0 malformed, exit 0, 36/36 mappings. 70 tests. 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>
…r source Two P2s from the chatgpt-codex-connector review threads on #129 — a different surface from the CLI gate, and one that blocks the merge until resolved. - Wholesale disappearance of mapped rows was the quietest possible outcome. Round 8 established that an ABSENT row is not a schema change, because a model may simply have left the feed and coverage names it. Taken to its limit that was wrong: if LiteLLM returns `{}` or wraps the rows in a new container, EVERY mapped lookup hits that same `continue`, `problems` stays empty, and the sentinel passes — the run then reports drift, or nothing, for a response it never understood. One absent row is a removal; every absent row is the shape having changed. Both sentinels now fail when no mapped row resolves at all. - Tier differences were attributed to every source that answered the MODEL. When both sources publish input/output but only one publishes the cache tier that differs, the report listed both beside that comparison — crediting a rate to a source that never stated it, so a reader checking the other source finds nothing and cannot tell whether the tool or the source is wrong. TierDiff now carries `publishedBy`, resolved per tier, and the report renders "per litellm" or "per litellm + models.dev" against each difference. Model-level sources are still shown, relabelled "answered". Live verdict unchanged for the eleventh consecutive round: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, 0 malformed, exit 0, 36/36 mappings. 71 tests. 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 rounds 7–9, connector threads, and the current gate stateRound 7 —
|
…ered `HEADINGS` was already a total Record, so a new outcome could not be added without a heading. But SECTION_ORDER — the array that actually drives rendering — was hand-listed, and the summary table was nine hand-written rows. Omitting an outcome from either compiles cleanly, so a new failing outcome could be counted, could fail the run, and could appear in no table and no section: present in the verdict, absent from the report explaining it. Both are now derived from one compile-checked list. Exhaustiveness that depends on remembering is a failure rate, not a guarantee. THE GUARD ITSELF WAS VACUOUS TWICE BEFORE IT WORKED, and neither dead version was detectable by reading it: 1. `type _Uncovered = Exclude<..., (typeof SECTION_ORDER)[number]>` with the array annotated `Exclude<Outcome, "agree">[]`. That annotation makes `[number]` the DECLARED element type — every outcome — so `_Uncovered` is `never` whatever the array contains. Fixed with `as const satisfies`. 2. `const x: _Uncovered[] = []`. An empty array is assignable to `T[]` for every T, so it passes whatever `_Uncovered` resolves to. Fixed with `type _AssertNever<T extends never> = T`. Both were found by deleting a member and checking the build actually breaks. The third form fails with `Type '"unmapped"' does not satisfy the constraint 'never'` — naming the omission — and compiles clean when whole. A guard is not verified by being present. This one looked correct twice while checking nothing, in the file whose subject is exactly that failure. Live verdict unchanged: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, 0 malformed, exit 0, 36/36 mappings. 71 tests. 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>
Exhaustiveness made mechanical — and the guard that did it was vacuous twice
Both are now derived from one compile-checked list. The guard was dead twice, and neither version was detectable by reading itAttempt 1 — Attempt 2 — Attempt 3 fails with Both dead versions were found by deleting a member and checking the build actually breaks — neither by re-reading. A guard is not verified by being present, and this one looked correct twice while checking nothing, in the file whose subject is precisely that failure. The procedural fix, now standard here: run the positive control first. Prove the mutated form errors, then prove the whole form is clean. Ordered the other way I would have accepted both dead versions, because absence of a compile error reads identically to a working guard. State
Live verdict, unchanged through every round: 16 agree · 1 expected deviation · 2 source conflicts · 7 uncorroborated · 0 malformed · exit 0 · 36/36 mappings. Two commits now sit past the last independent verdict — the connector fixes ( Nine concluded CLI rounds plus the connector pass: 35 findings, every one accepted as real. Recording the two ungated commits explicitly rather than letting nine green rounds cover them. |
…wn drift Eleventh round: 2 P2, no P1. THE COMPILE-TIME GUARD WAS NEVER EXECUTED BY CI. `scripts/` is covered by no project in the root tsconfig and `drift:test` runs through transpile-only tsx, so the exhaustiveness assertion added last commit fired only when someone ran `tsc` by hand with an ad-hoc config. Adding an Outcome without updating SECTION_ORDER would still have passed CI and silently omitted that outcome from every report. That is the same defect the guard exists to prevent, one level up, and I had already reported the underlying gap — "scripts/ isn't covered by any tsconfig" — before building a compile-only guard there anyway. The ad-hoc config I kept creating and deleting to check it WAS the problem: a check that only runs when its author remembers to run it has the failure rate of remembering. Fixed with a tracked `scripts/pricing-drift/tsconfig.json` wired into `npm run typecheck`, which CI runs on every push and PR. Positive control first: removing "unmapped" from SECTION_ORDER now fails `npm run typecheck` with `Type '"unmapped"' does not satisfy the constraint 'never'`, and the whole form is clean. - Inconclusive runs no longer overwrite known drift. Scoping the comment-instead-of-edit rule to STALE heads was too narrow: a fresh run whose source returns 500 also reaches the edit path and replaces a live drift body with a generic could-not-run notice. Every inconclusive result now comments; only a conclusive one — instrument working, tree current, real report produced — has standing to replace what an earlier conclusive run established. Losing a real finding to a transient outage is strictly worse than the outage. Live verdict unchanged: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, 0 malformed, exit 0, 36/36 mappings. 71 tests. 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>
Committed the previous change without reading biome's output, which had flagged this file: spaces where the repo uses tabs. Exit status was not checked and the summary line was not read — the same 'confirm the instrument said what you assumed' failure this branch keeps documenting, committed by the author documenting it. Biome back to the 39-warning master baseline, 0 errors. 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>
…pload soft Twelfth round: 3 P2, no P1. - Cache gaps were attributed to every source that answered the MODEL. The round-11 fix added `publishedBy` to TierDiff and stopped there; `cacheGaps` was still a bare Tier[] rendered beside `f.sources`, so an omitted tier only one source publishes was credited to both — sending a reader to check a source that never stated a cache rate. Same defect, adjacent field, missed because the first fix was applied to the structure that had the bug rather than to the property that was wrong. - A null LiteLLM row was dereferenced. `row === undefined` admits a retained key holding `null`, and the next property read throws — collapsing a NAMED missing-mapping report into a generic exit-2 "could not check". The schema sentinel already treated such rows as absent; normalization now agrees. - An artifact-upload failure filed nothing. After a CLEAN comparison it satisfies neither the issue predicate nor the failure predicate, so the job ended red having recorded nothing — a scheduled failure with no trace, which is the exact state this workflow exists to prevent. The upload is a convenience and the issue is the real channel, so upload is now non-fatal. Live verdict unchanged: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, 0 malformed, exit 0, 36/36 mappings. 73 tests. 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>
Thirteenth round: 1 P2. `rawTier` preserves a negative cache entry; the canonical resolver replaces it with `inputPer1k`. The formatter printed only the raw value, so `cacheReadPer1k: -5` against sources at 5–10 read "ours -5" while both classification and metering used 50 — a report contradicting its own section heading, on the one number in it that represents money. Differences now render as `-5 (meters at 50)` whenever the effective rate differs from the field, matching the treatment omitted tiers already had. Live verdict unchanged: 16 agree, 1 expected deviation, 2 source conflicts, 7 uncorroborated, 0 malformed, exit 0, 36/36 mappings. 74 tests. 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>
…erdict
Fourteenth round: 1 P2, and it is the sharpest test finding of the branch.
Every existing test drove `compareTable` and read its RETURN VALUE. The
workflow reads a PROCESS EXIT CODE. Nothing connected the two, so `main()`
could produce a non-empty failed report and still return 0 while all 74 tests
passed — and the weekly run would accept that zero and CLOSE a valid drift
issue. The mandatory positive control did not cover the contract the whole
alerting design rests on.
THE FIRST ATTEMPT AT THIS TEST WAS ITSELF THE DEFECT IT DESCRIBES. It fed
`{}` to both sources, which trips the SCHEMA SENTINEL and returns 2 from an
early path — never reaching the `failed ? 1 : 0` line it was written to
protect. Forcing that line to `return 0` left it passing. A test named for
one guard reaching another, in the file whose subject is exactly that.
Rebuilt with fixtures derived from the shipped MODEL_MAP and PRICING_TABLE,
so every mapped row is present and valid and the run reaches the comparison.
The baseline reproduces claude-sonnet-5's real upstream deviation rather than
flattening it — a corpus where everything agrees makes that allowlist entry
stale, which is a genuine failure and would have made the negative control
lie in the other direction.
Mutation-verified in both directions: forcing `main()` to return 0 fails
exactly `returns 1 when a rate drifts — the code the workflow actually
reads`, and the unmutated form passes 76.
Live verdict unchanged: 16 agree, 1 expected deviation, 2 source conflicts,
7 uncorroborated, 0 malformed, exit 0, 36/36 mappings.
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>
Committed the previous change without reading biome's summary line — the SECOND time on this branch. Both times the tail of the output was blank or truncated and I treated absence of a visible error as absence of an error. 'Found N errors' is the verdict; anything else is not. Reading the instrument's own summary rather than the shape of its output is the whole discipline this branch documents, and I have now failed it twice while writing it down. Biome: 0 errors, 39 warnings (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>
The previous commit left `npm run typecheck` failing with TS18048 — and I committed it after the check had already told me so, because I read the absence of my own 'typecheck OK' echo rather than the command's output. That is the THIRD instrument-reading failure on this branch and the second in consecutive commits: blank output taken for clean output, a missing success echo not noticed, and earlier a biome summary not read. The lesson this branch keeps writing down is that a check's verdict must be read, not inferred from the shape of what it printed — and CI would have caught this one, which is precisely why local checks that are run but not read are worse than not running them: they manufacture false confidence. All gates now verified by reading their output: typecheck clean, biome 0 errors / 39 warnings, 76 tests, live run exit 0, and the exit-code mutation still fails its named test. 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 gate — CLEAN on the merge SHA
Four independent signals agree, which is the bar this PR has been holding itself to: the process terminated ( The round before this one was discarded, not readRound 15 came back Three instrument-reading failures of my own, all in the last few commitsWorth recording, because they are the same class the tool is built to catch and I committed them while writing it down:
The first two were caught by re-running and reading the summary line; the third by the Also worth noting: my own verdict extractor returned nothing here. It keys on Final state
Live verdict, identical through every one of the fifteen rounds: 16 agree · 1 expected deviation · 2 source conflicts · 7 uncorroborated · 0 malformed · exit 0 · 36/36 mappings. Fourteen concluded CLI rounds plus a connector pass: 41 findings, every one accepted as real, trending 5 → 7 → 3 → 5 → 4 → 3 → 5 → 4 → 1 → [connector 2] → 2 → 3 → 1 → 1 → 0. Not one of the 41 fixes changed today's answer. Each closed a path by which a future run could report clean while something was wrong — which, for a monitor, is the entire product. What the rounds were actually aboutOne idea kept resurfacing one abstraction higher each time: a comparison that cannot conclude something must not thereby conclude that nothing is wrong. Conflict masking understatement → conflict masking disagreement → a rate above every source still called a conflict → a malformed rate compared raw instead of effective → a clean-but-stale run finishing green. And five times the monitor carried, in its own instrument, the failure it exists to detect: a schema test comparing a constant to itself; a coverage floor derived from the map it guarded; a cache baseline whose totals stayed satisfied while one model lost one tier; an exhaustiveness guard that was vacuous twice; and a positive control that never exercised the exit code the workflow actually reads. Every guard in this PR has been mutation-verified in both directions — including against the live feeds — because a guard is not verified by being present. |
The monitor counted `corroboration = 2` when LiteLLM and models.dev agreed and treated that as independent confirmation. They are not independent: both derive from the providers' published pages and both lag promotional changes, so N of them agreeing is ONE upstream fact with N mirrors. The strongest signal was systematically overstated in exactly the case where verification matters most — a rate that just moved. Measured 2026-08-22: both catalogs, and this repo's own table, priced `gpt-5.6-sol` at 50/300. OpenAI's page said 40/200, footnoted promotional through 2026-11-21. Acting on "three-way agreement" would have overcharged output by 50% on the most-used model, with three sources apparently confirming it. CONFIRMED BY EVENTS WITHIN HOURS. LiteLLM has since moved to 40/200 while models.dev still carries 50/300, so the two now visibly disagree on that exact rate and the monitor reports it as a source conflict. The "agreement" was a TIMING ARTEFACT — two mirrors of one page, one of which had not refreshed. That also means this repo's own table is stale at 50/300, caught by its own checker. Adds explicit precedence — billed > provider > catalog — and reports `evidence`, the count of distinct evidence TIERS, rather than a source count. Two catalogs agreeing is 1. A catalog agreeing with a provider page is 2. The report now reads "litellm, models.dev — 1 evidence tier" instead of implying two confirmations. The narrower lesson, which is the durable one: before calling a provider-vs-catalog disagreement an error, ask WHICH QUESTION EACH SIDE IS ANSWERING. List vs promotional, standard vs priority tier, base vs context-cliff, 5m vs 1h cache-write are different questions, and a catalog silently answers whichever one it was built from. Outcome-neutral: verified by running HEAD without this change against the same live feeds and getting an identical verdict. Tests 78, typecheck clean. Signed-off-by: Cam <cam@camwhiteus.com>
492fab8 to
15969d2
Compare
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
Re-measured live 2026-08-23T20:48:02Z against
PRICING_TABLE_VERSION2026-08-10:claude-sonnet-5deepseek-chat,deepseek-reasoner,gpt-5.6-solCoverage 36/36 source→model mappings, across 19/19 mapped models. Exit status is 0.
Nothing in the table is understated. One entry is now known stale in the safe direction:
gpt-5.6-solcarries 50/300 while OpenAI publishes 40/200 promotional through 2026-11-21. Report-only stays report-only, so that rate is adjudicated in #142 rather than changed here.Five 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.Catalogs vote once, not N times. LiteLLM and models.dev are not independent: both derive from the providers' published pages and both lag promotional changes, so N of them agreeing is one upstream fact with N mirrors. Measured 2026-08-22, both catalogs and this table priced
gpt-5.6-solat 50/300 while OpenAI's page said 40/200 — acting on "three-way agreement" would have overcharged output by 50% on the most-used model, with three sources apparently confirming it. Within hours LiteLLM moved to 40/200 while models.dev did not, so the agreement was visibly a timing artefact. The report now carriesevidence, a count of distinct evidence tiers under an explicitbilled > provider > catalogprecedence, and reads "litellm, models.dev — 1 evidence tier" instead of implying two confirmations.Testing
78 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 — tracked in pricing: resolveRates prefix match understates silently and reports rateSource "table" #143.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.gpt-5.6-solis stale in the safe direction — tracked in pricing: adjudicate gpt-5.6-sol — table carries 50/300, OpenAI publishes 40/200 promotional through 2026-11-21 #142. Caught by this monitor on its own table; the promotional window through 2026-11-21 makes "correct" time-dependent, so it needs a decision rather than a drive-by edit.