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
56 changes: 51 additions & 5 deletions rust/src/providers/opencode/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,10 @@ impl OpenCodeProvider {
}

fn window_percent(obj: &Value) -> Option<f64> {
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",
Expand All @@ -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<f64> {
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,
}
Expand Down Expand Up @@ -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::<Utc>::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::<Utc>::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();
Expand Down
42 changes: 27 additions & 15 deletions rust/src/providers/opencode/scraper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -470,25 +470,37 @@ impl OpenCodeUsageFetcher {

/// Parse a window object into (percent, reset_in_sec)
fn parse_window(json: &serde_json::Value, _now: DateTime<Utc>) -> 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<DateTime<Utc>> {
Expand Down
40 changes: 38 additions & 2 deletions rust/src/providers/opencodego/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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#"
Expand All @@ -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);
Expand Down
Loading