From c7c36e3b8c188969eb7ffdc591cd8a4f51fde0e0 Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Wed, 5 Aug 2026 03:58:04 -0400 Subject: [PATCH 1/2] Stop reporting credit balance as money spent 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. --- rust/src/providers/codex/api.rs | 132 ++++++++++++++++++++++----- rust/src/providers/opencodego/mod.rs | 24 +++-- 2 files changed, 127 insertions(+), 29 deletions(-) diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 807404fb..3c0c691f 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -354,6 +354,15 @@ impl CodexApi { // Extract credits if present let cost = self.extract_credits(json); + if let Some(balance) = Self::credit_balance(json) { + // `balance` is credit remaining, not money spent, so it gets its own + // info-only line rather than being passed off as cost. + usage = usage.with_extra_rate_window( + "codex-credit-balance", + "Credits", + RateWindow::with_details(0.0, None, None, Some(format!("${balance:.2} left"))), + ); + } Ok((usage, cost)) } @@ -491,33 +500,45 @@ impl CodexApi { )) } - fn extract_credits(&self, json: &serde_json::Value) -> Option { + /// The remaining credit balance, when the account has metered credits. + /// + /// This is what is *left*, never what was spent. Callers must not put it in + /// a `CostSnapshot`'s `used` slot. + fn credit_balance(json: &serde_json::Value) -> Option { let credits = json.get("credits")?; - - let has_credits = credits + if !credits .get("has_credits") .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if !has_credits { + .unwrap_or(false) + { return None; } - - let unlimited = credits + if credits .get("unlimited") .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if unlimited { + .unwrap_or(false) + { return None; } + credits.get("balance").and_then(|v| v.as_f64()) + } - let balance = credits - .get("balance") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - - Some(CostSnapshot::new(balance, "USD", "Credits")) + /// Money actually spent against a credit allowance. + /// + /// Spend is only knowable when the account also reports a spend-control + /// limit: `used = limit - balance`. A balance on its own says how much is + /// left, so reporting it as `used` inverts the number — it would fall as + /// the user spends and read $0 exactly when the account is exhausted. The + /// balance is surfaced separately as its own line instead. + fn extract_credits(&self, json: &serde_json::Value) -> Option { + let balance = Self::credit_balance(json)?; + let limit = json.get("individual_limit").or_else(|| { + json.get("rate_limit") + .and_then(|r| r.get("individual_limit")) + })?; + serde_json::from_value::(limit.clone()) + .ok()? + .to_cost_snapshot(balance) } fn build_result( @@ -596,12 +617,10 @@ impl CodexApi { let balance = credits.balance.unwrap_or(0.0); if credits.unlimited() { None // Unlimited credits, no need to show - } else if let Some(limit) = - credit_limit.and_then(|limit| limit.to_cost_snapshot(balance)) - { - Some(limit) } else { - Some(CostSnapshot::new(balance, "USD", "Credits")) + // Without a limit there is no way to know what was spent; + // `balance` is what remains. Never report it as `used`. + credit_limit.and_then(|limit| limit.to_cost_snapshot(balance)) } } else { None @@ -1218,6 +1237,75 @@ mod tests { assert!(!lifted); } + #[test] + fn credit_balance_is_never_reported_as_money_spent() { + // `balance` is what is LEFT. Reporting it as `used` inverted the + // number: it fell as the user spent and read $0 at exhaustion. + let api = CodexApi::new(); + let (usage, cost) = api + .build_result_from_json(&json!({ + "rate_limit": { + "primary_window": { "used_percent": 10, "limit_window_seconds": 18000 } + }, + "credits": { "has_credits": true, "unlimited": false, "balance": 10.0 } + })) + .expect("codex usage"); + + // No spend-control limit, so spend is unknowable and none is claimed. + assert!(cost.is_none(), "a balance alone is not spend"); + // The balance is still shown, as its own line. + let credits = usage + .extra_rate_windows + .iter() + .find(|w| w.id == "codex-credit-balance") + .expect("credit balance line"); + assert_eq!(credits.title, "Credits"); + assert_eq!( + credits.window.reset_description.as_deref(), + Some("$10.00 left") + ); + } + + #[test] + fn credit_spend_is_derived_from_the_spend_control_limit() { + // With a limit, real spend is limit - balance: $40 of $50. + let api = CodexApi::new(); + let (_, cost) = api + .build_result_from_json(&json!({ + "rate_limit": { + "primary_window": { "used_percent": 10, "limit_window_seconds": 18000 } + }, + "credits": { "has_credits": true, "unlimited": false, "balance": 10.0 }, + "individual_limit": { "limit": 50.0 } + })) + .expect("codex usage"); + + let cost = cost.expect("cost from spend-control limit"); + assert!((cost.used - 40.0).abs() < 0.01, "used = limit - balance"); + assert_eq!(cost.limit, Some(50.0)); + } + + #[test] + fn unlimited_credits_report_neither_spend_nor_balance() { + let api = CodexApi::new(); + let (usage, cost) = api + .build_result_from_json(&json!({ + "rate_limit": { + "primary_window": { "used_percent": 10, "limit_window_seconds": 18000 } + }, + "credits": { "has_credits": true, "unlimited": true, "balance": 10.0 } + })) + .expect("codex usage"); + + assert!(cost.is_none()); + assert!( + !usage + .extra_rate_windows + .iter() + .any(|w| w.id == "codex-credit-balance") + ); + } + #[test] fn ignores_placeholder_additional_rate_limits() { let api = CodexApi::new(); diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index c2ebb37a..7edbbc9b 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -10,8 +10,8 @@ use reqwest::Client; use uuid::Uuid; use crate::core::{ - CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + RateWindow, SourceMode, UsageSnapshot, }; const BASE_URL: &str = "https://opencode.ai"; @@ -353,11 +353,11 @@ impl OpenCodeGoProvider { RateWindow::with_details(0.0, None, None, Some(format!("${balance:.2}"))), ); } - let mut result = ProviderFetchResult::new(usage, "web"); - if let Some(balance) = balance { - result = result.with_cost(CostSnapshot::new(balance, "USD", "Zen balance")); - } - Ok(result) + // The Zen balance is credit *remaining*, so it is shown above as an + // info-only line and deliberately not reported as cost: putting it in + // `CostSnapshot.used` would make spend fall as the user spends, and + // read $0 at exactly the moment the balance runs out. + Ok(ProviderFetchResult::new(usage, "web")) } } @@ -425,6 +425,16 @@ impl Provider for OpenCodeGoProvider { mod tests { use super::*; + #[test] + fn zen_balance_is_shown_as_remaining_not_as_spend() { + // `currentBalance` is credit left. It used to be reported as + // CostSnapshot.used, which inverted it: spend fell as the user spent + // and read $0 exactly when the balance ran out. + let balance = + OpenCodeGoProvider::parse_zen_balance(r#"{"currentBalance": 12.5, "other": 1}"#); + assert_eq!(balance, Some(12.5)); + } + #[test] fn parses_workspace_ids() { let text = r#"{ id: "wrk_abc123", name: "x" } { id: "wrk_def456" }"#; From 614649063801ee73f63b036b80b5fa3221b4e6c2 Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Wed, 5 Aug 2026 04:07:49 -0400 Subject: [PATCH 2/2] Address review: pin the OpenCode Go behavior, not just the parser 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. --- rust/src/providers/opencodego/mod.rs | 30 ++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index 7edbbc9b..b1972497 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -344,9 +344,16 @@ impl OpenCodeGoProvider { None => self.fetch_workspace_id(cookie_header).await?, }; let page = self.fetch_usage_page(&workspace_id, cookie_header).await?; - let mut usage = Self::parse_usage_text(&page)?; - let balance = Self::parse_zen_balance(&page); - if let Some(balance) = balance { + Self::result_from_page(&page) + } + + /// Build the snapshot from an already-fetched usage page. + /// + /// Split out from the fetch so the balance handling is testable without a + /// network round trip. + fn result_from_page(page: &str) -> Result { + let mut usage = Self::parse_usage_text(page)?; + if let Some(balance) = Self::parse_zen_balance(page) { usage = usage.with_extra_rate_window( "zen-balance", "Zen balance", @@ -430,9 +437,20 @@ mod tests { // `currentBalance` is credit left. It used to be reported as // CostSnapshot.used, which inverted it: spend fell as the user spent // and read $0 exactly when the balance ran out. - let balance = - OpenCodeGoProvider::parse_zen_balance(r#"{"currentBalance": 12.5, "other": 1}"#); - assert_eq!(balance, Some(12.5)); + // Shaped like the real page: unquoted JS keys for the windows, and the + // balance rendered as display text. + let page = r#"{rollingUsage: {usedPercent: 40, resetInSec: 3600}} Current balance $12.50"#; + let result = OpenCodeGoProvider::result_from_page(page).expect("usage"); + + assert!(result.cost.is_none(), "a balance is not money spent"); + let zen = result + .usage + .extra_rate_windows + .iter() + .find(|w| w.id == "zen-balance") + .expect("zen balance line"); + assert_eq!(zen.window.reset_description.as_deref(), Some("$12.50")); + assert_eq!(zen.window.used_percent, 0.0, "info-only, not a meter"); } #[test]