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..b1972497 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"; @@ -344,20 +344,27 @@ 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", 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 +432,27 @@ 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. + // 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] fn parses_workspace_ids() { let text = r#"{ id: "wrk_abc123", name: "x" } { id: "wrk_def456" }"#;