diff --git a/rust/src/providers/opencode/mod.rs b/rust/src/providers/opencode/mod.rs index d7c3fd861c..e2a8e4c144 100755 --- a/rust/src/providers/opencode/mod.rs +++ b/rust/src/providers/opencode/mod.rs @@ -345,7 +345,10 @@ impl OpenCodeProvider { } fn window_percent(obj: &Value) -> Option { - let percent_keys = [ + // Direct percent fields may arrive as 0..1 fractions or 0..100 percents. + // Upstream #2331: only apply the *100 heuristic to *direct* fields — never to + // a computed used/limit ratio (already 0..100; e.g. used=1, limit=100 → 1.0). + const DIRECT_PERCENT_KEYS: &[&str] = &[ "usagePercent", "usedPercent", "percentUsed", @@ -356,25 +359,35 @@ impl OpenCodeProvider { "utilization", "utilizationPercent", "utilization_percent", - "usage", "value", ]; - Self::first_f64(obj, &percent_keys) - .map(|val| if val <= 1.0 { val * 100.0 } else { val }) - .or_else(|| Self::percent_from_used_limit(obj)) + if let Some(val) = Self::first_f64(obj, DIRECT_PERCENT_KEYS) { + let scaled = if (0.0..=1.0).contains(&val) { + val * 100.0 + } else { + val + }; + return Some(scaled); + } + Self::percent_from_used_limit(obj) } fn percent_from_used_limit(obj: &Value) -> Option { let used = obj .get("used") .or(obj.get("usage")) + .or(obj.get("consumed")) + .or(obj.get("count")) + .or(obj.get("usedTokens")) .and_then(|v| v.as_f64()); let limit = obj .get("limit") .or(obj.get("total")) + .or(obj.get("allowance")) .and_then(|v| v.as_f64()); match (used, limit) { + // Already 0..100 — do not run the direct-fraction *100 heuristic. (Some(used), Some(limit)) if limit > 0.0 => Some((used / limit) * 100.0), _ => None, } @@ -720,6 +733,39 @@ mod tests { assert!((snap.secondary.as_ref().unwrap().used_percent - 25.0).abs() < f64::EPSILON); } + #[test] + fn sub_one_percent_computed_used_limit_is_not_rescaled_to_100() { + // Upstream #2331: used=1, limit=100 → 1%, not 100% (false exhausted). + let provider = OpenCodeProvider::new(); + let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); + let payload = serde_json::json!({ + "rollingUsage": { "used": 1, "limit": 100, "resetInSec": 600 }, + "weeklyUsage": { "used": 1, "limit": 200, "resetInSec": 86400 } + }); + let snap = provider.parse_usage_json(&payload, now).expect("snapshot"); + assert!( + (snap.primary.used_percent - 1.0).abs() < 0.001, + "primary {}", + snap.primary.used_percent + ); + assert!( + (snap.secondary.as_ref().unwrap().used_percent - 0.5).abs() < 0.001, + "secondary {}", + snap.secondary.as_ref().unwrap().used_percent + ); + } + + #[test] + fn direct_fractional_usage_percent_still_scales() { + let provider = OpenCodeProvider::new(); + let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); + let payload = serde_json::json!({ + "rollingUsage": { "usagePercent": 0.25, "resetInSec": 600 } + }); + let snap = provider.parse_usage_json(&payload, now).expect("snapshot"); + assert!((snap.primary.used_percent - 25.0).abs() < 0.001); + } + #[test] fn parse_subscription_accepts_realistic_server_fn_body() { let provider = OpenCodeProvider::new(); diff --git a/rust/src/providers/opencode/scraper.rs b/rust/src/providers/opencode/scraper.rs index a547d83699..bdd8a03359 100755 --- a/rust/src/providers/opencode/scraper.rs +++ b/rust/src/providers/opencode/scraper.rs @@ -29,7 +29,7 @@ const PERCENT_KEYS: &[&str] = &[ "utilization", "utilizationPercent", "utilization_percent", - "usage", + // Note: raw "usage" is often a token count — handled via used/limit only. ]; /// Keys to look for when parsing reset time in seconds @@ -470,25 +470,37 @@ impl OpenCodeUsageFetcher { /// Parse a window object into (percent, reset_in_sec) fn parse_window(json: &serde_json::Value, _now: DateTime) -> Option<(f64, i64)> { - let percent = PERCENT_KEYS + // Direct percent field (may be 0..1 fraction or 0..100). + let direct = PERCENT_KEYS .iter() .find_map(|k| json.get(k).and_then(|v| v.as_f64())); + let percent_is_direct = direct.is_some(); + + let percent = direct.or_else(|| { + // Computed used/limit is already 0..100 — never apply fraction rescale (#2331). + let used = ["used", "usage", "consumed", "count", "usedTokens"] + .iter() + .find_map(|k| json.get(*k).and_then(|v| v.as_f64())); + let limit = ["limit", "total", "allowance"] + .iter() + .find_map(|k| json.get(*k).and_then(|v| v.as_f64())); + match (used, limit) { + (Some(u), Some(l)) if l > 0.0 => Some((u / l) * 100.0), + _ => None, + } + })?; let reset_in = RESET_IN_KEYS .iter() - .find_map(|k| json.get(k).and_then(|v| v.as_i64())); - - match (percent, reset_in) { - (Some(p), Some(r)) => { - let normalized_percent = if (0.0..=1.0).contains(&p) { - p * 100.0 - } else { - p.clamp(0.0, 100.0) - }; - Some((normalized_percent, r.max(0))) - } - _ => None, - } + .find_map(|k| json.get(k).and_then(|v| v.as_i64())) + .unwrap_or(0); + + let normalized_percent = if percent_is_direct && (0.0..=1.0).contains(&percent) { + percent * 100.0 + } else { + percent.clamp(0.0, 100.0) + }; + Some((normalized_percent.clamp(0.0, 100.0), reset_in.max(0))) } fn find_datetime(json: &serde_json::Value, keys: &[&str]) -> Option> { diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index 12a6e865b3..f8ee8b3cbe 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -190,9 +190,34 @@ impl OpenCodeGoProvider { let reset = Self::extract_number(&reset_pattern, text) .map(|n| n as i64) .unwrap_or(0); - let p = if p <= 1.0 { p * 100.0 } else { p }; + // Regex path only matches direct percent field names — fraction + // heuristic is safe here (upstream #2331). used/limit computed + // percents must not use this path without a separate gate. + let p = if (0.0..=1.0).contains(&p) { p * 100.0 } else { p }; return Some((p.clamp(0.0, 100.0), reset.max(0))); } + + // Computed used/limit (already 0..100) — do not apply fraction *100. + let used_pattern = format!( + r#"{}[^}}]*?(?:used|usage|consumed)\s*[:=]\s*([0-9]+(?:\.[0-9]+)?)"#, + name + ); + let limit_pattern = format!( + r#"{}[^}}]*?(?:limit|total|allowance)\s*[:=]\s*([0-9]+(?:\.[0-9]+)?)"#, + name + ); + if let (Some(used), Some(limit)) = ( + Self::extract_number(&used_pattern, text), + Self::extract_number(&limit_pattern, text), + ) { + if limit > 0.0 { + let reset = Self::extract_number(&reset_pattern, text) + .map(|n| n as i64) + .unwrap_or(0); + let p = (used / limit) * 100.0; + return Some((p.clamp(0.0, 100.0), reset.max(0))); + } + } } None } @@ -429,6 +454,17 @@ mod tests { ); } + #[test] + fn sub_one_percent_computed_used_limit_is_not_rescaled() { + let text = r#" + rollingUsage: { used: 1, limit: 100, resetInSec: 600 } + weeklyUsage: { used: 1, limit: 200, resetInSec: 86400 } + "#; + let snap = OpenCodeGoProvider::parse_usage_text(text).unwrap(); + assert!((snap.primary.used_percent - 1.0).abs() < 0.001); + assert!((snap.secondary.as_ref().unwrap().used_percent - 0.5).abs() < 0.001); + } + #[test] fn parses_usage_blocks() { let text = r#" @@ -439,7 +475,7 @@ mod tests { let snap = OpenCodeGoProvider::parse_usage_text(text).unwrap(); assert!((snap.primary.used_percent - 42.5).abs() < 0.001); let secondary = snap.secondary.expect("weekly"); - // 0.13 normalized as fraction → 13% + // usagePercent: 0.13 is a direct fraction → 13% assert!((secondary.used_percent - 13.0).abs() < 0.001); let tertiary = snap.tertiary.expect("monthly"); assert!((tertiary.used_percent - 7.0).abs() < 0.001);