Stop reporting credit balance as money spent - #208
Conversation
Codex and OpenCode Go both put a remaining credit balance into `CostSnapshot.used`, which is documented as "amount used in the current period". The number was inverted: it fell as the user spent and read $0.00 at exactly the moment the account was exhausted. Codex proves it knew better. `SpendControlLimitSnapshot::to_cost_snapshot` derives `used = (limit - balance).max(0.0)`, but the live JSON path never consulted the spend-control limit at all, so the raw balance is what shipped. Spend is now derived from the limit when one is reported, and claimed not at all when it is not: a balance alone cannot tell you what was spent. The balance is still shown, as its own "Credits" line. OpenCode Go already displayed the Zen balance honestly as an info-only line, then additionally reported the same number as cost. The bogus cost snapshot is gone; the honest line stays. Found by an audit of the core providers, the same defect class as the Cursor on-demand work in 1.5.24.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
ceiling | 6146490 | Commit Preview URL Branch Preview URL |
Aug 05 2026, 08:08 AM |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughCodex and OpenCodeGo now separate remaining credit balances from monetary spend. Codex derives spend only from spend-control limits. OpenCodeGo reports Zen balance as informational usage. Tests cover balance extraction and credit-limit scenarios. ChangesProvider usage accounting
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Pull request overview
This PR fixes incorrect cost reporting in two providers where a remaining credit balance was being surfaced as money spent (CostSnapshot.used), causing spend to decrease as the user spent and to show $0 at exhaustion. It updates the Codex and OpenCode Go providers to (1) only derive spend when a spend-control limit is available and (2) display remaining balance as an info-only line instead of cost.
Changes:
- OpenCode Go: stop emitting a
CostSnapshotfrom Zen balance; keep balance as an info-only rate window and add a regression test around parsing. - Codex: add an explicit “Credits … left” info-only line and only report cost when a spend-control limit is present (deriving
used = limit - balance). - Add Codex tests covering: balance-without-limit (no spend claimed), balance-with-limit (spend derived), and unlimited credits (neither spend nor balance shown).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| rust/src/providers/opencodego/mod.rs | Removes reporting Zen balance as cost; keeps it as an info-only line and adds a small regression test. |
| rust/src/providers/codex/api.rs | Splits “credit balance remaining” from “money spent”; derives spend only from spend-control limit and adds targeted tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The test claimed to verify the Zen balance is not reported as spend, but only asserted that parse_zen_balance returns a number, which the fix did not touch. Split the snapshot assembly out of the fetch as `result_from_page`, so the behavior is testable without a network round trip, and assert what the fix actually changed: no CostSnapshot, and the balance present as an info-only line reading "$12.50" at 0%. The fixture is shaped like the real page (unquoted JS keys for the windows, balance as display text); a quoted-JSON fixture parses as neither, which is what the first attempt got wrong.
|
Good catch, fixed. The test name promised behavior verification and only asserted Rather than rename it down, I split the snapshot assembly out of the fetch as Worth noting the fixture took two tries. A quoted-JSON page parses as neither — 805 rust / 474 tauri, clippy clean. |
Closes SOU-546. Claude's OAuth and web sources had drifted into two response types with two snapshot builders. Every window Anthropic adds has to be wired twice, and it never was — so each path dropped what the other rendered. One root cause, three symptoms. ## What was broken - **OAuth lost the Design window.** It was parsed (`sevenDayDesign`, with `seven_day_oauth_apps` aliased onto it) and then simply never read when the snapshot was built. Only routines made it into `extra_rate_windows`. - **OAuth lost extra-usage dollars.** The `ExtraUsage` struct existed, deserialized correctly, and was discarded. `ProviderFetchResult::new(usage, "oauth")` never got a cost snapshot, so the monthly limit and spend that web showed were invisible on OAuth. - **Web lost the Sonnet window.** Its model-specific slot read `seven_day_opus` and nothing else, so an account with no Opus pool rendered an empty slot where Sonnet belonged. OAuth already had the Opus-then-Sonnet fallback. ## What changed `rust/src/providers/claude/usage_api.rs` is new and owns the wire shape: `ClaudeUsageResponse`, `ClaudeUsageWindow`, `ClaudeExtraUsage`, plus `build_snapshot` and `extra_usage_cost`. Both fetchers deserialize into it and build through it. The map-based deserializer that web needed — Anthropic ships overlapping keys like `seven_day_design` and `seven_day_omelette` in one payload, which serde aliases reject as a duplicate field — now covers OAuth too, with both casings accepted on every key. `build_snapshot` takes the window converter as a closure, which preserves the one difference that is real: OAuth's `to_rate_window` returns `Option` and skips windows with no utilization; web's always emits. Both keep the behavior they had. ## Behavior change beyond the three symptoms **An OAuth window is renamed.** OAuth aliased `seven_day_omelette` onto routines, so it rendered as "Daily Routines". It maps to the promotional window now and reads **"Weekly promo"**, as it already did on web. The web name is the correct one; this is a visible label change for anyone whose payload carries that key. Web's cost path is unchanged. An earlier revision of this PR added a second extra-usage fallback at the cost site; CodeRabbit caught that the fetch site already falls back to the embedded payload when the dedicated overage endpoint fails, so the second one could only fire when that endpoint *succeeded* and reported `is_enabled: false` — turning a deliberate "overage is off" into a cost read from the embedded copy. Removed in 261538c. ## Open item before release `used_credits` is treated as money spent. Web has always done this and it is unchanged here — but this PR propagates that assumption to OAuth for the first time. Given #208 just fixed exactly this inversion in Codex and OpenCode Go, and that audit turned up five further findings, the number is worth confirming against a live OAuth account in the running app. It is unreachable from tests and mocks. ## Verification - `cargo test --manifest-path rust/Cargo.toml` — **807 passed** (805 baseline, +2 new) - `cargo test --manifest-path apps/desktop-tauri/src-tauri/Cargo.toml` — **474 passed** - `cargo clippy --all-targets -- -D warnings` — clean on both manifests - `cargo fmt --check` — clean Two new regression tests in `usage_api.rs` cover all three symptoms: an OAuth-shaped payload keeps Design and converts extra-usage cents to dollars with its limit; a web-shaped payload with no Opus pool puts Sonnet in the model-specific slot. Existing test counts per file are unchanged — nothing was deleted to get here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Improvements** * Improved Claude usage reporting across OAuth and web connections. * Usage windows now display consistently for primary, secondary, model-specific, and weekly limits. * Added extra-usage details, including spending limits, credits used, currency, and associated costs. * Improved compatibility with Claude response formats using camelCase or snake_case data. * Null or unavailable usage fields are handled more reliably across usage reports. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: tsouth89 <tsouth89@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two core providers put a remaining credit balance into
CostSnapshot.used, whichcore/usage_snapshot.rsdocuments as "Amount used in the current period". The number is inverted: it falls as you spend, and reads$0.00at exactly the moment the account is exhausted.Codex
codex/api.rscontains both the right answer and the wrong one:The live path is
build_result_from_json, and it never consultedindividual_limitat all, so the raw balance is what users saw. (build_result, which does check the limit, is only reachable from tests.)Now: spend is derived from the spend-control limit when one is reported, and not claimed at all when it isn't — a balance on its own genuinely cannot tell you what was spent. The balance is still surfaced, as its own info-only "Credits" line, so nothing is lost.
OpenCode Go
Already displayed the Zen balance honestly as an info-only line, then reported the same number as cost two lines later. The parser matches
currentBalance/zenBalance/balanceUsd— all meaning remaining. The bogus cost snapshot is removed; the honest line stays.Verification
805 rust (up from 801) / 474 tauri, clippy clean. Three new Codex tests cover: balance-without-limit claims no spend but still shows the balance; balance-with-limit derives
$40 of $50; unlimited credits report neither.Provenance
Found by an audit of the core providers only (Claude, Codex, Cursor, Copilot, Grok, Antigravity, OpenCode Go). Same defect class as the Cursor on-demand work in 1.5.24. The audit turned up five further findings, which I'm filing as issues rather than folding in here.
Summary by CodeRabbit