Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions rust/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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::*;
Expand Down
65 changes: 65 additions & 0 deletions rust/src/core/percent_scale.rs
Original file line number Diff line number Diff line change
@@ -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<Item = f64>) -> 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);
}
}
54 changes: 47 additions & 7 deletions rust/src/providers/chutes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,39 +108,68 @@ fn snapshot_from_usage(value: &Value) -> UsageSnapshot {
}

fn quota_windows(value: &Value) -> Vec<RateWindow> {
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<RateWindow>) {
fn collect_reported_percents(value: &Value, out: &mut Vec<f64>) {
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<RateWindow>) {
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<String, Value>) -> Option<f64> {
fn percent_from_object(map: &serde_json::Map<String, Value>, fraction_scale: bool) -> Option<f64> {
for key in [
"usage_percent",
"usagePercent",
"percent_used",
"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"]
Expand All @@ -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);
}
}
83 changes: 67 additions & 16 deletions rust/src/providers/opencode/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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));
}
Comment on lines +269 to 276
}

Expand All @@ -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<f64> {
fn window_percent(obj: &Value) -> Option<(f64, bool)> {
let percent_keys = [
"usagePercent",
"usedPercent",
Expand All @@ -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<f64> {
Expand Down Expand Up @@ -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::<Utc>::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::<Utc>::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 });
Expand Down
40 changes: 29 additions & 11 deletions rust/src/providers/opencode/scraper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -469,7 +478,11 @@ impl OpenCodeUsageFetcher {
}

/// Parse a window object into (percent, reset_in_sec)
fn parse_window(json: &serde_json::Value, _now: DateTime<Utc>) -> Option<(f64, i64)> {
fn parse_window(
json: &serde_json::Value,
fraction_scale: bool,
_now: DateTime<Utc>,
) -> Option<(f64, i64)> {
let percent = PERCENT_KEYS
.iter()
.find_map(|k| json.get(k).and_then(|v| v.as_f64()));
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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);
}
}
Loading
Loading