Skip to content

Commit 618dc7b

Browse files
committed
fix(usage): normalize OpenAI used_percent unconditionally as percent
The wham/usage and OpenAI account-usage endpoints always return `used_percent` in [0, 100]. The previous heuristic (`raw > 1.0`) misclassified low integer values like `used_percent: 1` (1% used) as the already-normalized ratio `1.0` (100% used), which made the CLI and `/usage` overlay report ChatGPT subscriptions as fully exhausted when the live endpoint reported only a few percent consumed. Drop the heuristic in both src/usage/openai_helpers.rs and the legacy src/usage_openai.rs and divide by 100 unconditionally. Add two regression tests in src/usage/tests.rs: - direct unit coverage of the helper across 0/1/5/50/100/>100/-5/NaN - parse_openai_usage_payload regression built from a real wham/usage response with used_percent: 1 in the secondary window Ports upstream PR 1jehuang#178 by @ktmyname (Cargo.lock version-bump hunk skipped, our fork tracks its own version). Closes quangdang46#137
1 parent e1886bb commit 618dc7b

3 files changed

Lines changed: 80 additions & 11 deletions

File tree

src/usage/openai_helpers.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@ pub(super) struct ParsedOpenAIUsageReport {
88
pub(super) hard_limit_reached: bool,
99
}
1010

11-
fn normalize_ratio_value(raw: f32) -> f32 {
11+
pub(super) fn normalize_ratio_value(raw: f32) -> f32 {
1212
if !raw.is_finite() {
1313
return 0.0;
1414
}
15-
if raw > 1.0 {
16-
(raw / 100.0).clamp(0.0, 1.0)
17-
} else {
18-
raw.clamp(0.0, 1.0)
19-
}
15+
// The ChatGPT `wham/usage` endpoint (and equivalent OpenAI account usage
16+
// endpoints) always report `used_percent` as a value in `[0, 100]`. The
17+
// previous implementation tried to auto-detect ratio-vs-percent based on
18+
// `raw > 1.0`, which incorrectly mapped the legitimate response
19+
// `used_percent: 1` (1% used) to a ratio of `1.0` (100% used). Treating
20+
// the value as a percent unconditionally avoids that misclassification
21+
// and matches the documented API contract.
22+
(raw / 100.0).clamp(0.0, 1.0)
2023
}
2124

2225
fn normalize_percent(raw: f32) -> f32 {

src/usage/tests.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,3 +641,66 @@ fn test_account_usage_probe_detects_all_accounts_exhausted() {
641641
assert!(probe.best_available_alternative().is_none());
642642
assert!(probe.switch_guidance().is_none());
643643
}
644+
645+
#[test]
646+
fn test_normalize_ratio_value_treats_low_integer_values_as_percent() {
647+
// Direct unit test for the normalization helper to lock in the contract:
648+
// `wham/usage` and OpenAI account-usage endpoints report `used_percent`
649+
// in `[0, 100]`. Inputs must always be divided by 100, never treated as
650+
// an already-normalized ratio.
651+
assert!((openai_helpers::normalize_ratio_value(0.0) - 0.0).abs() < 1e-6);
652+
assert!((openai_helpers::normalize_ratio_value(1.0) - 0.01).abs() < 1e-6);
653+
assert!((openai_helpers::normalize_ratio_value(5.0) - 0.05).abs() < 1e-6);
654+
assert!((openai_helpers::normalize_ratio_value(50.0) - 0.5).abs() < 1e-6);
655+
assert!((openai_helpers::normalize_ratio_value(100.0) - 1.0).abs() < 1e-6);
656+
// Out of range / weird inputs are clamped, not exploded.
657+
assert!((openai_helpers::normalize_ratio_value(150.0) - 1.0).abs() < 1e-6);
658+
assert!((openai_helpers::normalize_ratio_value(-5.0) - 0.0).abs() < 1e-6);
659+
assert!((openai_helpers::normalize_ratio_value(f32::NAN) - 0.0).abs() < 1e-6);
660+
}
661+
662+
#[test]
663+
fn test_parse_openai_usage_payload_reports_low_percentages_correctly() {
664+
// Regression for upstream PR #178 / issue #137.
665+
// The live `wham/usage` payload returns `used_percent` values in
666+
// `[0, 100]`. A weekly bucket reporting `1` (1% used) must not be
667+
// misclassified as a fully exhausted ratio of `1.0` (100% used). This
668+
// mirrors the shape of a real production response from
669+
// `https://chatgpt.com/backend-api/wham/usage`.
670+
let json = serde_json::json!({
671+
"plan_type": "prolite",
672+
"rate_limit": {
673+
"allowed": true,
674+
"limit_reached": false,
675+
"primary_window": {
676+
"used_percent": 5,
677+
"reset_at": 1_778_283_299_i64
678+
},
679+
"secondary_window": {
680+
"used_percent": 1,
681+
"reset_at": 1_778_870_099_i64
682+
}
683+
},
684+
"additional_rate_limits": [{
685+
"limit_name": "GPT-5.3-Codex-Spark",
686+
"rate_limit": {
687+
"allowed": true,
688+
"primary_window": { "used_percent": 5, "reset_at": 1_778_283_310_i64 },
689+
"secondary_window": { "used_percent": 1, "reset_at": 1_778_870_110_i64 }
690+
}
691+
}]
692+
});
693+
694+
let parsed = openai_helpers::parse_openai_usage_payload(&json);
695+
696+
assert!(!parsed.hard_limit_reached);
697+
let by_name: std::collections::HashMap<_, _> = parsed
698+
.limits
699+
.iter()
700+
.map(|l| (l.name.as_str(), l.usage_percent))
701+
.collect();
702+
assert_eq!(by_name.get("5-hour window"), Some(&5.0));
703+
assert_eq!(by_name.get("7-day window"), Some(&1.0));
704+
assert_eq!(by_name.get("GPT-5.3-Codex-Spark (5h)"), Some(&5.0));
705+
assert_eq!(by_name.get("GPT-5.3-Codex-Spark (7d)"), Some(&1.0));
706+
}

src/usage_openai.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ fn normalize_ratio_value(raw: f32) -> f32 {
1212
if !raw.is_finite() {
1313
return 0.0;
1414
}
15-
if raw > 1.0 {
16-
(raw / 100.0).clamp(0.0, 1.0)
17-
} else {
18-
raw.clamp(0.0, 1.0)
19-
}
15+
// The ChatGPT `wham/usage` endpoint (and equivalent OpenAI account usage
16+
// endpoints) always report `used_percent` as a value in `[0, 100]`. The
17+
// previous implementation tried to auto-detect ratio-vs-percent based on
18+
// `raw > 1.0`, which incorrectly mapped the legitimate response
19+
// `used_percent: 1` (1% used) to a ratio of `1.0` (100% used). Treating
20+
// the value as a percent unconditionally avoids that misclassification
21+
// and matches the documented API contract.
22+
(raw / 100.0).clamp(0.0, 1.0)
2023
}
2124

2225
fn normalize_percent(raw: f32) -> f32 {

0 commit comments

Comments
 (0)