From 1065631f180edcd62a470fd3d51125c949ec86a5 Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Mon, 3 Aug 2026 00:03:39 -0400 Subject: [PATCH] Stop 1% usage reading as 100% across providers --- CHANGELOG.md | 4 + rust/src/core/mod.rs | 2 + rust/src/core/percent_scale.rs | 65 ++++++++++++++ rust/src/providers/chutes/mod.rs | 54 ++++++++++-- rust/src/providers/opencode/mod.rs | 83 ++++++++++++++---- rust/src/providers/opencode/scraper.rs | 40 ++++++--- rust/src/providers/opencodego/mod.rs | 71 ++++++++++++--- rust/src/providers/qoder/mod.rs | 72 +++++++++++---- rust/src/providers/sakana/mod.rs | 116 ++++++++++++++++++------- 9 files changed, 416 insertions(+), 91 deletions(-) create mode 100644 rust/src/core/percent_scale.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b648e3bc..e8fb0df8 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed +- A lightly used OpenCode Go account could report its rolling window as 100% used while the dashboard showed 1%. The usage page reports each window as either whole percentages or fractions of the limit, and a lone `1` means 1% in one and 100% in the other. The old rule scaled any value at or below 1 by 100 window-by-window, so the first 1% of use rendered as a maxed-out rolling window. The scale is now resolved once per response, and only read as fractions when a window actually contains a fractional value, which is the case that proves it. +- The same 1%-reads-as-100% scale bug affected the OpenCode, Qoder, Chutes, and Sakana providers, all of which scaled values at or below 1 by 100 window-by-window. OpenCode (same backend as OpenCode Go), Qoder, and Chutes now resolve the scale once per response like OpenCode Go. Sakana no longer scales literal percent text at all (a page showing "1%" cannot mean 100% used), and only its JSON percent keys use the evidence-based scale. + ## [Ceiling] 1.5.21 - 2026-08-01 Patch release for duplicate scheduled-reset notifications and reliability hardening around local state and account-scoped history. diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index 5c9f3ba9..f91c53f6 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -11,6 +11,7 @@ mod http; mod jsonl_scanner; mod models_dev_pricing; mod openai_dashboard; +mod percent_scale; mod provider; mod provider_factory; mod rate_window; @@ -32,6 +33,7 @@ pub use http::*; pub use jsonl_scanner::*; pub use models_dev_pricing::*; pub use openai_dashboard::*; +pub use percent_scale::*; pub use provider::*; pub use provider_factory::instantiate as instantiate_provider; pub use rate_window::*; diff --git a/rust/src/core/percent_scale.rs b/rust/src/core/percent_scale.rs new file mode 100644 index 00000000..b183885b --- /dev/null +++ b/rust/src/core/percent_scale.rs @@ -0,0 +1,65 @@ +//! Resolve whether a provider reports usage as whole percentages or as +//! fractions of a limit, from the raw values in one response. + +/// Detect whether a response reports usage as fractions of a limit (`0.23` = +/// 23%) or whole percentages (`23` = 23%). +/// +/// Two things settle it, and only real evidence in the payload counts: +/// +/// - a value above `1.0` can only be a percentage, since a fraction never +/// exceeds the limit; +/// - a value strictly between `0` and `1` can only be a fraction, since these +/// APIs report whole percentages. +/// +/// With neither, every window is `0` or `1` and the response is genuinely +/// ambiguous. That is read as percentages, because the alternative is worse in +/// practice: an account that has just been used reports `1` for 1%, and calling +/// that a fraction renders a barely-touched window as **100% used**, which is +/// what the per-window `<= 1.0` rule previously did. +pub fn detect_fraction_scale(values: impl IntoIterator) -> bool { + let mut saw_fraction = false; + for value in values { + if !value.is_finite() { + continue; + } + if value > 1.0 { + return false; + } + if value > 0.0 && value < 1.0 { + saw_fraction = true; + } + } + saw_fraction +} + +/// Convert one raw reported value to a 0-100 percentage. +pub fn to_percent(raw: f64, fraction_scale: bool) -> f64 { + if fraction_scale { + (raw * 100.0).clamp(0.0, 100.0) + } else { + raw.clamp(0.0, 100.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_scale_from_evidence() { + assert!(!detect_fraction_scale([50.0, 3.0, 0.0])); + assert!(detect_fraction_scale([0.5, 0.0, 0.0])); + assert!(detect_fraction_scale([0.14, 0.0])); + assert!(!detect_fraction_scale([1.0, 0.0])); + assert!(!detect_fraction_scale([0.0, 0.0])); + assert!(!detect_fraction_scale([])); + } + + #[test] + fn converts_with_selected_scale() { + assert!((to_percent(1.0, false) - 1.0).abs() < 0.001); + assert!((to_percent(1.0, true) - 100.0).abs() < 0.001); + assert!((to_percent(0.14, true) - 14.0).abs() < 0.001); + assert!((to_percent(23.0, false) - 23.0).abs() < 0.001); + } +} diff --git a/rust/src/providers/chutes/mod.rs b/rust/src/providers/chutes/mod.rs index 6561e074..30fcb87e 100644 --- a/rust/src/providers/chutes/mod.rs +++ b/rust/src/providers/chutes/mod.rs @@ -108,31 +108,60 @@ fn snapshot_from_usage(value: &Value) -> UsageSnapshot { } fn quota_windows(value: &Value) -> Vec { + let mut raw_percents = Vec::new(); + collect_reported_percents(value, &mut raw_percents); + let fraction_scale = crate::core::detect_fraction_scale(raw_percents); let mut out = Vec::new(); - collect_windows(value, &mut out); + collect_windows(value, fraction_scale, &mut out); out } -fn collect_windows(value: &Value, out: &mut Vec) { +fn collect_reported_percents(value: &Value, out: &mut Vec) { match value { Value::Object(map) => { - if let Some(percent) = percent_from_object(map) { + for key in [ + "usage_percent", + "usagePercent", + "percent_used", + "percentUsed", + ] { + if let Some(v) = map.get(key).and_then(Value::as_f64) { + out.push(v); + } + } + for value in map.values() { + collect_reported_percents(value, out); + } + } + Value::Array(items) => { + for value in items { + collect_reported_percents(value, out); + } + } + _ => {} + } +} + +fn collect_windows(value: &Value, fraction_scale: bool, out: &mut Vec) { + match value { + Value::Object(map) => { + if let Some(percent) = percent_from_object(map, fraction_scale) { out.push(RateWindow::new(percent)); } for value in map.values() { - collect_windows(value, out); + collect_windows(value, fraction_scale, out); } } Value::Array(items) => { for value in items { - collect_windows(value, out); + collect_windows(value, fraction_scale, out); } } _ => {} } } -fn percent_from_object(map: &serde_json::Map) -> Option { +fn percent_from_object(map: &serde_json::Map, fraction_scale: bool) -> Option { for key in [ "usage_percent", "usagePercent", @@ -140,7 +169,7 @@ fn percent_from_object(map: &serde_json::Map) -> Option { "percentUsed", ] { if let Some(v) = map.get(key).and_then(Value::as_f64) { - return Some(if v <= 1.0 { v * 100.0 } else { v }); + return Some(crate::core::to_percent(v, fraction_scale)); } } let used = ["used", "usage", "current_usage", "currentUsage"] @@ -165,4 +194,15 @@ mod tests { snapshot_from_usage(&serde_json::json!({"quotas":[{"used":25,"limit":100}]})); assert_eq!(snapshot.primary.used_percent, 25.0); } + + #[test] + fn one_percent_window_is_not_full() { + let snapshot = snapshot_from_usage(&serde_json::json!({ + "quotas": [ + {"usagePercent": 1, "limit": 100}, + {"usagePercent": 0, "limit": 100} + ] + })); + assert!((snapshot.primary.used_percent - 1.0).abs() < 0.001); + } } diff --git a/rust/src/providers/opencode/mod.rs b/rust/src/providers/opencode/mod.rs index 20073f11..a210c509 100755 --- a/rust/src/providers/opencode/mod.rs +++ b/rust/src/providers/opencode/mod.rs @@ -226,15 +226,26 @@ impl OpenCodeProvider { self.find_usage_window(json, &["rollingUsage", "rolling", "rolling_usage"])?; let weekly = self.find_usage_window(json, &["weeklyUsage", "weekly", "weekly_usage"])?; + // A window reports either a whole percentage (23 = 23%) or a fraction of + // the limit (0.23 = 23%). A lone `1` is ambiguous, so resolve the scale + // from every reported window in this response before converting. + let fraction_scale = crate::core::detect_fraction_scale( + [rolling, weekly] + .into_iter() + .filter_map(|(percent, _, from_percent_key)| from_percent_key.then_some(percent)), + ); + let rolling_percent = Self::resolve_percent(rolling, fraction_scale); + let weekly_percent = Self::resolve_percent(weekly, fraction_scale); + let primary = RateWindow::with_details( - rolling.0, + rolling_percent, Some(300), Some(now + chrono::Duration::seconds(rolling.1)), None, ); let secondary = RateWindow::with_details( - weekly.0, + weekly_percent, Some(10080), Some(now + chrono::Duration::seconds(weekly.1)), None, @@ -255,12 +266,13 @@ impl OpenCodeProvider { } /// Find usage window in JSON by keys - fn find_usage_window(&self, json: &Value, keys: &[&str]) -> Option<(f64, i64)> { + fn find_usage_window(&self, json: &Value, keys: &[&str]) -> Option<(f64, i64, bool)> { for key in keys { if let Some(obj) = json.get(key) - && let Some(window) = self.parse_window(obj) + && let Some((percent, from_percent_key)) = Self::window_percent(obj) { - return Some(window); + let reset_sec = Self::window_reset_seconds(obj).unwrap_or(0); + return Some((percent, reset_sec, from_percent_key)); } } @@ -276,14 +288,7 @@ impl OpenCodeProvider { None } - /// Parse a usage window object - fn parse_window(&self, obj: &Value) -> Option<(f64, i64)> { - let percent = Self::window_percent(obj)?; - let reset_sec = Self::window_reset_seconds(obj).unwrap_or(0); - Some((percent.clamp(0.0, 100.0), reset_sec.max(0))) - } - - fn window_percent(obj: &Value) -> Option { + fn window_percent(obj: &Value) -> Option<(f64, bool)> { let percent_keys = [ "usagePercent", "usedPercent", @@ -297,9 +302,24 @@ impl OpenCodeProvider { "usage", ]; - 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, &percent_keys) { + return Some((val, true)); + } + Self::percent_from_used_limit(obj).map(|val| (val, false)) + } + + /// Convert a raw window value to a percentage, scaling it only when it came + /// from an ambiguous percent key. A used/limit fallback is already a + /// percentage. + fn resolve_percent( + (percent, _, from_percent_key): (f64, i64, bool), + fraction_scale: bool, + ) -> f64 { + if from_percent_key { + crate::core::to_percent(percent, fraction_scale) + } else { + percent.clamp(0.0, 100.0) + } } fn percent_from_used_limit(obj: &Value) -> Option { @@ -565,6 +585,37 @@ mod tests { ); } + #[test] + fn one_percent_window_is_not_full() { + // A lightly used account reports `1` for 1% on the whole-percent scale + // with the weekly window at 0. The old per-window rule read that lone + // `1` as a fraction and rendered the rolling window as 100% used. + let provider = OpenCodeProvider::new(); + let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); + let payload = serde_json::json!({ + "rollingUsage": { "usagePercent": 1, "resetInSec": 600 }, + "weeklyUsage": { "usagePercent": 0, "resetInSec": 3600 }, + }); + + let snap = provider.parse_usage_json(&payload, now).expect("snapshot"); + assert!((snap.primary.used_percent - 1.0).abs() < 0.001); + assert!((snap.secondary.unwrap().used_percent - 0.0).abs() < 0.001); + } + + #[test] + fn parses_fractional_windows() { + let provider = OpenCodeProvider::new(); + let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); + let payload = serde_json::json!({ + "rollingUsage": { "usagePercent": 0.14, "resetInSec": 600 }, + "weeklyUsage": { "usagePercent": 0.5, "resetInSec": 3600 }, + }); + + let snap = provider.parse_usage_json(&payload, now).expect("snapshot"); + assert!((snap.primary.used_percent - 14.0).abs() < 0.001); + assert!((snap.secondary.unwrap().used_percent - 50.0).abs() < 0.001); + } + #[test] fn ignores_out_of_range_reset_timestamps() { let payload = serde_json::json!({ "resetAt": i64::MAX }); diff --git a/rust/src/providers/opencode/scraper.rs b/rust/src/providers/opencode/scraper.rs index a547d836..67b81844 100755 --- a/rust/src/providers/opencode/scraper.rs +++ b/rust/src/providers/opencode/scraper.rs @@ -452,8 +452,17 @@ impl OpenCodeUsageFetcher { let weekly = weekly_keys.iter().find_map(|k| json.get(k)); if let (Some(rolling), Some(weekly)) = (rolling, weekly) { - let rolling_window = Self::parse_window(rolling, now)?; - let weekly_window = Self::parse_window(weekly, now)?; + // A window reports either a whole percentage (23 = 23%) or a + // fraction of the limit (0.23 = 23%). Resolve the scale from every + // window in this response before converting. + let fraction_scale = + crate::core::detect_fraction_scale([rolling, weekly].iter().filter_map(|obj| { + PERCENT_KEYS + .iter() + .find_map(|k| obj.get(*k).and_then(|v| v.as_f64())) + })); + let rolling_window = Self::parse_window(rolling, fraction_scale, now)?; + let weekly_window = Self::parse_window(weekly, fraction_scale, now)?; return Some(OpenCodeUsageSnapshot { rolling_usage_percent: rolling_window.0, @@ -469,7 +478,11 @@ impl OpenCodeUsageFetcher { } /// Parse a window object into (percent, reset_in_sec) - fn parse_window(json: &serde_json::Value, _now: DateTime) -> Option<(f64, i64)> { + fn parse_window( + json: &serde_json::Value, + fraction_scale: bool, + _now: DateTime, + ) -> Option<(f64, i64)> { let percent = PERCENT_KEYS .iter() .find_map(|k| json.get(k).and_then(|v| v.as_f64())); @@ -479,14 +492,7 @@ impl OpenCodeUsageFetcher { .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))) - } + (Some(p), Some(r)) => Some((crate::core::to_percent(p, fraction_scale), r.max(0))), _ => None, } } @@ -689,4 +695,16 @@ mod tests { assert_eq!(snapshot.rolling_reset_in_sec, 3600); assert_eq!(snapshot.weekly_reset_in_sec, 604800); } + + #[test] + fn one_percent_window_is_not_full() { + let json = r#"{ + "rollingUsage": {"usagePercent": 1, "resetInSec": 3600}, + "weeklyUsage": {"usagePercent": 0, "resetInSec": 604800} + }"#; + + let snapshot = OpenCodeUsageFetcher::parse_subscription(json, Utc::now()).unwrap(); + assert!((snapshot.rolling_usage_percent - 1.0).abs() < 0.01); + assert!((snapshot.weekly_usage_percent - 0.0).abs() < 0.01); + } } diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index 12a6e865..beb4be60 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -131,15 +131,31 @@ impl OpenCodeGoProvider { fn parse_usage_text(text: &str) -> Result { let now = Utc::now(); - let rolling = Self::extract_window(text, &["rollingUsage", "rolling_usage", "rolling"]) + let raw_rolling = Self::extract_window(text, &["rollingUsage", "rolling_usage", "rolling"]) .ok_or_else(|| ProviderError::Parse("Missing rolling usage window".to_string()))?; - let weekly = Self::extract_window(text, &["weeklyUsage", "weekly_usage", "weekly"]); - let monthly = Self::extract_window(text, &["monthlyUsage", "monthly_usage", "monthly"]); + let raw_weekly = Self::extract_window(text, &["weeklyUsage", "weekly_usage", "weekly"]); + let raw_monthly = Self::extract_window(text, &["monthlyUsage", "monthly_usage", "monthly"]); + + // The page reports every window on one scale, either whole percentages + // (`23` = 23%) or fractions of the limit (`0.23` = 23%). A lone `1` is + // ambiguous, so resolve the scale from the whole response before + // converting any window. + let fraction_scale = crate::core::detect_fraction_scale( + std::iter::once(raw_rolling.0) + .chain(raw_weekly.map(|window| window.0)) + .chain(raw_monthly.map(|window| window.0)), + ); + + let rolling = crate::core::to_percent(raw_rolling.0, fraction_scale); + let weekly = + raw_weekly.map(|window| (crate::core::to_percent(window.0, fraction_scale), window.1)); + let monthly = + raw_monthly.map(|window| (crate::core::to_percent(window.0, fraction_scale), window.1)); let primary = RateWindow::with_details( - rolling.0, + rolling, Some(300), - Some(now + chrono::Duration::seconds(rolling.1)), + Some(now + chrono::Duration::seconds(raw_rolling.1)), None, ); let mut snap = UsageSnapshot::new(primary).with_login_method("OpenCode Go"); @@ -173,7 +189,8 @@ impl OpenCodeGoProvider { Ok(snap) } - /// Extract `(percent, resetInSec)` for a usage block by name. + /// Extract raw `(usage, resetInSec)` for a usage block by name, before any + /// percent-scale normalization. fn extract_window(text: &str, names: &[&str]) -> Option<(f64, i64)> { for name in names { let percent_pattern = format!( @@ -190,8 +207,7 @@ 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 }; - return Some((p.clamp(0.0, 100.0), reset.max(0))); + return Some((p, reset.max(0))); } } None @@ -430,21 +446,54 @@ mod tests { } #[test] - fn parses_usage_blocks() { + fn parses_usage_blocks_as_whole_percentages() { let text = r#" rollingUsage: { usagePercent: 42.5, resetInSec: 3600 } - weeklyUsage: { usagePercent: 0.13, resetInSec: 86400 } + weeklyUsage: { usagePercent: 13, resetInSec: 86400 } monthlyUsage: { usagePercent: 7, resetInSec: 2592000 } "#; 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% assert!((secondary.used_percent - 13.0).abs() < 0.001); let tertiary = snap.tertiary.expect("monthly"); assert!((tertiary.used_percent - 7.0).abs() < 0.001); } + #[test] + fn parses_usage_blocks_as_fractions() { + let text = r#" + rollingUsage: { usagePercent: 0.425, resetInSec: 3600 } + weeklyUsage: { usagePercent: 0.13, resetInSec: 86400 } + monthlyUsage: { usagePercent: 0.07, resetInSec: 2592000 } + "#; + let snap = OpenCodeGoProvider::parse_usage_text(text).unwrap(); + assert!((snap.primary.used_percent - 42.5).abs() < 0.001); + let secondary = snap.secondary.expect("weekly"); + assert!((secondary.used_percent - 13.0).abs() < 0.001); + let tertiary = snap.tertiary.expect("monthly"); + assert!((tertiary.used_percent - 7.0).abs() < 0.001); + } + + #[test] + fn one_percent_window_is_not_full() { + // The reported regression: a lightly used account sent `1` for its + // rolling window (1% on the whole-percent scale) with the other + // windows at `0`. The old per-window rule read that lone `1` as a + // fraction and rendered the rolling window as 100% used. + let text = r#" + rollingUsage: { usagePercent: 1, resetInSec: 3600 } + weeklyUsage: { usagePercent: 0, resetInSec: 86400 } + monthlyUsage: { usagePercent: 0, resetInSec: 2592000 } + "#; + let snap = OpenCodeGoProvider::parse_usage_text(text).unwrap(); + assert!((snap.primary.used_percent - 1.0).abs() < 0.001); + let secondary = snap.secondary.expect("weekly"); + assert!((secondary.used_percent - 0.0).abs() < 0.001); + let tertiary = snap.tertiary.expect("monthly"); + assert!((tertiary.used_percent - 0.0).abs() < 0.001); + } + #[test] fn parses_renewal_window() { let text = r#" diff --git a/rust/src/providers/qoder/mod.rs b/rust/src/providers/qoder/mod.rs index 8fcea6e4..cf061299 100644 --- a/rust/src/providers/qoder/mod.rs +++ b/rust/src/providers/qoder/mod.rs @@ -290,32 +290,66 @@ fn string_from_value_keys(value: &Value, keys: &[&str]) -> Option { } fn collect_credit_windows(value: &Value) -> Vec { + let mut raw_percents = Vec::new(); + collect_reported_percents(value, &mut raw_percents); + let fraction_scale = crate::core::detect_fraction_scale(raw_percents); let mut out = Vec::new(); - collect_credit_windows_inner(value, &mut out); + collect_credit_windows_inner(value, fraction_scale, &mut out); out.sort_by(|a, b| b.used_percent.total_cmp(&a.used_percent)); out } -fn collect_credit_windows_inner(value: &Value, out: &mut Vec) { +fn collect_reported_percents(value: &Value, out: &mut Vec) { match value { Value::Object(map) => { - if let Some(window) = rate_window_from_object(map) { + if let Some(percent) = number_from_keys( + map, + &[ + "usedPercent", + "used_percent", + "usagePercent", + "usage_percent", + "percent", + ], + ) { + out.push(percent); + } + for value in map.values() { + collect_reported_percents(value, out); + } + } + Value::Array(items) => { + for value in items { + collect_reported_percents(value, out); + } + } + _ => {} + } +} + +fn collect_credit_windows_inner(value: &Value, fraction_scale: bool, out: &mut Vec) { + match value { + Value::Object(map) => { + if let Some(window) = rate_window_from_object(map, fraction_scale) { out.push(window); } for value in map.values() { - collect_credit_windows_inner(value, out); + collect_credit_windows_inner(value, fraction_scale, out); } } Value::Array(items) => { for value in items { - collect_credit_windows_inner(value, out); + collect_credit_windows_inner(value, fraction_scale, out); } } _ => {} } } -fn rate_window_from_object(map: &serde_json::Map) -> Option { +fn rate_window_from_object( + map: &serde_json::Map, + fraction_scale: bool, +) -> Option { let used = number_from_keys( map, &[ @@ -349,6 +383,7 @@ fn rate_window_from_object(map: &serde_json::Map) -> Option 0.0 => Some(used / total * 100.0), _ => None, @@ -363,12 +398,7 @@ fn rate_window_from_object(map: &serde_json::Map) -> Option Some(format!("{used:.0} credits used")), _ => None, }; - Some(RateWindow::with_details( - normalized_percent(percent), - None, - reset, - description, - )) + Some(RateWindow::with_details(percent, None, reset, description)) } fn number_from_keys(map: &serde_json::Map, keys: &[&str]) -> Option { @@ -387,10 +417,6 @@ fn string_from_keys(map: &serde_json::Map, keys: &[&str]) -> Opti }) } -fn normalized_percent(value: f64) -> f64 { - if value <= 1.0 { value * 100.0 } else { value } -} - fn parse_datetime(raw: String) -> Option> { if let Ok(number) = raw.parse::() { let seconds = if number > 10_000_000_000.0 { @@ -492,6 +518,20 @@ mod tests { assert!(snapshot.primary.resets_at.is_some()); } + #[test] + fn one_percent_credit_window_is_not_full() { + let payload = serde_json::json!({ + "data": { + "credits": [ + { "usedPercent": 1, "totalCredits": 1000 }, + { "usedPercent": 0, "totalCredits": 1000 } + ] + } + }); + let snapshot = snapshot_from_payload(&payload, "Qoder").unwrap(); + assert!((snapshot.primary.used_percent - 1.0).abs() < 0.001); + } + #[test] fn qoder_zero_total_quota_defaults_to_exhausted() { let payload = serde_json::json!({ diff --git a/rust/src/providers/sakana/mod.rs b/rust/src/providers/sakana/mod.rs index b6b66fb1..5f27640f 100644 --- a/rust/src/providers/sakana/mod.rs +++ b/rust/src/providers/sakana/mod.rs @@ -107,56 +107,98 @@ fn looks_signed_out(text: &str) -> bool { } fn snapshot_from_html(text: &str) -> Result { - let primary = extract_window(text, &["5-hour", "5 hour", "five-hour", "session"]) + let raw_primary = extract_raw_window(text, &["5-hour", "5 hour", "five-hour", "session"]) .ok_or_else(|| ProviderError::Parse("Missing Sakana 5-hour quota".into()))?; + let raw_weekly = extract_raw_window(text, &["weekly", "week"]); + + // Only values read from a JSON percent key are ambiguous between whole + // percentages and fractions of a limit. Literal "%" text is always a whole + // percentage. Resolve the scale from the ambiguous values in this response. + let fraction_scale = crate::core::detect_fraction_scale( + std::iter::once(&raw_primary) + .chain(raw_weekly.iter()) + .filter_map(|window| window.from_json.then_some(window.percent)), + ); + + let primary = raw_primary.into_window(fraction_scale); let mut snapshot = UsageSnapshot::new(primary).with_login_method("Sakana Console"); - if let Some(weekly) = extract_window(text, &["weekly", "week"]) { - snapshot = snapshot.with_secondary(weekly); + if let Some(weekly) = raw_weekly { + snapshot = snapshot.with_secondary(weekly.into_window(fraction_scale)); } Ok(snapshot) } -fn extract_window(text: &str, labels: &[&str]) -> Option { +struct RawWindow { + percent: f64, + from_json: bool, + reset: Option>, + reset_description: Option, + minutes: Option, +} + +impl RawWindow { + fn into_window(self, fraction_scale: bool) -> RateWindow { + let percent = if self.from_json { + crate::core::to_percent(self.percent, fraction_scale) + } else { + self.percent + }; + let mut window = RateWindow::with_details(percent, self.minutes, self.reset, None); + if let Some(reset_text) = self.reset_description { + window.reset_description = Some(reset_text); + } + window + } +} + +fn extract_raw_window(text: &str, labels: &[&str]) -> Option { let lower = text.to_ascii_lowercase(); let anchor = labels .iter() .find_map(|label| lower.find(label).map(|idx| (idx, *label)))?; let end = (anchor.0 + 1400).min(text.len()); let segment = &text[anchor.0..end]; - let percent = extract_percent(segment)?; + let (percent, from_json) = extract_percent(segment)?; let reset = extract_reset(segment); - let mut window = RateWindow::with_details( + let reset_description = extract_reset_text(segment); + let minutes = if anchor.1.contains("week") { + Some(7 * 24 * 60) + } else { + Some(5 * 60) + }; + Some(RawWindow { percent, - if anchor.1.contains("week") { - Some(7 * 24 * 60) - } else { - Some(5 * 60) - }, + from_json, reset, - None, - ); - if let Some(reset_text) = extract_reset_text(segment) { - window.reset_description = Some(reset_text); - } - Some(window) + reset_description, + minutes, + }) } -fn extract_percent(segment: &str) -> Option { - let patterns = [ +fn extract_percent(segment: &str) -> Option<(f64, bool)> { + // Values with a literal "%" are whole percentages by construction. + let literal_patterns = [ r#"(?i)([0-9]+(?:\.[0-9]+)?)\s*%\s*(?:used|usage)?"#, r#"(?i)(?:used|usage)[^0-9]{0,40}([0-9]+(?:\.[0-9]+)?)\s*%"#, - r#"(?i)"(?:usedPercent|used_percent|percent)"\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, ]; - patterns.iter().find_map(|pattern| { - Regex::new(pattern) - .ok()? - .captures(segment)? - .get(1)? - .as_str() - .parse::() - .ok() - .map(|value| if value <= 1.0 { value * 100.0 } else { value }) - }) + for pattern in literal_patterns { + if let Some(value) = capture_number(pattern, segment) { + return Some((value, false)); + } + } + // A JSON percent key can be either scale. + let json_pattern = r#"(?i)"(?:usedPercent|used_percent|percent)"\s*:\s*([0-9]+(?:\.[0-9]+)?)"#; + capture_number(json_pattern, segment).map(|value| (value, true)) +} + +fn capture_number(pattern: &str, segment: &str) -> Option { + Regex::new(pattern) + .ok()? + .captures(segment)? + .get(1)? + .as_str() + .parse::() + .ok() } fn extract_reset(segment: &str) -> Option> { @@ -239,6 +281,20 @@ mod tests { ); } + #[test] + fn one_percent_usage_is_not_full() { + // A lightly used account's billing page shows "1%" literally. The old + // rule scaled any value at or below 1 by 100 per window, so a real 1% + // rendered as 100% used. + let html = r#" +
5-hour quota 1% resets July 3, 2026 at 4:30 PM
+
Weekly usage 0% used resets July 8, 2026 at 12:00 AM
+ "#; + let snapshot = snapshot_from_html(html).unwrap(); + assert_eq!(snapshot.primary.used_percent, 1.0); + assert_eq!(snapshot.secondary.unwrap().used_percent, 0.0); + } + #[test] fn normalizes_sakana_cookie_header() { assert_eq!(