fix(costs): price subscription runs from a rate card instead of booking $0 as reported - #10544
fix(costs): price subscription runs from a rate card instead of booking $0 as reported#10544phattbeats wants to merge 7 commits into
Conversation
Greptile SummaryThis PR adds notional rate-card pricing for subscription-backed runs and surfaces it throughout cost reporting.
Confidence Score: 4/5The PR is not yet safe to merge because its historical backfill still persists understated rate-card values for rows containing folded cache writes. The current backfill explicitly leaves pre-migration cache-creation tokens inside input_tokens while cache_write_tokens remains zero, so those historical tokens are recomputed at the ordinary input rate rather than the cache-write premium; the previously reported dashboard visibility issues are otherwise fixed at current HEAD. Files Needing Attention: packages/db/src/backfill-cost-event-rate-card.ts
|
| Filename | Overview |
|---|---|
| packages/db/src/backfill-cost-event-rate-card.ts | Adds an idempotent rate-card backfill, but historical cache writes remain knowingly priced at the lower ordinary-input rate. |
| server/src/services/costs.ts | Propagates rate-card and cache-write totals through cost aggregation endpoints and fixes subscription-aware project ordering. |
| server/src/services/heartbeat.ts | Derives notional prices for credible token usage while preserving cash cost as the budget input. |
| packages/shared/src/model-rate-card.ts | Introduces centralized model normalization and token-category rate-card pricing. |
| ui/src/components/BillerSpendCard.tsx | Correctly displays subscription rate-card equivalents in biller, billing-type, and provider breakdowns. |
| ui/src/pages/Costs.tsx | Updates cost views to show notional subscription spend and correctly handles mixed cash and subscription model rows. |
Reviews (11): Last reviewed commit: "fix(costs): decide cash vs rate card per..." | Re-trigger Greptile
|
Thanks — both findings are valid. One is fixed in Issue 2 (backfill misprices historical cache writes) — confirmed, and deliberately not "fixed"Correct diagnosis. Pre-migration rows folded cache-creation tokens into I did not write a correction, because the split was never recorded and is not recoverable. Anything I computed — a fixed cache-write ratio, a per-model heuristic — would be a guess presented as data, in a column whose entire purpose is to stop guesses from reading as facts. That seemed like the wrong trade for this particular field. What
Scope of the error is bounded: it applies only to rows written before this change, the affected span stops growing the moment it merges, and the figure is notional — it never reaches Issue 1 (subscription costs remain invisible in the UI) — real, and the natural next PRAlso correct: I'd like to land this PR without it, for two reasons:
The columns, the aggregates, and the shared response types are all in place, so the UI PR is additive and small. If you'd rather see them together, say so and I'll fold it in here instead. Also in
|
874eab3 to
8723f46
Compare
… (PHA-1654) Implements House's HYBRID verdict from PHA-1643 research. Two coupled changes on cost_events: 1. New non-nullable `pricing_methodology` column with allowed values 'measured' / 'pre_cache_write_aware' / 'unpriced', enforced by a CHECK constraint. Lets dashboards and BI tools tell measured rows apart from rows that were priced at the input rate because cache-write tokens were folded into input_tokens before the 0198 migration. 2. `rate_card_cents` is now nullable. Subscription auth / unlisted models record NULL with `pricing_methodology='unpriced'` rather than zeroing the column, so the silence is not mistaken for a fact. The migration backfills pre-0199 rows to `pre_cache_write_aware` and flips their `cost_status` to `reported_pre_migration`. Both UPDATEs are idempotent (only touch rows still at their defaults). The backout path is documented as a commented block in the migration. New writes from the heartbeat are always `measured` (or `unpriced` when the rate card is NULL). The cost service insert now carries `pricing_methodology` through. The shared `CostEvent` type uses `rateCardCents: number | null` and adds `pricingMethodology: PricingMethodology` so the column lands in the same response shape PHA-1640 will read. PHA-1643 stays in_review as the research record. PHA-1640 (dashboard rendering) is unblocked once PR paperclipai#10544 merges — the new column lands in the response shape here, so PHA-1640 should not need to redo its PR. PR paperclipai#10544 status: open, unmerged (mergeable: false at the time of writing). This branch sits on top of paperclipai#10544's HEAD so it auto-rebases when paperclipai#10544 lands. If paperclipai#10544 closes without merging, this branch will need to be rebased onto master directly.
8723f46 to
ed85bd3
Compare
|
🚨 Contributor flagged. Click here for more info: Superagent Dashboard |
ed18edf to
1708709
Compare
|
Both Greptile P1s addressed. One was right and is now fixed in code; the other is right about the arithmetic but not fixable from stored data, so it is disclosed instead. P1: "Subscription costs remain invisible" — valid, fixed in 6b2f806This was the real one, and it went to the heart of the change: the service returned Added
It renders only when there is subscription usage, and it is always labelled While in there I also fixed a second-order version of the same bug: the per-model share was Covered by P1: "Backfill misprices historical cache writes" — correct, and disclosed rather than guessedThe arithmetic is right. Rows written before It is not recoverable. The split was never stored, so any correction would be a guessed ratio presented as data — worse than a known, stated understatement. Handled in 1708709 by documenting it in the file header and printing the affected-row count at runtime, so the number cannot be read as exact. On the current dataset that is 5,986 of 7,244 rows. Full suite was green on the previous head — Build, all four server shards, all four serialized suites, e2e, typecheck. This push adds UI-only changes plus one new test file. UI typechecks clean. |
64c1b81 to
8e2825a
Compare
|
Greptile's third P1 is correct, and it is the same mistake one layer down. Fixed in What was wrongMy previous commit put the rate-card equivalent in current.costCents += entry.costCents; // subscriptionRateCardCents droppedSo for a subscription-only biller the card totalled $158.68 at the top and then listed every upstream provider beneath it at $0.00. The spend was visible but not attributable — you could see the company had a runaway and not which provider caused it, which is most of the value of the breakdown. The billing-type list had it worse: the row labelled "Subscription included" summed Fix
On the testsWorth being explicit, because the last round of tests is what let this through. My storybook fixtures carried
Each assertion is scoped to the section under test, via a Also in this pushRebased onto Unchanged: the second P1 about historical cache-write pricing stands as previously answered — arithmetically right, not recoverable from stored data, so it is disclosed in the file header and counted at runtime rather than guessed at. |
8e2825a to
efedb54
Compare
|
Rebased onto master tip The collision
Renumbered to I ran the repo's own guard rather than eyeballing the journal, because a clean No duplicate tag, no duplicate 4-digit prefix. The journal does carry one duplicate Verified on the rebased tree
On the two red checksNeither is a code failure, and I want to be exact about the second one rather than wave it off:
|
…ng $0 as reported The Claude Code CLI emits a well-formed `total_cost_usd: 0` when it runs under OAuth / subscription auth. The ledger's cost-status guard was type-check-only (`costUsd == null`), so that numeric zero sailed through and every subscription run was written as an authoritative `cost_status='reported'` zero. In practice that meant ~822M tokens/week of real inference was recorded not as a gap in the data but as a confirmed $0 — indistinguishable from genuinely free work, and invisible to anyone looking at spend. What changes: - `cost_status` gains a third value, `derived`. The guard now asks whether the provider gave a *credible* cost, not merely a non-null one: real token usage plus a zero/absent cost can never be `reported` again. It becomes `derived` when we can price it and `unpriced` when we cannot. - A new `rate_card_cents` column carries the notional token x list-price figure. `cost_cents` intentionally still means actual cash — it stays 0 for subscription-included runs, and it remains the only figure that feeds budget enforcement, so budgets and overage behaviour are untouched. - A new `cache_write_tokens` column makes cache-write spend recordable for the first time. Cache-creation tokens carry a write premium over plain input and were previously folded into `input_tokens`, which flattened the distinction and hid the premium. Producers now report them on their own field. - `packages/shared/src/model-rate-card.ts` holds the published list prices (USD per million tokens, with dated tiers for introductory pricing) and derives the notional figure from a model id plus a token count. An unlisted model yields `null`, which surfaces as `unpriced` rather than a silent zero. - `packages/db/src/backfill-cost-event-rate-card.ts` recomputes the two new columns and `cost_status` for historical rows from the token counts already stored on them.
…mate The first pass keyed `reported` off `costUsd > 0`. That misses the case it was written for: the Claude Code CLI prints a rate-card `total_cost_usd` (~$2.35 on a real run) even under subscription auth, while `normalizeBilledCostCents` zeroes the cash because nothing is metered. Live rows therefore kept landing as `reported` with `cost_cents = 0` and real tokens - the exact shape this issue exists to eliminate - and disagreed with the historical backfill, which classifies off `cost_cents` and marked identical rows `derived`. Classify off the billed cents instead, so live and backfilled rows agree and a subscription run is `derived` (priced from the rate card) rather than an authoritative zero.
The rate-card change widened CostByProviderModel, CostWindowSpendRow, and CostByBiller with cacheWriteTokens/rateCardCents (plus the subscription variants), and added cacheWriteTokens to the adapter usage summary. The storybook fixtures and one server assertion were not updated, which broke the UI typecheck (18 TS2739) and one server test.
…ternal ids Greptile flagged that the backfill prices pre-migration cache-creation tokens at the ordinary input rate. That is correct. Those tokens were summed into input_tokens and never stored separately, so the split is not recoverable and any correction would be a guessed ratio presented as data. Documented in the file header and surfaced at runtime with a count of affected rows, so the backfilled figure cannot be read as exact. cost_cents is unaffected. Also removes internal tracker ids from three comments, per CONTRIBUTING.
The service already returned subscriptionRateCardCents and the API already shipped it, but no production component read it. Every dashboard therefore still printed $0.00 for subscription-included runs, which is the failure this change set exists to fix: the heaviest token consumers looked free and sorted cheapest. Adds one shared RateCardEquivalent tag, used on the by-agent list, the biller card, and the provider card plus its per-model rows. It renders only when there is subscription usage, and is always labelled "rate card" with an explainer on hover, because this figure is right for comparing agents and wrong for an invoice. Also fixes the per-model share split, which divided by costCents and so showed 0% on every row of a subscription-only agent. It now falls back to the rate-card basis when there is no cash to divide by.
BillerSpendCard's header showed the biller-wide rate-card equivalent, but both of its breakdowns aggregated only costCents. For a subscription-only biller that meant the card totalled correctly while every row beneath it read $0.00 — the billing-type row labelled "Subscription included" most of all. The spend was visible but not attributable to a provider. Both aggregations now carry subscriptionRateCardCents, and both sort on cash plus rate card so a subscription-only provider is not pinned to the bottom of the list at a notional zero. Tests render the component rather than asserting on fixtures: four of the five fail against the previous component. The header repeats the same totals, so each assertion is scoped to the section under test — an unscoped one passes while the section itself still renders $0.00, which is how this survived the first round.
The by-agent model breakdown chose between cash and the rate-card equivalent from the agent's own total. That works for an agent whose usage is entirely one kind and fails for any agent holding both: because the aggregate was positive, every subscription row rendered "$0.00 (0%)" with no tag, hiding the subscription half of a mixed agent in the one view built to surface it. Each model row already carries exactly one billing type, so the choice belongs to the row. Percentages now share a cash + rate-card basis, so a mixed agent's rows still sum to 100% instead of running past it. Extracts the rule into ui/src/lib/agent-model-share.ts with tests, so "which rows get a rate-card tag" is pinned rather than restated inside JSX — this is the fourth review round to catch a variant of the same mistake, each one a layer further in. Also promotes SUBSCRIPTION_BILLING_TYPES to @paperclipai/shared. The SQL that decides which rows feed subscriptionRateCardCents and the UI that decides which rows get a tag were about to hold separate copies of that answer; they now read one. Closes the last instance of the class in By project, which rendered a bare $0.00 for subscription-funded projects and sorted them last on cash.
d363a5d to
483ae48
Compare
Thinking Path
Linked Issues or Issue Description
No public GitHub issue exists for this. The problem is described inline below, following
.github/ISSUE_TEMPLATE/bug_report.yml.What happened?
Every agent run on a subscription-authenticated adapter writes a
cost_eventsrow withcost_status = 'reported'andcost_cents = 0, next to correct and non-zero token counts.resolveLedgerCostStatusinserver/src/services/heartbeat.tsdecides the status:The
unpricedbranch needscostUsdto benull. The Claude Code CLI emits a well-formed numeric0under OAuth auth, and0 != null, so the branch never runs. On the instance where this was found,cost_status='unpriced'had zero rows for all time, across the whole database. The guard was dead code on this path.Expected behavior
A run that used millions of tokens must not be recorded as a confirmed $0. Either the ledger prices the run, or it marks the cost as unknown. It must not assert zero.
Steps to reproduce
claude_localagent with OAuth or subscription auth, not an API key.cost_cents = 0, andcost_status = 'reported'.Scale observed
One instance, one week, provider
anthropic: 156 of 172 rows and 822,597,919 tokens, allreportedat $0.00. The same tokens priced at list are about $326.Environment
4813ed3(master)claude_local; the ledger change also affects any adapter that reports a subscription costSupersedes #10476 — same commits, same author. That PR was opened from a branch whose name carried an internal ticket id, which the contributing checklist disallows. Renaming the branch closed it automatically, so this PR replaces it. #10476 has no review history to preserve: its review gate never passed.
Related pull requests (searched, not duplicates)
unpricedstatus this PR repairs. That status could never be reached on the subscription path.cost_centsmust be0for subscription billing types. This PR keeps it0and agrees. It adds the missing number in a separate column instead of changingcost_cents.grok_local. That PR fixes one adapter. This PR fixes the shared ledger rule.What Changed
cost_statusgains a third value,derived. The guard now asks whether the reported cost is credible, not only whether it is non-null. Real token usage plus a zero or absent cost can no longer producereported. It becomesderivedwhen the rate card can price it, andunpricedwhen it cannot.rate_card_centsholds the notional list price. It is separate fromcost_centson purpose.cost_centsstill means actual cash, it stays0forsubscription_included, and it stays the only input to budget enforcement.cache_write_tokens. Cache-creation tokens were folded intoinput_tokens, so the cache-write premium was not recordable on any adapter. The claude-local parser and the ACPX engine now report the field separately.packages/shared/src/model-rate-card.tsholds per-model list prices in cents per million tokens, for input, output, cache read, and cache write.packages/db/src/backfill-cost-event-rate-card.tsrecomputesrate_card_centsand the status for existing rows. The token counts on disk are already correct, so no run must be repeated.0197_cost_event_rate_card.sqladds both columns. It is additive and usesADD COLUMN IF NOT EXISTS.Verification
Automated:
The accounting test suite asserts the rule directly. It proves that token usage plus a zero cost can never resolve to
reported, thatcost_centsstays0forsubscription_included, and thatrate_card_centsis computed from tokens.Manual, on a live instance:
claude_localagent.cost_status='derived',cost_cents=0, andrate_card_cents > 0.agents.spent_monthly_centsdid not change.A backfill of 6,694 historical rows on one instance moved them to
derived.cost_centsstayed0for every row.Risks
NOT NULL DEFAULT 0, so existing rows get0and no read path breaks. There is no data loss and no rewrite of existing values.cost_statusgains a value. Any consumer that switches on the status must handlederived. In-tree consumers are updated. An out-of-tree dashboard that assumes two values will see an unknown status.rate_card_centswrong. It cannot makecost_centswrong, so it cannot affect billing or budgets.rate_card_centsmust not be read as money owed. It is notional. Nobody is invoiced for it. This is the reason it is a separate column and not a correction tocost_cents. A reviewer who prefers the opposite policy should read [codex] Count subscription-included usage in budgets #5724, which proposes counting these estimates against budgets. This PR does not, because the budget guard enforces real spend, and an estimated figure would let a rate-card error stop real work.unpricedrather than a wrong price. This is a visible gap, which is the intended failure mode.Roadmap Note
ROADMAP.mdlists Better Budgeting as delivered, and it names "clearer spend visibility". This PR is adjacent to that item, so a maintainer should confirm the direction before merge.Two points argue that it does not duplicate the roadmap work. It is a bug fix, not a feature: the existing
unpricedstatus was unreachable on this path, and the fix restores the behaviour the ledger already intended. It also leaves budget enforcement exactly as it is, becausecost_centsis untouched. If a maintainer prefers this to land as part of a larger budgeting effort, redirect it and I will rework the shape.Model Used
Claude Opus 5, model id
claude-opus-5, 1M context configuration (claude-opus-5[1m]), with extended thinking and tool use, running in Claude Code.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template#NNN/github.com/paperclipai/paperclipURLs)docs/...,fix/...) and contains no internal Paperclip ticket id or instance-derived details