diff --git a/src/usage/openai_helpers.rs b/src/usage/openai_helpers.rs index 448c6ca1f4..9b33b7bd2a 100644 --- a/src/usage/openai_helpers.rs +++ b/src/usage/openai_helpers.rs @@ -8,15 +8,18 @@ pub(super) struct ParsedOpenAIUsageReport { pub(super) hard_limit_reached: bool, } -fn normalize_ratio_value(raw: f32) -> f32 { +pub(super) fn normalize_ratio_value(raw: f32) -> f32 { if !raw.is_finite() { return 0.0; } - if raw > 1.0 { - (raw / 100.0).clamp(0.0, 1.0) - } else { - raw.clamp(0.0, 1.0) - } + // The ChatGPT `wham/usage` endpoint (and equivalent OpenAI account usage + // endpoints) always report `used_percent` as a value in `[0, 100]`. The + // previous implementation tried to auto-detect ratio-vs-percent based on + // `raw > 1.0`, which incorrectly mapped the legitimate response + // `used_percent: 1` (1% used) to a ratio of `1.0` (100% used). Treating + // the value as a percent unconditionally avoids that misclassification + // and matches the documented API contract. + (raw / 100.0).clamp(0.0, 1.0) } fn normalize_percent(raw: f32) -> f32 { diff --git a/src/usage/tests.rs b/src/usage/tests.rs index ea246bdbfa..ca8ebd9c18 100644 --- a/src/usage/tests.rs +++ b/src/usage/tests.rs @@ -641,3 +641,66 @@ fn test_account_usage_probe_detects_all_accounts_exhausted() { assert!(probe.best_available_alternative().is_none()); assert!(probe.switch_guidance().is_none()); } + +#[test] +fn test_normalize_ratio_value_treats_low_integer_values_as_percent() { + // Direct unit test for the normalization helper to lock in the contract: + // `wham/usage` and OpenAI account-usage endpoints report `used_percent` + // in `[0, 100]`. Inputs must always be divided by 100, never treated as + // an already-normalized ratio. + assert!((openai_helpers::normalize_ratio_value(0.0) - 0.0).abs() < 1e-6); + assert!((openai_helpers::normalize_ratio_value(1.0) - 0.01).abs() < 1e-6); + assert!((openai_helpers::normalize_ratio_value(5.0) - 0.05).abs() < 1e-6); + assert!((openai_helpers::normalize_ratio_value(50.0) - 0.5).abs() < 1e-6); + assert!((openai_helpers::normalize_ratio_value(100.0) - 1.0).abs() < 1e-6); + // Out of range / weird inputs are clamped, not exploded. + assert!((openai_helpers::normalize_ratio_value(150.0) - 1.0).abs() < 1e-6); + assert!((openai_helpers::normalize_ratio_value(-5.0) - 0.0).abs() < 1e-6); + assert!((openai_helpers::normalize_ratio_value(f32::NAN) - 0.0).abs() < 1e-6); +} + +#[test] +fn test_parse_openai_usage_payload_reports_low_percentages_correctly() { + // Regression for upstream PR #178 / issue #137. + // The live `wham/usage` payload returns `used_percent` values in + // `[0, 100]`. A weekly bucket reporting `1` (1% used) must not be + // misclassified as a fully exhausted ratio of `1.0` (100% used). This + // mirrors the shape of a real production response from + // `https://chatgpt.com/backend-api/wham/usage`. + let json = serde_json::json!({ + "plan_type": "prolite", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 5, + "reset_at": 1_778_283_299_i64 + }, + "secondary_window": { + "used_percent": 1, + "reset_at": 1_778_870_099_i64 + } + }, + "additional_rate_limits": [{ + "limit_name": "GPT-5.3-Codex-Spark", + "rate_limit": { + "allowed": true, + "primary_window": { "used_percent": 5, "reset_at": 1_778_283_310_i64 }, + "secondary_window": { "used_percent": 1, "reset_at": 1_778_870_110_i64 } + } + }] + }); + + let parsed = openai_helpers::parse_openai_usage_payload(&json); + + assert!(!parsed.hard_limit_reached); + let by_name: std::collections::HashMap<_, _> = parsed + .limits + .iter() + .map(|l| (l.name.as_str(), l.usage_percent)) + .collect(); + assert_eq!(by_name.get("5-hour window"), Some(&5.0)); + assert_eq!(by_name.get("7-day window"), Some(&1.0)); + assert_eq!(by_name.get("GPT-5.3-Codex-Spark (5h)"), Some(&5.0)); + assert_eq!(by_name.get("GPT-5.3-Codex-Spark (7d)"), Some(&1.0)); +} diff --git a/src/usage_openai.rs b/src/usage_openai.rs index 448c6ca1f4..b1e794991e 100644 --- a/src/usage_openai.rs +++ b/src/usage_openai.rs @@ -12,11 +12,14 @@ fn normalize_ratio_value(raw: f32) -> f32 { if !raw.is_finite() { return 0.0; } - if raw > 1.0 { - (raw / 100.0).clamp(0.0, 1.0) - } else { - raw.clamp(0.0, 1.0) - } + // The ChatGPT `wham/usage` endpoint (and equivalent OpenAI account usage + // endpoints) always report `used_percent` as a value in `[0, 100]`. The + // previous implementation tried to auto-detect ratio-vs-percent based on + // `raw > 1.0`, which incorrectly mapped the legitimate response + // `used_percent: 1` (1% used) to a ratio of `1.0` (100% used). Treating + // the value as a percent unconditionally avoids that misclassification + // and matches the documented API contract. + (raw / 100.0).clamp(0.0, 1.0) } fn normalize_percent(raw: f32) -> f32 {