From c6efa0760e743b4fbc6eb63f8c7f895c3066cf5f Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:34:40 +0700 Subject: [PATCH 1/8] Harden provider numeric boundaries --- rust/src/providers/amp/mod.rs | 18 ++++ rust/src/providers/amp/subscription.rs | 52 ++++++++-- rust/src/providers/kilo/mod.rs | 66 +++++++++--- rust/src/providers/longcat/mod.rs | 138 ++++++++++++++++++++----- 4 files changed, 231 insertions(+), 43 deletions(-) diff --git a/rust/src/providers/amp/mod.rs b/rust/src/providers/amp/mod.rs index d248d22d38..591112e31c 100755 --- a/rust/src/providers/amp/mod.rs +++ b/rust/src/providers/amp/mod.rs @@ -501,4 +501,22 @@ period 2026-09-13 to 2026-10-13, resets upon renewal in 27 days"; assert!(snapshot.secondary.is_none()); assert_eq!(snapshot.primary.used_percent, 85.0); } + + #[test] + fn tier_keeps_explicit_period_when_renewal_count_overflows() { + let now = Utc.with_ymd_and_hms(2026, 9, 16, 12, 0, 0).unwrap(); + let text = "Amp Example Tier: agent usage $10 of $20 remaining - \ +period 2026-09-13 to 2026-10-13, resets upon renewal in 999999999999999999999999999999 days"; + let sub = parse_amp_subscription_usage(text, now).expect("tier"); + assert_eq!( + sub.resets_at(), + Some(Utc.with_ymd_and_hms(2026, 10, 13, 0, 0, 0).unwrap()) + ); + assert_eq!( + sub.reset_description, + "renews in 999999999999999999999999999999 days" + ); + let snapshot = usage_snapshot_from_amp_display_text(text, now).expect("snapshot"); + assert_eq!(snapshot.primary.used_percent, 50.0); + } } diff --git a/rust/src/providers/amp/subscription.rs b/rust/src/providers/amp/subscription.rs index 449b98199a..d471cea1f8 100644 --- a/rust/src/providers/amp/subscription.rs +++ b/rust/src/providers/amp/subscription.rs @@ -197,10 +197,21 @@ pub(super) fn parse_amp_subscription_usage( let agent_remaining = parse_amp_number(caps.get(2)?.as_str())?; let agent_limit = parse_amp_number(caps.get(3)?.as_str())?; let details = caps.get(4)?.as_str(); - let renewal_value: i64 = caps.get(5)?.as_str().replace(',', "").parse().ok()?; + let renewal_text = caps.get(5)?.as_str().replace(',', ""); + let renewal_value = renewal_text.parse::().ok(); let renewal_unit = caps.get(6)?.as_str().to_ascii_lowercase(); - let reset_description = amp_renewal_description(renewal_value, &renewal_unit); - let (period_start, resets_at) = parse_amp_tier_period(details).unzip(); + let period = parse_amp_tier_period(details); + let has_period_text = details.to_ascii_lowercase().contains("period "); + let resets_at = period.map(|(_, end)| end).or_else(|| { + (!has_period_text) + .then(|| { + renewal_value + .and_then(|value| subscription_reset_date(value, &renewal_unit, now)) + }) + .flatten() + }); + let reset_description = amp_renewal_description_text(&renewal_text, &renewal_unit); + let period_start = period.map(|(start, _)| start); let orb = orb_re.captures(details).and_then(|orb_caps| { let remaining = parse_amp_number(orb_caps.get(1)?.as_str())?; let limit = parse_amp_number(orb_caps.get(2)?.as_str())?; @@ -242,11 +253,7 @@ pub(super) fn parse_amp_subscription_usage( continue; } let unit = caps.get(5)?.as_str().to_ascii_lowercase(); - let resets_at = if unit.starts_with("month") { - add_calendar_months(now, renewal_value)? - } else { - now + chrono::Duration::days(renewal_value) - }; + let resets_at = subscription_reset_date(renewal_value, &unit, now)?; let reset_description = amp_renewal_description(renewal_value, &unit); return Some(AmpSubscriptionUsage { plan: plan.to_string(), @@ -274,6 +281,35 @@ fn amp_renewal_description(value: i64, unit: &str) -> String { } } +fn amp_renewal_description_text(value: &str, unit: &str) -> String { + let singular = if unit.starts_with("month") { + "month" + } else { + "day" + }; + if value == "1" { + format!("renews in 1 {singular}") + } else { + format!("renews in {value} {singular}s") + } +} + +fn subscription_reset_date( + value: i64, + unit: &str, + now: chrono::DateTime, +) -> Option> { + if value < 0 { + return None; + } + if unit.starts_with("month") { + add_calendar_months(now, value) + } else { + let seconds = value.checked_mul(24 * 60 * 60)?; + now.checked_add_signed(chrono::Duration::seconds(seconds)) + } +} + fn parse_amp_tier_period( text: &str, ) -> Option<(chrono::DateTime, chrono::DateTime)> { diff --git a/rust/src/providers/kilo/mod.rs b/rust/src/providers/kilo/mod.rs index 5aa8bd330a..4b10d32d40 100644 --- a/rust/src/providers/kilo/mod.rs +++ b/rust/src/providers/kilo/mod.rs @@ -111,8 +111,8 @@ impl KiloProvider { has_blocks = !arr.is_empty(); for block in arr { if let Ok(b) = serde_json::from_value::(block.clone()) { - total += b.amount_m_usd.unwrap_or(0.0); - remaining += b.balance_m_usd.unwrap_or(0.0); + add_finite(&mut total, b.amount_m_usd); + add_finite(&mut remaining, b.balance_m_usd); } } } @@ -121,23 +121,27 @@ impl KiloProvider { && let Some(balance_m_usd) = payload.get("totalBalance_mUsd").and_then(|v| v.as_f64()) { - total = balance_m_usd; - remaining = balance_m_usd; + total = finite_or_zero(Some(balance_m_usd)); + remaining = total; } } let total_usd = total / 1_000_000.0; let remaining_usd = remaining / 1_000_000.0; - let used_usd = (total_usd - remaining_usd).max(0.0); - let percent = if total_usd > 0.0 { - ((used_usd / total_usd) * 100.0).clamp(0.0, 100.0) + let primary = if total_usd.is_finite() && remaining_usd.is_finite() { + let used_usd = (total_usd - remaining_usd).max(0.0); + let percent = if total_usd > 0.0 { + ((used_usd / total_usd) * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + let mut window = RateWindow::new(percent); + window.reset_description = Some(format!("${used_usd:.2}/${total_usd:.2}")); + window } else { - 0.0 + RateWindow::informational("Credit usage unavailable") }; - let mut primary = RateWindow::new(percent); - primary.reset_description = Some(format!("${:.2}/${:.2}", used_usd, total_usd)); - let mut snap = UsageSnapshot::new(primary); // --- Kilo Pass (secondary window) --- @@ -145,17 +149,20 @@ impl KiloProvider { let usage = pass .get("currentPeriodUsageUsd") .and_then(|v| v.as_f64()) + .filter(|value| value.is_finite()) .unwrap_or(0.0); let base = pass .get("currentPeriodBaseCreditsUsd") .and_then(|v| v.as_f64()) + .filter(|value| value.is_finite()) .unwrap_or(0.0); let bonus = pass .get("currentPeriodBonusCreditsUsd") .and_then(|v| v.as_f64()) + .filter(|value| value.is_finite()) .unwrap_or(0.0); let pass_total = base + bonus; - if pass_total > 0.0 { + if pass_total.is_finite() && pass_total > 0.0 { let pass_pct = ((usage / pass_total) * 100.0).clamp(0.0, 100.0); let mut secondary = RateWindow::new(pass_pct); secondary.reset_description = Some(format!("${:.2}/${:.2}", usage, pass_total)); @@ -221,6 +228,17 @@ impl KiloProvider { } } +fn finite_or_zero(value: Option) -> f64 { + value.filter(|value| value.is_finite()).unwrap_or(0.0) +} + +fn add_finite(total: &mut f64, value: Option) { + let Some(value) = value.filter(|value| value.is_finite()) else { + return; + }; + *total += value; +} + fn direct_kilo_api_key(api_key: Option<&str>) -> Option { api_key.filter(|key| !key.is_empty()).map(str::to_string) } @@ -373,4 +391,28 @@ mod tests { ); assert!(snap.secondary.is_none()); } + + #[test] + fn makes_overflowed_credit_totals_unavailable() { + let credit_blocks = serde_json::json!({ + "creditBlocks": [ + { "amount_mUsd": 1e308, "balance_mUsd": 1e308 }, + { "amount_mUsd": 1e308, "balance_mUsd": 1e308 } + ] + }); + let snap = KiloProvider::build_snapshot(Some(&credit_blocks), None).unwrap(); + assert!(snap.primary.is_informational); + assert!(snap.primary.used_percent.is_finite()); + } + + #[test] + fn omits_pass_window_when_usage_arithmetic_overflows() { + let pass = serde_json::json!({ + "currentPeriodUsageUsd": 1e308, + "currentPeriodBaseCreditsUsd": 1e308, + "currentPeriodBonusCreditsUsd": 1e308 + }); + let snap = KiloProvider::build_snapshot(None, Some(&pass)).unwrap(); + assert!(snap.secondary.is_none()); + } } diff --git a/rust/src/providers/longcat/mod.rs b/rust/src/providers/longcat/mod.rs index f3ef3f6ce4..1bbd777608 100644 --- a/rust/src/providers/longcat/mod.rs +++ b/rust/src/providers/longcat/mod.rs @@ -125,7 +125,7 @@ impl Provider for LongCatProvider { }; let account = self.get_json(USER_CURRENT, &cookie).await?; // Meituan-style envelope may return HTTP 200 with business 401. - if let Some(code) = envelope_code(&account) + if let Some(code) = envelope_code(&account)? && (code == 401 || code == 403) { return Err(ProviderError::AuthRequired); @@ -181,11 +181,16 @@ fn normalize_cookie_header(raw: &str) -> Option { (!header.is_empty()).then_some(header) } -fn envelope_code(value: &Value) -> Option { +fn envelope_code(value: &Value) -> Result, ProviderError> { value .get("code") - .and_then(|c| c.as_i64()) - .or_else(|| value.get("status").and_then(|c| c.as_i64())) + .or_else(|| value.get("status")) + .map(|raw| { + json_integer(raw).ok_or_else(|| { + ProviderError::Parse("LongCat response code was not a valid integer".into()) + }) + }) + .transpose() } fn envelope_data(value: &Value) -> &Value { @@ -197,10 +202,54 @@ fn json_f64(value: &Value, key: &str) -> Option { } fn json_number(value: &Value) -> Option { - value + let number = value .as_f64() .or_else(|| value.as_i64().map(|number| number as f64)) + .or_else(|| value.as_str()?.trim().parse().ok())?; + number.is_finite().then_some(number) +} + +fn json_integer(value: &Value) -> Option { + value + .as_i64() + .or_else(|| value.as_f64().and_then(truncate_to_i64)) .or_else(|| value.as_str()?.trim().parse().ok()) + .or_else(|| { + value + .as_str()? + .trim() + .parse::() + .ok() + .and_then(truncate_to_i64) + }) +} + +fn truncate_to_i64(value: f64) -> Option { + if !value.is_finite() { + return None; + } + let truncated = value.trunc(); + // `i64::MAX as f64` rounds up to 2^63, so keep that boundary exclusive. + if truncated < i64::MIN as f64 || truncated >= i64::MAX as f64 { + return None; + } + #[expect( + clippy::cast_possible_truncation, + reason = "the finite value was range-checked before conversion" + )] + Some(truncated as i64) +} + +fn whole_number(value: f64) -> Option { + if !value.is_finite() { + return None; + } + let normalized = if value.trunc() == 0.0 { + 0.0 + } else { + value.trunc() + }; + Some(format!("{normalized:.0}")) } fn json_str(value: &Value, key: &str) -> Option { @@ -259,19 +308,16 @@ fn build_snapshot( )); }; - let primary = if total > 0.0 { + let primary = if total.is_finite() && total > 0.0 && used.is_finite() { let mut w = RateWindow::new(((used / total) * 100.0).clamp(0.0, 100.0)); - // Display-only rendering of token counts; values beyond i64 are - // unrealistic quota sizes and would only affect this label. - #[allow( - clippy::cast_possible_truncation, - reason = "display-only quota label; token counts beyond i64 are unrealistic" - )] - let desc = format!("{}/{}", used as i64, total as i64); - w.reset_description = Some(desc); + if let (Some(used_text), Some(total_text)) = (whole_number(used), whole_number(total)) { + w.reset_description = Some(format!("{used_text}/{total_text}")); + } w - } else { + } else if total.is_finite() && total <= 0.0 { RateWindow::informational("No token quota") + } else { + RateWindow::informational("Token quota unavailable") }; let account_name = json_str(account_data, "name") @@ -286,19 +332,20 @@ fn build_snapshot( if let Some(fuel_raw) = fuel_raw { let fuel_data = envelope_data(fuel_raw); if let Some((total_fuel, remaining_fuel, expiry)) = parse_fuel(fuel_data) + && total_fuel.is_finite() + && remaining_fuel.is_finite() && total_fuel > 0.0 { let used_fuel = (total_fuel - remaining_fuel).max(0.0); let mut secondary = RateWindow::new(((used_fuel / total_fuel) * 100.0).clamp(0.0, 100.0)); secondary.resets_at = expiry; - // Same display-only label for fuel-pack counts. - #[allow( - clippy::cast_possible_truncation, - reason = "display-only fuel label; fuel counts beyond i64 are unrealistic" - )] - let fuel_desc = format!("Fuel pack: {}/{}", remaining_fuel as i64, total_fuel as i64); - secondary.reset_description = Some(fuel_desc); + if let (Some(remaining_text), Some(total_text)) = + (whole_number(remaining_fuel), whole_number(total_fuel)) + { + secondary.reset_description = + Some(format!("Fuel pack: {remaining_text}/{total_text}")); + } snap = snap.with_secondary(secondary); } } @@ -374,7 +421,7 @@ fn parse_fuel_timestamp(value: &Value) -> Option> { } else { number * 1000.0 }; - if millis <= 1_000_000_000_000.0 || millis > i64::MAX as f64 { + if millis <= 1_000_000_000_000.0 || millis >= i64::MAX as f64 { return None; } #[expect( @@ -494,4 +541,49 @@ mod tests { Some("a=1; b=2") ); } + + #[test] + fn rejects_unrepresentable_response_codes() { + let error = envelope_code(&json!({ "code": "Infinity" })).unwrap_err(); + assert!(matches!(error, ProviderError::Parse(_))); + assert_eq!(envelope_code(&json!({ "code": 200.9 })).unwrap(), Some(200)); + } + + #[test] + fn formats_large_counts_without_i64_saturation() { + let account = json!({ "code": 0, "data": { "name": "cat" } }); + let usage = json!({ + "code": 0, + "data": { + "usage": { + "totalToken": 2e20, + "availableToken": 1e20 + } + } + }); + let snapshot = build_snapshot(&account, None, Some(&usage), None).unwrap(); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("100000000000000000000/200000000000000000000") + ); + assert!(snapshot.primary.used_percent.is_finite()); + } + + #[test] + fn omits_fuel_window_when_counts_overflow() { + let account = json!({ "code": 0 }); + let usage = json!({ + "data": { "usage": { "totalToken": 100, "availableToken": 50 } } + }); + let fuel = json!({ + "totalQuota": 1e308, + "list": [ + { "availableToken": 1e308 }, + { "availableToken": 1e308 } + ] + }); + let snapshot = build_snapshot(&account, None, Some(&usage), Some(&fuel)).unwrap(); + assert!(snapshot.secondary.is_none()); + assert!(snapshot.primary.used_percent.is_finite()); + } } From 703e0d54db3293d4102e8730e4a2e6f2b53f56d1 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:52:18 +0700 Subject: [PATCH 2/8] Harden oversized provider usage values --- rust/src/providers/chutes/mod.rs | 219 ++++++++++++++++-- rust/src/providers/kimi/mod.rs | 37 ++- .../src/providers/minimax/coding_plan_html.rs | 38 ++- rust/src/providers/perplexity/mod.rs | 60 +++-- 4 files changed, 309 insertions(+), 45 deletions(-) diff --git a/rust/src/providers/chutes/mod.rs b/rust/src/providers/chutes/mod.rs index 5c69951749..c6c75c4727 100644 --- a/rust/src/providers/chutes/mod.rs +++ b/rust/src/providers/chutes/mod.rs @@ -141,9 +141,15 @@ fn collect_windows(value: &Value, out: &mut Vec) { fn window_from_object(map: &serde_json::Map) -> Option { let percent = percent_from_object(map)?; let detail = quota_count_description(map); + let window_minutes = window_minutes_from_object(map); // Only parse dedicated reset timestamp keys — never raw quota counts. let resets_at = first_reset_timestamp(map); - Some(RateWindow::with_details(percent, None, resets_at, detail)) + Some(RateWindow::with_details( + percent, + window_minutes, + resets_at, + detail, + )) } fn percent_from_object(map: &serde_json::Map) -> Option { @@ -162,7 +168,9 @@ fn percent_from_object(map: &serde_json::Map) -> Option { // 0..=1 as fractions turned a real 1% into a false 100% exhausted // state (#408; same class as #247 / upstream #3216, fixed for // opencodego in #407). - return Some(v.clamp(0.0, 100.0)); + if v.is_finite() { + return Some(v.clamp(0.0, 100.0)); + } } let used = first_f64(map, &["used", "usage", "current_usage", "currentUsage"]); let limit = first_f64( @@ -171,13 +179,21 @@ fn percent_from_object(map: &serde_json::Map) -> Option { ); let remaining = first_f64(map, &["remaining", "remaining_quota", "remainingQuota"]); match (used, limit, remaining) { - (Some(used), Some(limit), _) if limit > 0.0 => Some((used / limit) * 100.0), + (Some(used), Some(limit), _) if limit > 0.0 => { + let percent = (used / limit) * 100.0; + percent.is_finite().then_some(percent.clamp(0.0, 100.0)) + } (None, Some(limit), Some(remaining)) if limit > 0.0 => { - Some(((limit - remaining).max(0.0) / limit) * 100.0) + let percent = ((limit - remaining).max(0.0) / limit) * 100.0; + percent.is_finite().then_some(percent.clamp(0.0, 100.0)) } (Some(used), None, Some(remaining)) => { let limit = used + remaining; - (limit > 0.0).then_some((used / limit) * 100.0) + if limit <= 0.0 { + return None; + } + let percent = (used / limit) * 100.0; + percent.is_finite().then_some(percent.clamp(0.0, 100.0)) } _ => None, } @@ -196,7 +212,7 @@ fn quota_count_description(map: &serde_json::Map) -> Option 0.0)?; + let limit = limit.filter(|l| l.is_finite() && *l > 0.0)?; let used = match used { Some(u) => u, None => remaining.map(|r| (limit - r).max(0.0))?, @@ -210,6 +226,123 @@ fn quota_count_description(map: &serde_json::Map) -> Option) -> Option { + for (keys, multiplier) in [ + ( + [ + "window_minutes", + "windowMinutes", + "period_minutes", + "periodMinutes", + "duration_minutes", + "durationMinutes", + ] + .as_slice(), + 1.0, + ), + ( + [ + "window_hours", + "windowHours", + "period_hours", + "periodHours", + "duration_hours", + "durationHours", + ] + .as_slice(), + 60.0, + ), + ( + [ + "window_days", + "windowDays", + "period_days", + "periodDays", + "duration_days", + "durationDays", + ] + .as_slice(), + 24.0 * 60.0, + ), + ( + [ + "window_seconds", + "windowSeconds", + "period_seconds", + "periodSeconds", + "duration_seconds", + "durationSeconds", + ] + .as_slice(), + 1.0 / 60.0, + ), + ] { + if let Some(minutes) = keys.iter().find_map(|key| { + map.get(*key) + .and_then(numeric_value) + .and_then(|value| rounded_window_minutes(value * multiplier)) + }) { + return Some(minutes); + } + } + + ["window", "period", "interval", "duration"] + .iter() + .find_map(|key| map.get(*key).and_then(Value::as_str)) + .and_then(parse_window_duration_text) +} + +fn numeric_value(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_f64().filter(|value| value.is_finite()), + Value::String(text) => text + .trim() + .parse::() + .ok() + .filter(|value| value.is_finite()), + _ => None, + } +} + +fn rounded_window_minutes(value: f64) -> Option { + if !value.is_finite() || value <= 0.0 { + return None; + } + let rounded = value.round(); + if rounded <= 0.0 || rounded > u32::MAX as f64 { + return None; + } + #[expect( + clippy::cast_possible_truncation, + reason = "rounded value is bounded by u32::MAX" + )] + Some(rounded as u32) +} + +fn parse_window_duration_text(raw: &str) -> Option { + let compact: String = raw + .chars() + .filter(|character| !character.is_whitespace()) + .collect(); + let split_at = compact.find(|character: char| { + !character.is_ascii_digit() && !matches!(character, '.' | '+' | '-' | 'e' | 'E') + })?; + let (number, suffix) = compact.split_at(split_at); + let value = number.parse::().ok()?; + let multiplier = if suffix.starts_with("min") || suffix == "m" { + 1.0 + } else if suffix.starts_with("hour") || suffix.starts_with("hr") || suffix == "h" { + 60.0 + } else if suffix.starts_with("day") || suffix == "d" { + 24.0 * 60.0 + } else if suffix.starts_with("month") || suffix == "mo" { + 30.0 * 24.0 * 60.0 + } else { + return None; + }; + rounded_window_minutes(value * multiplier) +} + fn first_reset_timestamp(map: &serde_json::Map) -> Option> { for key in [ "resets_at", @@ -274,8 +407,11 @@ fn epoch_to_datetime(value: f64) -> Option> { } fn first_f64(map: &serde_json::Map, keys: &[&str]) -> Option { - keys.iter() - .find_map(|k| map.get(*k).and_then(Value::as_f64)) + keys.iter().find_map(|k| { + map.get(*k) + .and_then(Value::as_f64) + .filter(|v| v.is_finite()) + }) } fn first_str<'a>(map: &'a serde_json::Map, keys: &[&str]) -> Option<&'a str> { @@ -286,14 +422,18 @@ fn first_str<'a>(map: &'a serde_json::Map, keys: &[&str]) -> Opti } fn format_quota_amount(value: f64) -> String { - if (value - value.round()).abs() < 0.0001 { + if !value.is_finite() { + return "unknown".to_string(); + } + let rounded = value.round(); + if (value - rounded).abs() < 0.0001 && rounded >= i64::MIN as f64 && rounded < i64::MAX as f64 { // Guarded above: value is within 0.0001 of a whole number, so the - // fractional part is zero. - #[expect( + // fractional part is zero and the rounded value fits in i64. + #[allow( clippy::cast_possible_truncation, - reason = "whole-number guard above; fractional part is zero" + reason = "finite rounded value is bounded to the i64 range above" )] - let whole = value.round() as i64; + let whole = rounded as i64; format!("{}", whole) } else { let mut text = format!("{value:.2}"); @@ -393,4 +533,57 @@ mod tests { })); assert_eq!(snapshot.primary.used_percent, 100.0); } + + #[test] + fn large_quota_amounts_keep_their_description() { + let snapshot = snapshot_from_usage(&serde_json::json!({ + "rolling_window": {"used": 1e20, "limit": 2e20, "unit": "credits"} + })); + assert_eq!(snapshot.primary.used_percent, 50.0); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("100000000000000000000/200000000000000000000 credits") + ); + } + + #[test] + fn duration_fields_populate_window_minutes() { + let snapshot = snapshot_from_usage(&serde_json::json!({ + "quotas": [ + {"used": 25, "limit": 100, "duration": "4 hours"}, + {"used": 1, "limit": 2, "window_seconds": 1800} + ] + })); + assert_eq!(snapshot.primary.window_minutes, Some(240)); + assert_eq!( + snapshot.secondary.as_ref().unwrap().window_minutes, + Some(30) + ); + } + + #[test] + fn unrepresentable_duration_keeps_usage_with_unknown_window() { + let snapshot = snapshot_from_usage(&serde_json::json!({ + "rolling_window": { + "used": 25, + "limit": 100, + "window_hours": "1e308" + } + })); + assert_eq!(snapshot.primary.used_percent, 25.0); + assert_eq!(snapshot.primary.window_minutes, None); + } + + #[test] + fn non_finite_amount_formatting_is_safe() { + assert_eq!(format_quota_amount(f64::INFINITY), "unknown"); + assert_eq!(format_quota_amount(f64::NAN), "unknown"); + } + + #[test] + fn oversized_integral_amount_is_not_saturated_to_i64_max() { + let value = 2_f64.powi(63); + + assert_eq!(format_quota_amount(value), "9223372036854775808"); + } } diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index abc47c76e7..d96d3a36fc 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -411,8 +411,8 @@ fn kimi_window_minutes(window: &KimiWindow) -> Option { match unit.as_str() { "second" | "seconds" => Some((window.duration / 60).max(1)), "minute" | "minutes" => Some(window.duration), - "hour" | "hours" => Some(window.duration.saturating_mul(60)), - "day" | "days" => Some(window.duration.saturating_mul(24 * 60)), + "hour" | "hours" => window.duration.checked_mul(60), + "day" | "days" => window.duration.checked_mul(24 * 60), _ => None, } } @@ -583,14 +583,22 @@ fn ascii_header_value(raw: &str) -> String { } fn format_usage_amount(value: f64) -> String { - if (value.fract()).abs() < f64::EPSILON { - // Value verified integral to f64 precision; the i64 cast loses nothing. + if value.is_finite() + && value.fract() == 0.0 + && value >= i64::MIN as f64 + && value < i64::MAX as f64 + { + // The strict upper bound excludes 2^63, which is representable as f64 + // but has no exact i64 representation. #[allow( clippy::cast_possible_truncation, - reason = "guarded by the fract() == 0 check above" + reason = "finite integral value is bounded to the i64 range above" )] let integral = value as i64; format!("{integral}") + } else if value.is_finite() && value.fract() == 0.0 { + // Preserve a large integral value without saturating it to i64::MAX. + format!("{value:.0}") } else { format!("{value:.2}") } @@ -849,4 +857,23 @@ mod tests { assert_eq!(cleaned_owned("'token'").as_deref(), Some("token")); assert!(cleaned_owned(" ").is_none()); } + + #[test] + fn oversized_integral_usage_amount_is_not_saturated_to_i64_max() { + let value = 2_f64.powi(63); + let formatted = format_usage_amount(value); + + assert!(formatted.starts_with("9223372036854775808")); + assert_ne!(formatted, i64::MAX.to_string()); + } + + #[test] + fn overflowing_window_units_are_omitted() { + let window = KimiWindow { + duration: u32::MAX, + time_unit: "hours".to_string(), + }; + + assert_eq!(kimi_window_minutes(&window), None); + } } diff --git a/rust/src/providers/minimax/coding_plan_html.rs b/rust/src/providers/minimax/coding_plan_html.rs index 35bc47f205..0c4ee6c7cc 100644 --- a/rust/src/providers/minimax/coding_plan_html.rs +++ b/rust/src/providers/minimax/coding_plan_html.rs @@ -159,7 +159,7 @@ fn parse_available_usage(text: &str) -> Option<(i64, u32)> { return None; } let duration: f64 = duration_raw.parse().ok()?; - let window_minutes = minutes_from_duration(duration, unit_raw); + let window_minutes = minutes_from_duration(duration, unit_raw)?; if window_minutes == 0 { return None; } @@ -167,14 +167,23 @@ fn parse_available_usage(text: &str) -> Option<(i64, u32)> { } /// Convert a duration + unit to minutes (upstream `minutes(from:unit:)`). -fn minutes_from_duration(value: f64, unit: &str) -> u32 { - // Window lengths come from the provider's own dashboard text and are - // minutes-scale; u32 overflow would need a >8000-year window. - #[allow( - clippy::cast_possible_truncation, - reason = "dashboard window durations are minutes-scale; u32 is far beyond any real window" - )] - let to_minutes = |scaled: f64| -> u32 { scaled.round() as u32 }; +fn minutes_from_duration(value: f64, unit: &str) -> Option { + let to_minutes = |scaled: f64| -> Option { + if !scaled.is_finite() { + return None; + } + let rounded = scaled.round(); + if !(0.0..=u32::MAX as f64).contains(&rounded) { + return None; + } + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "finite rounded value is bounded to the non-negative u32 range above" + )] + let rounded = rounded as u32; + Some(rounded) + }; let lower = unit.to_lowercase(); if lower.starts_with('d') { return to_minutes(value * 24.0 * 60.0); @@ -186,9 +195,9 @@ fn minutes_from_duration(value: f64, unit: &str) -> u32 { return to_minutes(value); } if lower.starts_with('s') { - return to_minutes(value / 60.0).max(1); + return to_minutes(value / 60.0).map(|minutes| minutes.max(1)); } - 0 + None } /// Parse "37% used" or "used 37%" (upstream `parseUsedPercent`). @@ -608,4 +617,11 @@ mod tests { assert!((usage.primary.used_percent - 25.0).abs() < 0.01); assert_eq!(usage.login_method.as_deref(), Some("Text Generation Pro")); } + + #[test] + fn oversized_html_duration_is_omitted() { + assert_eq!(minutes_from_duration(f64::MAX, "hours"), None); + assert_eq!(minutes_from_duration(f64::INFINITY, "days"), None); + assert_eq!(minutes_from_duration(5.0, "hours"), Some(300)); + } } diff --git a/rust/src/providers/perplexity/mod.rs b/rust/src/providers/perplexity/mod.rs index 06fbb1c9da..25057b50f6 100644 --- a/rust/src/providers/perplexity/mod.rs +++ b/rust/src/providers/perplexity/mod.rs @@ -72,6 +72,9 @@ impl PerplexityProvider { } fn ts_to_datetime(ts: f64) -> Option> { + if !ts.is_finite() { + return None; + } // Grant expiry epochs are whole-second unix timestamps, far below i64::MAX. #[expect( clippy::cast_possible_truncation, @@ -117,7 +120,7 @@ impl PerplexityProvider { let purchased_used = remaining_usage.min(purchased_total); let pct = |used: f64, total: f64| -> f64 { - if total <= 0.0 { + if !used.is_finite() || !total.is_finite() || total <= 0.0 { 0.0 } else { ((used / total) * 100.0).clamp(0.0, 100.0) @@ -128,33 +131,26 @@ impl PerplexityProvider { let mut primary = RateWindow::new(pct(recurring_used, recurring_total)); primary.resets_at = renewal; - primary.reset_description = Some(format!( - "${:.2}/${:.2}", - recurring_used / 100.0, - recurring_total / 100.0 - )); + primary.reset_description = Self::credit_description(recurring_used, recurring_total); let mut snapshot = UsageSnapshot::new(primary); if bonus_total > 0.0 { let mut secondary = RateWindow::new(pct(bonus_used, bonus_total)); secondary.resets_at = bonus_expiry; - let mut bonus_description = - format!("${:.2}/${:.2}", bonus_used / 100.0, bonus_total / 100.0); - if let Some(expiry) = bonus_expiry { - bonus_description.push_str(&format!(" · exp. {}", expiry.format("%Y-%m-%d"))); + let mut bonus_description = Self::credit_description(bonus_used, bonus_total); + if let Some(expiry) = bonus_expiry + && let Some(description) = bonus_description.as_mut() + { + description.push_str(&format!(" · exp. {}", expiry.format("%Y-%m-%d"))); } - secondary.reset_description = Some(bonus_description); + secondary.reset_description = bonus_description; snapshot = snapshot.with_secondary(secondary); } if purchased_total > 0.0 { let mut tertiary = RateWindow::new(pct(purchased_used, purchased_total)); - tertiary.reset_description = Some(format!( - "${:.2}/${:.2}", - purchased_used / 100.0, - purchased_total / 100.0 - )); + tertiary.reset_description = Self::credit_description(purchased_used, purchased_total); snapshot = snapshot.with_tertiary(tertiary); } @@ -175,6 +171,13 @@ impl PerplexityProvider { Ok(snapshot) } + fn credit_description(used: f64, total: f64) -> Option { + if !used.is_finite() || !total.is_finite() { + return None; + } + Some(format!("${:.2}/${:.2}", used / 100.0, total / 100.0)) + } + async fn fetch_with_cookies( &self, cookie_header: &str, @@ -317,4 +320,29 @@ mod tests { let snap = PerplexityProvider::parse_response(resp).unwrap(); assert_eq!(snap.login_method.as_deref(), Some("Max")); } + + #[test] + fn oversized_credit_totals_do_not_render_non_finite_descriptions() { + let resp = CreditsResponse { + balance_cents: 0.0, + renewal_date_ts: None, + current_period_purchased_cents: 0.0, + credit_grants: vec![ + CreditGrant { + grant_type: "recurring".to_string(), + amount_cents: f64::MAX, + expires_at_ts: None, + }, + CreditGrant { + grant_type: "recurring".to_string(), + amount_cents: f64::MAX, + expires_at_ts: None, + }, + ], + total_usage_cents: 0.0, + }; + + let snap = PerplexityProvider::parse_response(resp).unwrap(); + assert!(snap.primary.reset_description.is_none()); + } } From 594159caf9d9dd5985a88a574cb58e119a5ccc8c Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:31:12 +0700 Subject: [PATCH 3/8] Port standalone Pi history --- .../src-tauri/src/auto_refresh.rs | 2 +- .../src-tauri/src/commands/chart.rs | 1 + .../src-tauri/src/commands/spend_contract.rs | 3 +- .../src-tauri/src/commands/usage_spend.rs | 104 ++- .../src/components/providers/providerIcons.ts | 1 + .../src/lib/providerCharts.test.ts | 1 + apps/desktop-tauri/src/lib/providerCharts.ts | 2 +- .../providers/ProvidersSidebar.test.tsx | 2 +- .../sections/charts/ChartsSection.tsx | 4 +- .../desktop-tauri/src/test/providerCatalog.ts | 1 + rust/src/cli/cost.rs | 31 +- rust/src/cli/serve/dashboard/source.rs | 25 +- rust/src/cli/serve/data.rs | 1 + rust/src/cli/usage.rs | 2 +- rust/src/core/provider.rs | 10 +- rust/src/core/provider_factory.rs | 9 +- rust/src/core/token_accounts.rs | 1 + rust/src/cost_scanner.rs | 109 +++- rust/src/pi_session_cost.rs | 595 ++++++++++++++++-- rust/src/providers/mod.rs | 2 + rust/src/providers/pi.rs | 87 +++ rust/src/spend_contract.rs | 1 + 22 files changed, 883 insertions(+), 111 deletions(-) create mode 100644 rust/src/providers/pi.rs diff --git a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs index 6825f4170d..75128cb00d 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs @@ -218,7 +218,7 @@ fn local_usage_provider_ids(settings: &Settings) -> Vec { .get_enabled_provider_ids() .into_iter() .map(|provider| provider.cli_name().to_string()) - .filter(|provider_id| matches!(provider_id.as_str(), "codex" | "claude" | "muse")) + .filter(|provider_id| matches!(provider_id.as_str(), "codex" | "claude" | "pi" | "muse")) .collect() } diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 5e66b2d003..4561dd9129 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -644,6 +644,7 @@ fn scan_local_cost( match provider_id { "codex" => Some(scanner.scan_codex_with_cancel(cancel)), "claude" => Some(scanner.scan_claude_with_cancel(cancel)), + "pi" => Some(scanner.scan_pi_with_cancel(cancel)), "opencodego" => Some(scanner.scan_opencodego_with_cancel(cancel)), _ => None, } diff --git a/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs b/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs index a3e3637f12..4403ba7ad0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs @@ -11,7 +11,7 @@ pub async fn get_spend_contract( include_open_codex: Option, ) -> Result { let provider = provider_id.trim().to_ascii_lowercase(); - if !matches!(provider.as_str(), "codex" | "claude" | "opencodego") { + if !matches!(provider.as_str(), "codex" | "claude" | "pi" | "opencodego") { return Err(format!( "Spend contract is unavailable for provider: {provider}" )); @@ -24,6 +24,7 @@ pub async fn get_spend_contract( let summary = match provider.as_str() { "codex" => scanner.scan_codex(), "claude" => scanner.scan_claude(), + "pi" => scanner.scan_pi(), "opencodego" => scanner.scan_opencodego_with_cancel(None), _ => unreachable!(), }; diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs index f55ae71cd2..01f562e111 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -363,6 +363,9 @@ fn build_usage_spend_summary( ) -> UsageSpendSummary { let include_opencodex = settings.open_codex_usage_logs_enabled; let hide_native = settings.hide_native_codex_cost_when_open_codex_present; + let pi_selected = settings.enabled_providers.iter().any(|id| id == "pi") + || cached.iter().any(|snapshot| snapshot.provider_id == "pi"); + let include_pi_in_native = !pi_selected; // Upstream 0.55.0 #3105: independent provider baselines load in parallel. // Keep each provider's 7d/30d scans serial so they can safely share that @@ -372,29 +375,43 @@ fn build_usage_spend_summary( } else { codexbar::core::CostScanOptions::default() }; - let ((codex_7_summary, codex_30_summary), (claude_7_summary, claude_30_summary)) = - std::thread::scope(|scope| { - let codex = scope.spawn(move || { - ( - CostScanner::new(7) - .with_options(codex_scan_options) - .scan_codex(), - CostScanner::new(30) - .with_options(codex_scan_options) - .scan_codex(), - ) - }); - let claude = scope.spawn(|| { - ( - CostScanner::new(7).scan_claude(), - CostScanner::new(30).scan_claude(), - ) - }); + let mut codex_scan_options = codex_scan_options; + codex_scan_options.include_pi_sessions = include_pi_in_native; + let ( + (codex_7_summary, codex_30_summary), + (claude_7_summary, claude_30_summary), + (pi_7_summary, pi_30_summary), + ) = std::thread::scope(|scope| { + let codex = scope.spawn(move || { + ( + CostScanner::new(7) + .with_options(codex_scan_options) + .scan_codex(), + CostScanner::new(30) + .with_options(codex_scan_options) + .scan_codex(), + ) + }); + let claude = scope.spawn(|| { + ( + CostScanner::new(7) + .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native), + CostScanner::new(30) + .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native), + ) + }); + let pi = scope.spawn(|| { ( - codex.join().expect("Codex spend scan worker panicked"), - claude.join().expect("Claude spend scan worker panicked"), + CostScanner::new(7).scan_pi(), + CostScanner::new(30).scan_pi(), ) }); + ( + codex.join().expect("Codex spend scan worker panicked"), + claude.join().expect("Claude spend scan worker panicked"), + pi.join().expect("Pi spend scan worker panicked"), + ) + }); let codex_stale = !codex_30_summary.history_coverage_established; let codex_stale_updated_at = codex_stale @@ -421,6 +438,22 @@ fn build_usage_spend_summary( settings.hide_personal_info, codex_30_summary.clone(), ); + let pi_7_contract = build_local_spend_contract_from_summary( + "pi", + 7, + false, + false, + settings.hide_personal_info, + pi_7_summary.clone(), + ); + let pi_30_contract = build_local_spend_contract_from_summary( + "pi", + 30, + false, + false, + settings.hide_personal_info, + pi_30_summary.clone(), + ); let mut provider_ids: BTreeSet = settings.enabled_providers.iter().cloned().collect(); provider_ids.extend(cached.iter().map(|snapshot| snapshot.provider_id.clone())); @@ -494,6 +527,15 @@ fn build_usage_spend_summary( refreshing: false, stale_updated_at: None, }, + "pi" => SpendValues { + seven_day: pi_7_contract.known_cost_usd, + thirty_day: pi_30_contract.known_cost_usd, + seven_day_tokens: total_token_mix(&pi_7_contract.token_mix), + thirty_day_tokens: total_token_mix(&pi_30_contract.token_mix), + source: "local Pi/OMP history".to_string(), + refreshing: !pi_30_summary.history_coverage_established, + stale_updated_at: None, + }, "opencodego" | "kimi" | "deepseek" if include_opencodex => { let seven = build_local_spend_contract(&provider_id, 7, true); let thirty = build_local_spend_contract(&provider_id, 30, true); @@ -585,8 +627,11 @@ fn build_usage_spend_summary( thirty_day_tokens: spend.thirty_day_tokens, currency, source: spend.source, - included_in_overview: settings.enabled_providers.contains(&provider_id) - || cached_snapshot.is_some(), + included_in_overview: include_in_shared_overview( + &provider_id, + settings.enabled_providers.contains(&provider_id), + cached_snapshot.is_some(), + ), daily, refreshing: spend.refreshing, stale_updated_at: spend.stale_updated_at, @@ -623,6 +668,13 @@ fn build_usage_spend_summary( } } +/// Pi is an alternate local-history view over rows that may already be +/// projected into Codex or Claude. Keep it out of the shared denominator so +/// enabling Pi cannot double-count the same physical usage. +fn include_in_shared_overview(provider_id: &str, enabled: bool, cached: bool) -> bool { + provider_id != "pi" && (enabled || cached) +} + fn last_included_reporting_day(contract: &SpendContract) -> String { contract .daily @@ -774,4 +826,12 @@ mod cache_key_tests { let private = usage_spend_cache_key_with_privacy(&[], 30, false, false, true); assert_ne!(public, private); } + + #[test] + fn pi_history_is_an_alternate_view_not_a_shared_overview_source() { + assert!(!include_in_shared_overview("pi", true, true)); + assert!(include_in_shared_overview("codex", true, false)); + assert!(include_in_shared_overview("claude", false, true)); + assert!(!include_in_shared_overview("codex", false, false)); + } } diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index a2cf5dda8a..ccfa304078 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -166,6 +166,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { antigravity: { id: "antigravity", brandColor: "#60ba7e", fallbackLetter: "◉", svgPath: RAW.antigravity }, augment: { id: "augment", brandColor: "#6366f1", fallbackLetter: "A", svgPath: RAW.augment }, claude: { id: "claude", brandColor: "#cc7c5e", fallbackLetter: "◈", svgPath: RAW.claude }, + pi: { id: "pi", brandColor: "#7c3aed", fallbackLetter: "P" }, codebuff: { id: "codebuff", brandColor: "#44ff00", fallbackLetter: "B", svgPath: RAW.codebuff }, coderabbit: { id: "coderabbit", brandColor: "#ff5c35", fallbackLetter: "C", svgPath: RAW.coderabbit }, codex: { id: "codex", brandColor: "#49a3b0", fallbackLetter: "◆", svgPath: RAW.codex }, diff --git a/apps/desktop-tauri/src/lib/providerCharts.test.ts b/apps/desktop-tauri/src/lib/providerCharts.test.ts index bf8d1179a4..0f3c27a6be 100644 --- a/apps/desktop-tauri/src/lib/providerCharts.test.ts +++ b/apps/desktop-tauri/src/lib/providerCharts.test.ts @@ -7,6 +7,7 @@ describe("providerSupportsChartData", () => { expect(providerSupportsChartData("claude")).toBe(true); expect(providerSupportsChartData("openai")).toBe(true); expect(providerSupportsChartData("muse")).toBe(true); + expect(providerSupportsChartData("pi")).toBe(true); expect(providerSupportsChartData("OpenAI")).toBe(true); expect(providerSupportsChartData("copilot")).toBe(false); diff --git a/apps/desktop-tauri/src/lib/providerCharts.ts b/apps/desktop-tauri/src/lib/providerCharts.ts index f230f80090..6a4bec3586 100644 --- a/apps/desktop-tauri/src/lib/providerCharts.ts +++ b/apps/desktop-tauri/src/lib/providerCharts.ts @@ -1,4 +1,4 @@ -const PROVIDER_CHART_DATA_IDS = new Set(["claude", "codex", "muse", "openai"]); +const PROVIDER_CHART_DATA_IDS = new Set(["claude", "codex", "muse", "openai", "pi"]); export function providerSupportsChartData(providerId: string): boolean { return PROVIDER_CHART_DATA_IDS.has(providerId.toLowerCase()); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProvidersSidebar.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProvidersSidebar.test.tsx index 8b947a7dc0..e85aa379ce 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProvidersSidebar.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProvidersSidebar.test.tsx @@ -133,7 +133,7 @@ describe("ProvidersSidebar", () => { container.querySelectorAll(".providers-sidebar__name"), (node) => node.textContent, ); - expect(names.slice(0, 3)).toEqual(["Claude", "Codex", "Cursor"]); + expect(names.slice(0, 3)).toEqual(["Claude", "Codex", "Pi"]); }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx index 86bcb183eb..8257e3069c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx @@ -89,7 +89,9 @@ export function ChartsSection({ providerId, accountEmail, accentColor, t }: Prop // Upstream 0.50.0 #2930: Codex defaults to exact local token totals. const defaultTab: TabKey = - (providerId === "codex" || providerId === "muse") && hasTokens ? "tokens" : available[0]; + (providerId === "codex" || providerId === "muse" || providerId === "pi") && hasTokens + ? "tokens" + : available[0]; const current: TabKey = active && available.includes(active) ? active : defaultTab; const emptyMsg = t("DetailChartEmpty"); diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 6eac860900..c8ab2daa5d 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -1,6 +1,7 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["codex", "Codex"], ["claude", "Claude"], + ["pi", "Pi"], ["cursor", "Cursor"], ["factory", "Factory"], ["gemini", "Gemini"], diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index fe12351104..8e9be16dc2 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -16,7 +16,7 @@ use crate::spend_contract::build_local_spend_contract_from_summary; /// Arguments for the cost command #[derive(Args, Debug, Default)] pub struct CostArgs { - /// Provider to query (codex, claude, muse, antigravity, cursor, gemini, copilot, all, both) + /// Provider to query (codex, claude, pi, muse, antigravity, cursor, gemini, copilot, all, both) #[arg(short, long)] pub provider: Option, @@ -109,7 +109,12 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { } let mut scan_options = CostScanOptions::app_driven(); - scan_options.include_pi_sessions = !args.provider_native_only; + let requested_providers = providers.as_list(); + let pi_selected = requested_providers.contains(&ProviderId::Pi); + // When Pi is selected alongside native providers, the standalone Pi row + // owns its mirrored Codex/Claude events. A single native-provider request + // keeps the historical inclusive behavior unless explicitly narrowed. + scan_options.include_pi_sessions = !args.provider_native_only && !pi_selected; let scanner = CostScanner::new(args.days).with_options(scan_options); tracing::debug!( @@ -135,7 +140,21 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { }); } ProviderId::Claude => { - let summary = scanner.scan_claude(); + let summary = if pi_selected || args.provider_native_only { + scanner.scan_claude_with_cancel_and_pi_sessions(None, false) + } else { + scanner.scan_claude() + }; + results.push(CostResult { + provider: provider.cli_name().to_string(), + display_name: provider.display_name().to_string(), + summary, + supported: true, + token_history: None, + }); + } + ProviderId::Pi => { + let summary = scanner.scan_pi(); results.push(CostResult { provider: provider.cli_name().to_string(), display_name: provider.display_name().to_string(), @@ -450,7 +469,7 @@ fn build_json_payloads(results: &[CostResult], days: u32) -> Vec Vec serde_json::Value::String("complete".to_string()), crate::cost_scanner::ModelPricingCompleteness::Partial { unpriced_models } => serde_json::json!({"partial": {"unpriced_models": unpriced_models}}), diff --git a/rust/src/cli/serve/dashboard/source.rs b/rust/src/cli/serve/dashboard/source.rs index 76dc1dd162..8d28758df4 100644 --- a/rust/src/cli/serve/dashboard/source.rs +++ b/rust/src/cli/serve/dashboard/source.rs @@ -16,6 +16,7 @@ use chrono::{Local, Utc}; use crate::core::{CostScanOptions, FetchContext, ProviderId, SourceMode, instantiate_provider}; use crate::cost_scanner::{self, CostScanner}; use crate::settings::Settings; +use crate::spend_contract::build_local_spend_contract_from_summary; use crate::cli::serve::collection::{ AccountFetchEnvelope, ClaudeAccountsInput, ProviderFetchEnvelope, RawCostPayload, @@ -111,7 +112,7 @@ impl SnapshotProducer { let providers: Vec = indexed.into_iter().map(|(_, envelope)| envelope).collect(); - let costs = collect_costs().await; + let costs = collect_costs(provider_ids.contains(&ProviderId::Pi)).await; let claude_accounts = collect_claude_accounts(provider_ids.contains(&ProviderId::Claude)).await; @@ -205,13 +206,18 @@ async fn bounded_fetch( } } -/// Local cost data for the two scanned providers, computed off the async +/// Local cost data for the scanned providers, computed off the async /// runtime so a large corpus cannot stall dashboard builds. -async fn collect_costs() -> HashMap { - let result = tokio::task::spawn_blocking(|| { - let scanner = CostScanner::new(30).with_options(CostScanOptions::app_driven()); +async fn collect_costs(pi_selected: bool) -> HashMap { + let result = tokio::task::spawn_blocking(move || { + let mut scan_options = CostScanOptions::app_driven(); + scan_options.include_pi_sessions = !pi_selected; + let scanner = CostScanner::new(30).with_options(scan_options); let codex = scanner.scan_codex_with_cancel(None); - let claude = scanner.scan_claude_with_cancel(None); + let claude = scanner.scan_claude_with_cancel_and_pi_sessions(None, !pi_selected); + let pi = scanner.scan_pi_with_cancel(None); + let pi_contract = + build_local_spend_contract_from_summary("pi", 30, false, false, false, pi); let today = Local::now().date_naive().format("%Y-%m-%d").to_string(); let today_of = |provider: &str| { cost_scanner::get_daily_cost_history(provider, 30) @@ -234,6 +240,13 @@ async fn collect_costs() -> HashMap { last_30_days_usd: Some(claude.total_cost_usd), }, ); + costs.insert( + "pi".to_string(), + RawCostPayload { + today_usd: today_of("pi"), + last_30_days_usd: pi_contract.known_cost_usd, + }, + ); costs }) .await; diff --git a/rust/src/cli/serve/data.rs b/rust/src/cli/serve/data.rs index 37130eba8d..d1841db6bc 100644 --- a/rust/src/cli/serve/data.rs +++ b/rust/src/cli/serve/data.rs @@ -88,6 +88,7 @@ pub async fn cost_response(provider: Option<&str>) -> String { let (supported, summary) = match provider_id { ProviderId::Codex => (true, scanner.scan_codex()), ProviderId::Claude => (true, scanner.scan_claude()), + ProviderId::Pi => (true, scanner.scan_pi()), _ => (false, Default::default()), }; if supported { diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index f33373b90c..2b1dfff0af 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -25,7 +25,7 @@ pub(super) enum UsageOutput { Toon(Vec), } -pub const PROVIDER_ARG_HELP: &str = "Provider to query (for example: codex, claude, gemini, antigravity/agy, nanogpt, deepseek, codebuff, windsurf, all, both)"; +pub const PROVIDER_ARG_HELP: &str = "Provider to query (for example: codex, claude, pi, gemini, antigravity/agy, nanogpt, deepseek, codebuff, windsurf, all, both)"; /// Arguments for the usage command #[derive(Args, Debug, Default)] diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index feedadedcd..d6a38df551 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -14,6 +14,7 @@ use super::provider_state::ProviderStateKind; pub enum ProviderId { Codex, Claude, + Pi, Cursor, Factory, Gemini, @@ -97,6 +98,7 @@ impl ProviderId { &[ ProviderId::Codex, ProviderId::Claude, + ProviderId::Pi, ProviderId::Cursor, ProviderId::Factory, ProviderId::Gemini, @@ -179,6 +181,7 @@ impl ProviderId { match self { ProviderId::Codex => "codex", ProviderId::Claude => "claude", + ProviderId::Pi => "pi", ProviderId::Cursor => "cursor", ProviderId::Factory => "factory", ProviderId::Gemini => "gemini", @@ -261,6 +264,7 @@ impl ProviderId { match self { ProviderId::Codex => "Codex", ProviderId::Claude => "Claude", + ProviderId::Pi => "Pi", ProviderId::Cursor => "Cursor", ProviderId::Factory => "Factory", ProviderId::Gemini => "Gemini", @@ -349,6 +353,7 @@ impl ProviderId { ProviderId::Cursor => Some("cursor.com"), ProviderId::Factory => Some("app.factory.ai"), ProviderId::Codex => Some("chatgpt.com"), + ProviderId::Pi => None, ProviderId::Gemini => Some("aistudio.google.com"), ProviderId::Kiro => Some("kiro.dev"), ProviderId::Kimi => Some("kimi.moonshot.cn"), @@ -432,6 +437,7 @@ impl ProviderId { match name.to_lowercase().as_str() { "codex" | "openai" => Some(ProviderId::Codex), "claude" | "anthropic" => Some(ProviderId::Claude), + "pi" | "pi-mono" => Some(ProviderId::Pi), "cursor" => Some(ProviderId::Cursor), "factory" | "droid" => Some(ProviderId::Factory), "gemini" | "google" => Some(ProviderId::Gemini), @@ -1001,6 +1007,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { match id { ProviderId::Codex => "#49A3B0", ProviderId::Claude => "#CC7C5E", + ProviderId::Pi => "#7C3AED", ProviderId::Cursor => "#00BFA5", ProviderId::Factory => "#FF6B35", ProviderId::Gemini => "#AB87EA", @@ -1089,9 +1096,10 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 76); + assert_eq!(all.len(), 77); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); + assert!(all.contains(&ProviderId::Pi)); assert!(all.contains(&ProviderId::Fireworks)); assert!(all.contains(&ProviderId::Kimi)); assert!(all.contains(&ProviderId::KimiK2)); diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index b8fb6f7f7e..aff4b6f31d 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -18,10 +18,10 @@ use crate::providers::{ LongCatProvider, ManusProvider, MetaProvider, MiMoProvider, MiniMaxProvider, MistralProvider, MuseProvider, NanoGPTProvider, NeuralwattProvider, NotionProvider, NousProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, OpenRouterProvider, - PerplexityProvider, PoeProvider, QoderProvider, QwenCloudProvider, ReplicateProvider, - SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, VeniceProvider, - VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, XaiProvider, ZaiProvider, - ZedProvider, ZenMuxProvider, ZoomMateProvider, + PerplexityProvider, PiProvider, PoeProvider, QoderProvider, QwenCloudProvider, + ReplicateProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, + VeniceProvider, VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, + XaiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, ZoomMateProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -32,6 +32,7 @@ pub fn instantiate(id: ProviderId) -> Box { match id { ProviderId::Claude => Box::new(ClaudeProvider::new()), ProviderId::Codex => Box::new(CodexProvider::new()), + ProviderId::Pi => Box::new(PiProvider::new()), ProviderId::Cursor => Box::new(CursorProvider::new()), ProviderId::Gemini => Box::new(GeminiProvider::new()), ProviderId::Copilot => Box::new(CopilotProvider::new()), diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 0e1b6c076f..a8f3cc71ae 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -332,6 +332,7 @@ impl TokenAccountSupport { }), // These providers don't support token accounts ProviderId::Codex + | ProviderId::Pi | ProviderId::Gemini | ProviderId::Antigravity | ProviderId::Kiro diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index e8b1dd03cd..619f9ba7b0 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -523,6 +523,38 @@ impl CostScanner { self } + /// Scan standalone Pi and OMP local history. + /// + /// Pi session rows can represent either Codex or Claude models. They are + /// priced with the mapped provider's table but owned by the standalone Pi + /// source, so this path never adds native Codex/Claude transcripts. + pub fn scan_pi(&self) -> CostSummary { + self.scan_pi_with_cancel(None) + } + + pub fn scan_pi_with_cancel(&self, cancel: Option<&AtomicBool>) -> CostSummary { + let today = Utc::now().date_naive(); + let mut summary = CostSummary { + period_start: Some(today - Duration::days(self.days as i64)), + period_end: Some(today), + ..CostSummary::default() + }; + let mut seen_entries = HashSet::new(); + let evidence = crate::pi_session_cost::scan_pi_into( + &mut summary, + self.days, + cancel, + &mut seen_entries, + ); + summary.history_coverage_established = evidence.complete && !is_cancelled(cancel); + summary.known_zero = summary.history_coverage_established + && summary.sessions_count == 0 + && summary.input_tokens == 0 + && summary.output_tokens == 0 + && summary.cached_tokens == 0; + summary + } + /// Scan Codex local logs pub fn scan_claude(&self) -> CostSummary { self.scan_claude_with_cancel(None) @@ -530,6 +562,19 @@ impl CostScanner { /// Scan Claude local logs, stopping early when the caller cancels the scan. pub fn scan_claude_with_cancel(&self, cancel: Option<&AtomicBool>) -> CostSummary { + self.scan_claude_with_cancel_and_pi_sessions(cancel, true) + } + + /// Scan Claude local logs with optional Pi/OMP-compatible history. + /// + /// The default scanner remains inclusive for backwards compatibility. A + /// combined Codex/Claude/Pi selection can turn this off so the standalone + /// Pi row owns those mirrored events exactly once. + pub fn scan_claude_with_cancel_and_pi_sessions( + &self, + cancel: Option<&AtomicBool>, + include_pi_sessions: bool, + ) -> CostSummary { let projects_dir = self.get_claude_projects_dir(); let mut summary = CostSummary::default(); let today = Utc::now().date_naive(); @@ -565,14 +610,16 @@ impl CostScanner { } // OMP / pi-compatible anthropic rows, deduped across shared files. - let mut seen_pi = HashSet::new(); - crate::pi_session_cost::scan_pi_compatible_into( - &mut summary, - crate::pi_session_cost::PiMappedProvider::Claude, - self.days, - cancel, - &mut seen_pi, - ); + if include_pi_sessions { + let mut seen_pi = HashSet::new(); + crate::pi_session_cost::scan_pi_compatible_into( + &mut summary, + crate::pi_session_cost::PiMappedProvider::Claude, + self.days, + cancel, + &mut seen_pi, + ); + } // Claude has no persisted provider cost-report cache in the Windows // port. Rebuilding from the transcript inventory on every scan makes @@ -1092,7 +1139,7 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option< let date_str = date.format("%Y-%m-%d").to_string(); daily_costs.insert( date_str, - (provider != "codex" && provider != "claude").then_some(0.0), + (provider != "codex" && provider != "claude" && provider != "pi").then_some(0.0), ); } @@ -1173,6 +1220,21 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option< } } } + "pi" => { + let scan = crate::pi_session_cost::scan_pi_daily(days, None); + for (day_key, cost) in &scan.costs { + if let Some(slot) = daily_costs.get_mut(day_key) { + *slot = (!scan.unpriced_days.contains(day_key)).then_some(*cost); + } + } + if scan.history_coverage_established { + for (day_key, slot) in &mut daily_costs { + if slot.is_none() && !scan.unpriced_days.contains(day_key) { + *slot = Some(0.0); + } + } + } + } _ => {} } @@ -1247,6 +1309,17 @@ pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)> scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file); } } + "pi" => { + let scan = crate::pi_session_cost::scan_pi_daily(days, None); + for (day_key, tokens) in scan.tokens { + if let Some(slot) = daily_tokens.get_mut(&day_key) { + *slot = tokens; + } + } + if scan.history_coverage_established { + covered_days.extend(daily_tokens.keys().cloned()); + } + } _ => {} } @@ -1257,12 +1330,18 @@ pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)> // Codex only: the bounded catch-up may not have reached the requested // depth yet. Incomplete = history exists but the oldest quarter of the // window has no scanned day. - let incomplete = provider == "codex" - && !covered_days.is_empty() - && covered_days.len() < days as usize - && result[..(result.len() / 4).max(1)] - .iter() - .any(|(date, _)| !covered_days.contains(date)); + let incomplete = if provider == "pi" { + // Pi scans are bounded filesystem walks, so a complete parse covers + // the requested window even when the roots contain no sessions. + covered_days.is_empty() + } else { + provider == "codex" + && !covered_days.is_empty() + && covered_days.len() < days as usize + && result[..(result.len() / 4).max(1)] + .iter() + .any(|(date, _)| !covered_days.contains(date)) + }; (result, incomplete) } diff --git a/rust/src/pi_session_cost.rs b/rust/src/pi_session_cost.rs index e372bec322..a9f8f1b82c 100644 --- a/rust/src/pi_session_cost.rs +++ b/rust/src/pi_session_cost.rs @@ -1,10 +1,10 @@ //! Pi-compatible + OMP agent session cost scan (upstream #2269). //! -//! Walks `~/.pi/agent/sessions/**/*.jsonl` and `~/.omp/agent/sessions/**/*.jsonl` -//! and attributes openai-codex / anthropic assistant rows into cost summaries -//! without double-counting the same entry id across shared files. +//! Resolves Pi-family session roots and walks their JSONL files, attributing +//! openai-codex / anthropic assistant rows into cost summaries without +//! double-counting the same entry id across shared files. -use chrono::{DateTime, Duration, Utc}; +use chrono::{DateTime, Duration, Local, Utc}; use serde_json::Value; use std::collections::HashSet; use std::fs::File; @@ -12,8 +12,12 @@ use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; +use crate::agent_sessions::pi_family::roots::{ + EnvMap, PiProfile, omp_all_profile_roots, omp_default_profile_root, omp_named_profile_root, + omp_profile_selector, pi_settings_session_directory, +}; use crate::core::CostUsagePricing; -use crate::cost_scanner::CostSummary; +use crate::cost_scanner::{CostSummary, ModelPricingCompleteness}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PiMappedProvider { @@ -21,59 +25,254 @@ pub enum PiMappedProvider { Claude, } +#[derive(Debug, Default)] +pub struct PiDailyScan { + pub costs: std::collections::HashMap, + pub tokens: std::collections::HashMap, + pub unpriced_days: HashSet, + pub history_coverage_established: bool, +} + +#[derive(Debug, Clone, Copy)] +pub struct PiScanEvidence { + pub complete: bool, +} + +impl Default for PiScanEvidence { + fn default() -> Self { + Self { complete: true } + } +} + /// Session roots to scan: `.pi` and `.omp` under the user home. pub fn pi_compatible_session_roots(home: Option) -> Vec { let Some(home) = home else { return Vec::new(); }; - [".pi", ".omp"] + let cwd = std::env::current_dir().unwrap_or_else(|_| home.clone()); + let environment: EnvMap = std::env::vars().collect(); + pi_compatible_session_roots_for(&home, &cwd, &environment) +} + +fn pi_compatible_session_roots_for(home: &Path, cwd: &Path, environment: &EnvMap) -> Vec { + let mut roots = Vec::new(); + let pi_root = environment + .get("PI_CODING_AGENT_SESSION_DIR") + .and_then(|value| resolve_environment_path(value, cwd)) + .or_else(|| { + environment + .get("PI_CODING_AGENT_DIR") + .and_then(|value| resolve_environment_path(value, cwd)) + .map(|root| root.join("sessions")) + }) + .or_else(|| pi_settings_session_directory(cwd, home)) + .unwrap_or_else(|| home.join(".pi").join("agent").join("sessions")); + roots.push(pi_root); + + match omp_profile_selector(environment) { + PiProfile::Invalid => {} + PiProfile::Named(profile) => { + if let Some(root) = omp_named_profile_root(&profile, environment, cwd, home) { + roots.push(root); + } + } + PiProfile::Default => { + if let Some(root) = omp_default_profile_root(environment, cwd, home) { + roots.push(root); + } + roots.extend(omp_all_profile_roots(environment, home)); + } + } + + let mut seen = HashSet::new(); + roots .into_iter() - .map(|dir| home.join(dir).join("agent").join("sessions")) + .filter(|root| { + let key = std::fs::canonicalize(root) + .unwrap_or_else(|_| root.clone()) + .to_string_lossy() + .to_ascii_lowercase(); + seen.insert(key) + }) .collect() } +fn resolve_environment_path(value: &str, cwd: &Path) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + let path = PathBuf::from(value); + Some(if path.is_absolute() { + path + } else { + cwd.join(path) + }) +} + pub fn scan_pi_compatible_into( summary: &mut CostSummary, target: PiMappedProvider, days: u32, cancel: Option<&AtomicBool>, seen_entries: &mut HashSet, -) { +) -> PiScanEvidence { + scan_roots_into( + summary, + days, + cancel, + seen_entries, + pi_compatible_session_roots(dirs::home_dir()), + Some(target), + ) +} + +/// Scan Pi and OMP history as one standalone provider-owned source. +/// +/// The compatible Codex/Claude paths above intentionally project these rows +/// into their native summaries. This path keeps the same parser and pricing, +/// but accepts both mapped providers and shares one deduplication set across +/// both roots so standalone Pi history is not double-counted. +pub fn scan_pi_into( + summary: &mut CostSummary, + days: u32, + cancel: Option<&AtomicBool>, + seen_entries: &mut HashSet, +) -> PiScanEvidence { + scan_roots_into( + summary, + days, + cancel, + seen_entries, + pi_compatible_session_roots(dirs::home_dir()), + None, + ) +} + +/// Scan standalone Pi/OMP history into daily cost and token buckets. +pub fn scan_pi_daily(days: u32, cancel: Option<&AtomicBool>) -> PiDailyScan { + let cutoff = Utc::now() - Duration::days(days as i64); + scan_pi_daily_from_roots( + cutoff, + cancel, + pi_compatible_session_roots(dirs::home_dir()), + ) +} + +fn scan_pi_daily_from_roots( + cutoff: DateTime, + cancel: Option<&AtomicBool>, + roots: Vec, +) -> PiDailyScan { + let mut result = PiDailyScan::default(); + let mut seen_entries = HashSet::new(); + let mut missing_timestamp = false; + let mut evidence = PiScanEvidence::default(); + for root in roots { + if cancelled(cancel) { + break; + } + if !root.is_dir() { + continue; + } + if !walk_jsonl(&root, cancel, &mut |path| { + if cancelled(cancel) { + return false; + } + let file = for_each_pi_entry(path, cutoff, None, &mut seen_entries, |entry| { + let Some(timestamp) = entry.timestamp else { + missing_timestamp = true; + return; + }; + let day = timestamp + .with_timezone(&Local) + .date_naive() + .format("%Y-%m-%d") + .to_string(); + if !entry.pricing_known { + result.unpriced_days.insert(day.clone()); + } + *result.costs.entry(day.clone()).or_insert(0.0) += entry.cost; + let tokens = entry.input.saturating_add(entry.output); + let total = result.tokens.entry(day.clone()).or_insert(0); + *total = total.saturating_add(tokens); + }); + file.complete + }) { + evidence.complete = false; + } + } + result.history_coverage_established = + evidence.complete && !cancelled(cancel) && !missing_timestamp; + result +} + +fn scan_roots_into( + summary: &mut CostSummary, + days: u32, + cancel: Option<&AtomicBool>, + seen_entries: &mut HashSet, + roots: Vec, + target: Option, +) -> PiScanEvidence { let cutoff = Utc::now() - Duration::days(days as i64); let mut sessions = 0u32; - for root in pi_compatible_session_roots(dirs::home_dir()) { + let mut evidence = PiScanEvidence::default(); + for root in roots { if cancelled(cancel) { break; } if !root.is_dir() { continue; } - walk_jsonl(&root, cancel, &mut |path| { + if !walk_jsonl(&root, cancel, &mut |path| { if cancelled(cancel) { - return; + return false; } let before = seen_entries.len(); - let counted = for_each_pi_entry(path, cutoff, target, seen_entries, |entry| { + let file = for_each_pi_entry(path, cutoff, target, seen_entries, |entry| { apply_entry(summary, &entry); }); - if counted > 0 || seen_entries.len() > before { + if file.counted > 0 || seen_entries.len() > before { sessions += 1; } - }); + file.complete + }) { + evidence.complete = false; + } } summary.sessions_count = summary.sessions_count.saturating_add(sessions); + evidence } struct PiEntry { + timestamp: Option>, + provider: PiMappedProvider, model: String, input: u64, output: u64, cache_read: u64, cache_create: u64, cost: f64, + pricing_known: bool, } fn apply_entry(summary: &mut CostSummary, entry: &PiEntry) { + if !entry.pricing_known { + summary.unknown_models.insert(entry.model.clone()); + match &mut summary.model_pricing_completeness { + ModelPricingCompleteness::Complete => { + summary.model_pricing_completeness = ModelPricingCompleteness::Partial { + unpriced_models: vec![entry.model.clone()], + }; + } + ModelPricingCompleteness::Partial { unpriced_models } => { + if !unpriced_models.contains(&entry.model) { + unpriced_models.push(entry.model.clone()); + } + } + } + } summary.input_tokens += entry.input; summary.output_tokens += entry.output; summary.cached_tokens += entry.cache_read + entry.cache_create; @@ -92,62 +291,130 @@ fn cancelled(cancel: Option<&AtomicBool>) -> bool { cancel.is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)) } -fn walk_jsonl(root: &Path, cancel: Option<&AtomicBool>, on_file: &mut dyn FnMut(&Path)) { +fn walk_jsonl( + root: &Path, + cancel: Option<&AtomicBool>, + on_file: &mut dyn FnMut(&Path) -> bool, +) -> bool { let Ok(entries) = std::fs::read_dir(root) else { - return; + return false; }; - for entry in entries.flatten() { + let mut complete = true; + for entry in entries { if cancelled(cancel) { - return; + return false; } + let Ok(entry) = entry else { + complete = false; + continue; + }; let path = entry.path(); if path.is_dir() { - walk_jsonl(&path, cancel, on_file); + complete = walk_jsonl(&path, cancel, on_file) && complete; } else if path .extension() .and_then(|e| e.to_str()) .is_some_and(|e| e.eq_ignore_ascii_case("jsonl")) { - on_file(&path); + complete = on_file(&path) && complete; } } + complete +} + +#[derive(Debug, Clone, Copy)] +struct PiFileScanResult { + counted: u32, + complete: bool, } fn for_each_pi_entry( path: &Path, cutoff: DateTime, - target: PiMappedProvider, + target: Option, seen: &mut HashSet, mut on_entry: impl FnMut(PiEntry), -) -> u32 { +) -> PiFileScanResult { let Ok(file) = File::open(path) else { - return 0; + return PiFileScanResult { + counted: 0, + complete: false, + }; }; let mut counted = 0u32; + let mut complete = true; + let mut session_id = None; let reader = BufReader::new(file); - for line in reader.lines().map_while(Result::ok) { + for (ordinal, line_result) in reader.lines().enumerate() { + let Ok(line) = line_result else { + complete = false; + continue; + }; + if line.trim().is_empty() { + continue; + } let Ok(value) = serde_json::from_str::(&line) else { + complete = false; continue; }; - let Some(entry) = parse_pi_assistant_entry(&value, target) else { + if session_id.is_none() { + session_id = value + .get("id") + .and_then(|id| id.as_str()) + .filter(|_| value.get("type").and_then(Value::as_str) == Some("session")) + .map(str::to_string); + } + let Some(entry) = parse_pi_assistant_entry_any(&value) else { + if looks_like_usage_candidate(&value) { + complete = false; + } continue; }; + if entry.timestamp.is_none() { + complete = false; + } if let Some(ts) = entry_timestamp(&value) && ts < cutoff { continue; } - let entry_id = entry_dedup_key(&value, path, counted); + if target.is_some_and(|target| entry.provider != target) { + continue; + } + let entry_id = entry_dedup_key(&value, path, ordinal, session_id.as_deref()); if !seen.insert(entry_id) { continue; } on_entry(entry); counted += 1; } - counted + PiFileScanResult { counted, complete } } -fn entry_dedup_key(value: &Value, path: &Path, ordinal: u32) -> String { +fn looks_like_usage_candidate(value: &Value) -> bool { + let message = value.get("message").unwrap_or(value); + let role = message + .get("role") + .or_else(|| value.get("role")) + .and_then(Value::as_str) + .unwrap_or(""); + let typ = value.get("type").and_then(Value::as_str).unwrap_or(""); + let assistant_shape = role.eq_ignore_ascii_case("assistant") + || typ.eq_ignore_ascii_case("assistant") + || typ.eq_ignore_ascii_case("message"); + assistant_shape + && message + .get("usage") + .or_else(|| value.get("usage")) + .is_some() +} + +fn entry_dedup_key(value: &Value, path: &Path, ordinal: usize, session_id: Option<&str>) -> String { + // Pi/OMP migrations can mirror the same event into both roots, so a + // stable event id wins within the logical session. Scope it with the + // session header when available: message IDs can be reused by separate + // sessions. The file stem and physical line ordinal cover legacy rows + // without a session header or stable message id. if let Some(id) = value .get("id") .or_else(|| value.get("messageId")) @@ -156,7 +423,14 @@ fn entry_dedup_key(value: &Value, path: &Path, ordinal: u32) -> String { .map(str::trim) .filter(|s| !s.is_empty()) { - return id.to_string(); + let scope = session_id + .map(str::to_string) + .or_else(|| { + path.file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| path.display().to_string()); + return format!("{scope}#{id}"); } format!("{}#{ordinal}", path.display()) } @@ -190,7 +464,13 @@ fn map_provider(raw: &str) -> Option { None } +#[cfg(test)] fn parse_pi_assistant_entry(value: &Value, target: PiMappedProvider) -> Option { + let entry = parse_pi_assistant_entry_any(value)?; + (entry.provider == target).then_some(entry) +} + +fn parse_pi_assistant_entry_any(value: &Value) -> Option { // Accept either flat or nested { message: {...} } pi-compatible rows. let message = value.get("message").unwrap_or(value); let role = message @@ -216,10 +496,6 @@ fn parse_pi_assistant_entry(value: &Value, target: PiMappedProvider) -> Option

Option

CostUsagePricing::codex_cost_usd_with_cache_write( + let (cost, pricing_known) = match mapped { + PiMappedProvider::Codex => match CostUsagePricing::codex_cost_usd_with_cache_write( &model, input, cache_read, cache_create, output, - ) - .unwrap_or(0.0), + ) { + Some(cost) => (cost, true), + None => (0.0, false), + }, PiMappedProvider::Claude => { // Token counts come from API usage records and fit within i32; // the canonical Claude pricing table takes i32 per-token counts. @@ -297,27 +575,37 @@ fn parse_pi_assistant_entry(value: &Value, target: PiMappedProvider) -> Option

(), 132); + } + + #[test] + fn malformed_usage_input_keeps_valid_rows_but_marks_source_incomplete() { + let dir = tempdir().unwrap(); + let sessions = dir.path().join("agent").join("sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + let valid = r#"{"id":"valid","role":"assistant","provider":"openai-codex","model":"gpt-5","timestamp":"2026-07-20T12:00:00Z","usage":{"input":11,"output":3}}"#; + std::fs::write( + sessions.join("mixed.jsonl"), + format!("{valid}\n{{\"role\":\"assistant\",\"usage\":\n"), + ) + .unwrap(); + + let scan = scan_pi_daily_from_roots( + DateTime::parse_from_rfc3339("2026-07-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + None, + vec![sessions.clone()], + ); + assert!(!scan.history_coverage_established); + assert_eq!(scan.tokens.values().sum::(), 14); + + let mut summary = CostSummary::default(); + let mut seen = HashSet::new(); + let evidence = scan_roots_into(&mut summary, 365, None, &mut seen, vec![sessions], None); + assert!(!evidence.complete); + assert_eq!(summary.input_tokens, 11); + } + + #[test] + fn standalone_daily_scan_dedupes_pi_and_omp_roots() { + let dir = tempdir().unwrap(); + let pi_sessions = dir.path().join(".pi").join("agent").join("sessions"); + let omp_sessions = dir.path().join(".omp").join("agent").join("sessions"); + std::fs::create_dir_all(&pi_sessions).unwrap(); + std::fs::create_dir_all(&omp_sessions).unwrap(); + let codex = r#"{"id":"shared","role":"assistant","provider":"openai-codex","model":"gpt-5","timestamp":"2026-07-20T12:00:00Z","usage":{"input":50,"output":5}}"#; + let claude = r#"{"id":"claude-only","role":"assistant","provider":"anthropic","model":"claude-sonnet-4-6","timestamp":"2026-07-20T13:00:00Z","usage":{"input":70,"output":7}}"#; + std::fs::write( + pi_sessions.join("one.jsonl"), + format!("{codex}\n{claude}\n"), + ) + .unwrap(); + std::fs::write(omp_sessions.join("one.jsonl"), format!("{codex}\n")).unwrap(); + + let scan = scan_pi_daily_from_roots( + DateTime::parse_from_rfc3339("2026-07-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + None, + vec![pi_sessions, omp_sessions], + ); + assert!(scan.history_coverage_established); + assert_eq!(scan.tokens.values().sum::(), 132); + assert_eq!(scan.tokens.len(), 1); + } + #[test] fn session_roots_include_pi_and_omp() { - let roots = pi_compatible_session_roots(Some(PathBuf::from("/home/user"))); + let home = PathBuf::from("/home/user"); + let roots = pi_compatible_session_roots_for(&home, &home, &EnvMap::new()); assert!( roots .iter() diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 6ac5e0f1a8..ad697ad321 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -65,6 +65,7 @@ pub mod opencode; pub mod opencodego; pub mod openrouter; pub mod perplexity; +pub mod pi; pub mod poe; pub mod qoder; pub mod qwencloud; @@ -143,6 +144,7 @@ pub use opencode::OpenCodeProvider; pub use opencodego::OpenCodeGoProvider; pub use openrouter::OpenRouterProvider; pub use perplexity::PerplexityProvider; +pub use pi::PiProvider; pub use poe::PoeProvider; pub use qoder::QoderProvider; pub use qwencloud::QwenCloudProvider; diff --git a/rust/src/providers/pi.rs b/rust/src/providers/pi.rs new file mode 100644 index 0000000000..862f069abc --- /dev/null +++ b/rust/src/providers/pi.rs @@ -0,0 +1,87 @@ +//! Local Pi provider. +//! +//! Pi has no remote quota endpoint in the upstream provider model. Its usage +//! and token-cost history come from local Pi/OMP session JSONL files; the +//! ordinary provider refresh therefore exposes an informational local row and +//! leaves cost history to the dedicated scanner path. + +use async_trait::async_trait; + +use crate::core::{ + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + RateWindow, SourceMode, UsageSnapshot, +}; + +pub struct PiProvider { + metadata: ProviderMetadata, +} + +impl PiProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Pi, + display_name: "Pi", + session_label: "Session", + weekly_label: "Weekly", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://github.com/badlogic/pi-mono"), + status_page_url: None, + tertiary_label_key: None, + }, + } + } +} + +impl Default for PiProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for PiProvider { + fn id(&self) -> ProviderId { + ProviderId::Pi + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + if ctx.source_mode != SourceMode::Auto { + return Err(ProviderError::UnsupportedSource(ctx.source_mode)); + } + + Ok(ProviderFetchResult::new( + UsageSnapshot::new(RateWindow::informational("Local Pi history")), + "local", + ) + .with_non_authoritative_pace()) + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exposes_local_only_metadata() { + let provider = PiProvider::new(); + assert_eq!(provider.id(), ProviderId::Pi); + assert_eq!(provider.metadata().display_name, "Pi"); + assert!(!provider.metadata().default_enabled); + assert_eq!(provider.available_sources(), vec![SourceMode::Auto]); + assert!(!provider.supports_oauth()); + assert!(!provider.supports_web()); + assert!(!provider.supports_cli()); + } +} diff --git a/rust/src/spend_contract.rs b/rust/src/spend_contract.rs index 9a2e928b34..af9cdfef34 100644 --- a/rust/src/spend_contract.rs +++ b/rust/src/spend_contract.rs @@ -346,6 +346,7 @@ pub fn build_local_spend_contract( let summary = match provider_id { "codex" => scanner.scan_codex(), "claude" => scanner.scan_claude(), + "pi" => scanner.scan_pi(), "opencodego" => scanner.scan_opencodego_with_cancel(None), _ => CostSummary::default(), }; From b1a619cd0671afd4ba53adb942575753ffadc673 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:56:48 +0700 Subject: [PATCH 4/8] Contain oversized Claude history values --- rust/src/cost_scanner.rs | 128 ++++++++++++++++++------ rust/src/cost_scanner/claude_pricing.rs | 114 ++++++++++++++++++--- rust/src/cost_scanner/tests.rs | 96 +++++++++++++++++- 3 files changed, 291 insertions(+), 47 deletions(-) diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 619f9ba7b0..2b730cdc01 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -435,7 +435,9 @@ struct ClaudeUsageRecord { output: u64, cache_create: u64, cache_read: u64, - cost: f64, + /// `None` means pricing was unavailable or produced a non-finite value. + /// Keep that distinct from a real zero-dollar row. + cost: Option, } /// Exact Claude request rows retained for quota-window projection. @@ -468,6 +470,7 @@ struct ClaudeFileScanResult { malformed_lines: u32, incomplete_requests: u32, read_failures: u32, + aggregation_failures: u32, } impl ClaudeFileScanResult { @@ -478,10 +481,16 @@ impl ClaudeFileScanResult { .incomplete_requests .saturating_add(other.incomplete_requests); self.read_failures = self.read_failures.saturating_add(other.read_failures); + self.aggregation_failures = self + .aggregation_failures + .saturating_add(other.aggregation_failures); } fn is_complete(self) -> bool { - self.malformed_lines == 0 && self.incomplete_requests == 0 && self.read_failures == 0 + self.malformed_lines == 0 + && self.incomplete_requests == 0 + && self.read_failures == 0 + && self.aggregation_failures == 0 } } @@ -591,16 +600,21 @@ impl CostScanner { let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); let mut handle_file = |path: &Path| { - let file_result = scan_claude_file_with_pricing( + let mut aggregation_complete = true; + let mut file_result = scan_claude_file_with_pricing( path, &cutoff, &mut seen, cancel, &mut pricing, |record| { - add_claude_record_to_summary(&mut summary, record); + aggregation_complete &= add_claude_record_to_summary(&mut summary, record); }, ); + if !aggregation_complete { + file_result.aggregation_failures = + file_result.aggregation_failures.saturating_add(1); + } if file_result.counted > 0 { summary.sessions_count += 1; } @@ -667,7 +681,8 @@ impl CostScanner { let mut pricing = ClaudeScanPricingResolver::default(); self.walk_claude_files(&projects_dir, &cutoff, cancel, &mut |path| { let mut file_has_usage = false; - let file_result = scan_claude_file_with_pricing( + let mut aggregation_complete = true; + let mut file_result = scan_claude_file_with_pricing( path, &cutoff, &mut seen, @@ -675,9 +690,11 @@ impl CostScanner { &mut pricing, |record| { file_has_usage = true; - add_claude_record_to_summary(&mut summary, record); - add_claude_record_to_daily_costs(&mut daily_cost, record); - add_claude_record_to_daily_tokens(&mut daily_tokens, record); + aggregation_complete &= add_claude_record_to_summary(&mut summary, record); + aggregation_complete &= + add_claude_record_to_daily_costs(&mut daily_cost, record); + aggregation_complete &= + add_claude_record_to_daily_tokens(&mut daily_tokens, record); if let Some(quota_record) = quota_history_record_from_usage(record) { quota_records.push(quota_record); } else { @@ -685,6 +702,10 @@ impl CostScanner { } }, ); + if !aggregation_complete { + file_result.aggregation_failures = + file_result.aggregation_failures.saturating_add(1); + } if file_has_usage { summary.sessions_count += 1; } @@ -1007,7 +1028,7 @@ fn claude_usage_record_from_event_with_pricing( let cache_create_1h = usage.one_hour_cache_creation_tokens(cache_create); let pricing_known = pricing.is_known(model); - let cost = pricing.cost_usd_with_cache_ttl( + let computed_cost = pricing.cost_usd_with_cache_ttl( model, input, cache_create, @@ -1015,6 +1036,7 @@ fn claude_usage_record_from_event_with_pricing( cache_read, output, ); + let cost = computed_cost.is_finite().then_some(computed_cost); Some(ClaudeUsageRecord { model: model.to_string(), @@ -1033,25 +1055,52 @@ fn claude_usage_record_from_event_with_pricing( }) } -fn add_claude_record_to_summary(summary: &mut CostSummary, record: &ClaudeUsageRecord) { +fn add_claude_record_to_summary(summary: &mut CostSummary, record: &ClaudeUsageRecord) -> bool { if !record.pricing_known { summary.unknown_models.insert(record.model.clone()); } - summary.input_tokens += record.input; - summary.output_tokens += record.output; - summary.cached_tokens += record.cache_create + record.cache_read; - summary.total_cost_usd += record.cost; + let mut complete = checked_add_assign(&mut summary.input_tokens, record.input); + complete &= checked_add_assign(&mut summary.output_tokens, record.output); + let cached = record.cache_create.checked_add(record.cache_read); + complete &= cached.is_some_and(|value| checked_add_assign(&mut summary.cached_tokens, value)); - *summary.by_model.entry(record.model.clone()).or_insert(0.0) += record.cost; + if let Some(cost) = record.cost { + complete &= checked_add_finite(&mut summary.total_cost_usd, cost); + complete &= checked_add_finite( + summary.by_model.entry(record.model.clone()).or_insert(0.0), + cost, + ); + } else { + complete = false; + } let model_tokens = summary .by_model_tokens .entry(record.model.clone()) .or_default(); - model_tokens.input_tokens += record.input; - model_tokens.output_tokens += record.output; - model_tokens.cached_tokens += record.cache_create + record.cache_read; + complete &= checked_add_assign(&mut model_tokens.input_tokens, record.input); + complete &= checked_add_assign(&mut model_tokens.output_tokens, record.output); + complete &= + cached.is_some_and(|value| checked_add_assign(&mut model_tokens.cached_tokens, value)); + complete +} + +fn checked_add_assign(total: &mut u64, value: u64) -> bool { + let Some(sum) = total.checked_add(value) else { + return false; + }; + *total = sum; + true +} + +fn checked_add_finite(total: &mut f64, value: f64) -> bool { + let sum = *total + value; + if !value.is_finite() || !sum.is_finite() { + return false; + } + *total = sum; + true } fn quota_history_record_from_usage(record: &ClaudeUsageRecord) -> Option { @@ -1080,9 +1129,9 @@ fn quota_history_record_from_usage(record: &ClaudeUsageRecord) -> Option= 0.0, + cost_is_complete: record.pricing_known && record.cost.is_some_and(|cost| cost >= 0.0), dedup_key, attribution: ClaudeHistoryAttribution::Unavailable, }) @@ -1094,9 +1143,9 @@ fn quota_history_record_from_usage(record: &ClaudeUsageRecord) -> Option>, record: &ClaudeUsageRecord, -) { +) -> bool { let Some(timestamp) = record.timestamp else { - return; + return true; }; let date_str = timestamp .with_timezone(&Local) @@ -1104,8 +1153,18 @@ fn add_claude_record_to_daily_costs( .format("%Y-%m-%d") .to_string(); if let Some(cost) = daily_costs.get_mut(&date_str) { - *cost = Some(cost.unwrap_or(0.0) + record.cost); + let Some(record_cost) = record.cost else { + *cost = None; + return false; + }; + let sum = cost.unwrap_or(0.0) + record_cost; + if !sum.is_finite() { + *cost = None; + return false; + } + *cost = Some(sum); } + true } /// Check if any cost usage sources are available @@ -1294,6 +1353,7 @@ pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)> let cutoff = Utc::now() - Duration::days(days as i64); let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); + let mut aggregation_complete = true; let mut handle_file = |path: &Path| { for_each_claude_usage_record_with_pricing( path, @@ -1302,11 +1362,17 @@ pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)> None, &mut pricing, |record| { - add_claude_record_to_daily_tokens(&mut daily_tokens, record); + aggregation_complete &= + add_claude_record_to_daily_tokens(&mut daily_tokens, record); }, ); }; scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file); + if !aggregation_complete { + covered_days.clear(); + } else { + covered_days.extend(daily_tokens.keys().cloned()); + } } } "pi" => { @@ -1330,7 +1396,9 @@ pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)> // Codex only: the bounded catch-up may not have reached the requested // depth yet. Incomplete = history exists but the oldest quarter of the // window has no scanned day. - let incomplete = if provider == "pi" { + let incomplete = if provider == "claude" { + covered_days.is_empty() + } else if provider == "pi" { // Pi scans are bounded filesystem walks, so a complete parse covers // the requested window even when the roots contain no sessions. covered_days.is_empty() @@ -1349,9 +1417,9 @@ pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)> fn add_claude_record_to_daily_tokens( daily_tokens: &mut HashMap, record: &ClaudeUsageRecord, -) { +) -> bool { let Some(timestamp) = record.timestamp else { - return; + return true; }; let date_str = timestamp .with_timezone(&Local) @@ -1359,6 +1427,10 @@ fn add_claude_record_to_daily_tokens( .format("%Y-%m-%d") .to_string(); if let Some(slot) = daily_tokens.get_mut(&date_str) { - *slot += record.input + record.output; + let Some(tokens) = record.input.checked_add(record.output) else { + return false; + }; + return checked_add_assign(slot, tokens); } + true } diff --git a/rust/src/cost_scanner/claude_pricing.rs b/rust/src/cost_scanner/claude_pricing.rs index 89bcbe138a..e8f3e66973 100644 --- a/rust/src/cost_scanner/claude_pricing.rs +++ b/rust/src/cost_scanner/claude_pricing.rs @@ -139,24 +139,10 @@ impl ClaudeScanPricingResolver { let cache_create_1h = cache_create_1h.min(cache_create); let cache_create_5m = cache_create.saturating_sub(cache_create_1h); - #[allow( - clippy::cast_possible_truncation, - reason = "clamped to i32::MAX before casting" - )] - let clamp = |value: u64| value.min(i32::MAX as u64) as i32; - let resolved = self.resolve(model); let billable = resolved.or_else(|| self.resolve(FALLBACK_CLAUDE_MODEL)); let base = billable - .map(|pricing| { - CostUsagePricing::claude_cost_usd_from_resolution( - pricing, - clamp(input), - clamp(cache_read), - clamp(cache_create_5m), - clamp(output), - ) - }) + .map(|pricing| claude_cost_usd_u64(pricing, input, cache_read, cache_create_5m, output)) .unwrap_or(0.0); let input_rate = billable .map(CostUsagePricing::claude_input_cost_per_token_from_resolution) @@ -165,3 +151,101 @@ impl ClaudeScanPricingResolver { base + (cache_create_1h as f64) * input_rate * 2.0 } } + +/// Price transcript counters without narrowing them to `i32`. Local history is +/// untrusted input and can contain values far above the API's ordinary range; +/// narrowing those values silently understates spend before aggregation gets a +/// chance to mark non-finite results unavailable. +fn claude_cost_usd_u64( + resolution: ClaudePricingResolution, + input: u64, + cache_read: u64, + cache_write: u64, + output: u64, +) -> f64 { + match resolution { + ClaudePricingResolution::BuiltIn(pricing) => { + let tiered = |tokens: u64, base: f64, above: Option| { + let Some(threshold) = pricing.threshold_tokens.map(|value| value.max(0) as u64) + else { + return (tokens as f64) * base; + }; + let Some(above) = above else { + return (tokens as f64) * base; + }; + let below = tokens.min(threshold); + let over = tokens.saturating_sub(threshold); + (below as f64) * base + (over as f64) * above + }; + + tiered( + input, + pricing.input_cost_per_token, + pricing.input_cost_per_token_above_threshold, + ) + tiered( + cache_read, + pricing.cache_read_input_cost_per_token, + pricing.cache_read_input_cost_per_token_above_threshold, + ) + tiered( + cache_write, + pricing.cache_creation_input_cost_per_token, + pricing.cache_creation_input_cost_per_token_above_threshold, + ) + tiered( + output, + pricing.output_cost_per_token, + pricing.output_cost_per_token_above_threshold, + ) + } + ClaudePricingResolution::ModelsDev { + pricing, + threshold_tokens, + } => { + let use_tier = threshold_tokens.is_some_and(|threshold| { + input + .checked_add(cache_read) + .and_then(|value| value.checked_add(cache_write)) + .is_none_or(|total| total > threshold) + }); + let pick = |base: f64, above: Option| { + if use_tier { + above.unwrap_or(base) + } else { + base + } + }; + let input_rate = pick( + pricing.input_cost_per_token, + pricing.input_cost_per_token_above_threshold, + ); + let cache_read_rate = if use_tier { + pricing + .cache_read_input_cost_per_token_above_threshold + .or(pricing.cache_read_input_cost_per_token) + .unwrap_or(input_rate) + } else { + pricing + .cache_read_input_cost_per_token + .unwrap_or(input_rate) + }; + let cache_write_rate = if use_tier { + pricing + .cache_write_input_cost_per_token_above_threshold + .or(pricing.cache_write_input_cost_per_token) + .unwrap_or(input_rate) + } else { + pricing + .cache_write_input_cost_per_token + .unwrap_or(input_rate) + }; + let output_rate = pick( + pricing.output_cost_per_token, + pricing.output_cost_per_token_above_threshold, + ); + + (input as f64) * input_rate + + (cache_read as f64) * cache_read_rate + + (cache_write as f64) * cache_write_rate + + (output as f64) * output_rate + } + } +} diff --git a/rust/src/cost_scanner/tests.rs b/rust/src/cost_scanner/tests.rs index 066e8e2d98..d20e770b26 100644 --- a/rust/src/cost_scanner/tests.rs +++ b/rust/src/cost_scanner/tests.rs @@ -395,7 +395,7 @@ fn counts_claude_usage_once_across_duplicate_records() { assert_eq!(record.output, 50); assert_eq!(record.cache_create, 10); assert_eq!(record.cache_read, 20); - assert!(record.cost > 0.0); + assert!(record.cost.is_some_and(|cost| cost > 0.0)); let cutoff = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") .unwrap() @@ -552,9 +552,17 @@ fn shared_claude_reader_excludes_vertex_rows_but_keeps_anthropic_usage() { let anthropic = format!( r#"{{"type":"assistant","timestamp":"{timestamp}","requestId":"req_anthropic","message":{{"id":"msg_anthropic","model":"claude-sonnet-4-6","usage":{{"input_tokens":10,"output_tokens":5}}}}}}"# ); - let vertex = format!( - r#"{{"type":"assistant","timestamp":"{timestamp}","requestId":"req_vrtx_123","message":{{"id":"msg_vrtx_123","model":"claude-sonnet-4-6","usage":{{"input_tokens":1000,"output_tokens":500}}}}}}"# - ); + let vertex = serde_json::json!({ + "type": "assistant", + "timestamp": timestamp, + "requestId": "req_vrtx_123", + "message": { + "id": "msg_vrtx_123", + "model": "claude-sonnet-4-6", + "usage": {"input_tokens": u64::MAX, "output_tokens": u64::MAX} + } + }) + .to_string(); std::fs::write(&path, format!("{anthropic}\n{vertex}\n")).unwrap(); let cutoff = Utc::now() - Duration::days(30); @@ -569,6 +577,86 @@ fn shared_claude_reader_excludes_vertex_rows_but_keeps_anthropic_usage() { let _removed = std::fs::remove_file(&path); } +#[test] +fn oversized_claude_history_preserves_independent_components_and_fails_closed() { + let first: ClaudeEvent = serde_json::from_str(&format!( + r#"{{"type":"assistant","timestamp":"2026-09-20T12:00:00Z","requestId":"req_overflow_1","message":{{"id":"msg_overflow_1","model":"claude-sonnet-4-6","usage":{{"input_tokens":{},"output_tokens":2}}}}}}"#, + u64::MAX + )) + .unwrap(); + let second: ClaudeEvent = serde_json::from_str( + r#"{"type":"assistant","timestamp":"2026-09-20T12:01:00Z","requestId":"req_overflow_2","message":{"id":"msg_overflow_2","model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":3}}}"#, + ) + .unwrap(); + let first = claude_usage_record_from_event(&first).expect("first usage row"); + let second = claude_usage_record_from_event(&second).expect("second usage row"); + let mut summary = CostSummary::default(); + + assert!(add_claude_record_to_summary(&mut summary, &first)); + assert!(!add_claude_record_to_summary(&mut summary, &second)); + assert_eq!(summary.input_tokens, u64::MAX); + assert_eq!(summary.output_tokens, 5); + assert!(summary.total_cost_usd.is_finite()); + + finalize_claude_summary( + &mut summary, + true, + ClaudeFileScanResult { + counted: 2, + aggregation_failures: 1, + ..ClaudeFileScanResult::default() + }, + false, + ); + assert!(!summary.history_coverage_established); + assert!(!summary.known_zero); +} + +#[test] +fn oversized_single_claude_row_keeps_cost_but_marks_combined_quota_tokens_unknown() { + let event: ClaudeEvent = serde_json::from_str(&format!( + r#"{{"type":"assistant","timestamp":"2026-09-20T12:00:00Z","requestId":"req_combined_overflow","message":{{"id":"msg_combined_overflow","model":"claude-sonnet-4-6","usage":{{"input_tokens":{},"output_tokens":1}}}}}}"#, + u64::MAX + )) + .unwrap(); + let record = claude_usage_record_from_event(&event).expect("usage row"); + let quota = quota_history_record_from_usage(&record).expect("timestamped quota row"); + + assert!(record.cost.is_some_and(f64::is_finite)); + assert_eq!(quota.tokens, None); + assert!(!quota.tokens_are_complete); + assert!(quota.cost_usd.is_some_and(f64::is_finite)); + assert!(quota.cost_is_complete); +} + +#[test] +fn nonfinite_claude_price_is_unknown_instead_of_zero() { + let snapshot = crate::core::ModelsDevPricingSnapshot::from_catalog_json_for_tests( + r#"{ + "anthropic": {"models": {"claude-test-extreme-price": { + "id": "claude-test-extreme-price", "cost": {"input": 1e308, "output": 1} + }}} + }"#, + ) + .expect("pricing fixture"); + let mut pricing = ClaudeScanPricingResolver::with_snapshot(snapshot); + let event: ClaudeEvent = serde_json::from_str(&format!( + r#"{{"type":"assistant","timestamp":"2026-09-20T12:00:00Z","requestId":"req_nonfinite","message":{{"id":"msg_nonfinite","model":"claude-test-extreme-price","usage":{{"input_tokens":{},"output_tokens":1}}}}}}"#, + u64::MAX + )) + .unwrap(); + let record = + claude_usage_record_from_event_with_pricing(&event, &mut pricing).expect("usage row"); + let mut summary = CostSummary::default(); + + assert_eq!(record.cost, None); + assert!(!add_claude_record_to_summary(&mut summary, &record)); + assert_eq!(summary.input_tokens, u64::MAX); + assert_eq!(summary.output_tokens, 1); + assert_eq!(summary.total_cost_usd, 0.0); + assert!(!summary.known_zero); +} + fn claude_transcript_line( timestamp: &str, request_key: &str, From 6d5763ace04c95fb44bf5cf6c922bd6644f239e6 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:44:35 +0700 Subject: [PATCH 5/8] Centralize unsigned Claude pricing --- rust/src/core/cost_pricing/claude.rs | 80 +++++++++++++++--- rust/src/cost_scanner/claude_pricing.rs | 108 ++---------------------- 2 files changed, 78 insertions(+), 110 deletions(-) diff --git a/rust/src/core/cost_pricing/claude.rs b/rust/src/core/cost_pricing/claude.rs index 8cdab4f34d..05429d57a9 100644 --- a/rust/src/core/cost_pricing/claude.rs +++ b/rust/src/core/cost_pricing/claude.rs @@ -86,20 +86,38 @@ impl CostUsagePricing { cache_read_input_tokens: i32, cache_creation_input_tokens: i32, output_tokens: i32, + ) -> f64 { + Self::claude_cost_usd_u64_from_resolution( + resolution, + u64::try_from(input_tokens).unwrap_or(0), + u64::try_from(cache_read_input_tokens).unwrap_or(0), + u64::try_from(cache_creation_input_tokens).unwrap_or(0), + u64::try_from(output_tokens).unwrap_or(0), + ) + } + + /// Calculate cost from a resolved Claude pricing source without narrowing + /// untrusted local-history counters to the API-oriented signed type. + pub(crate) fn claude_cost_usd_u64_from_resolution( + resolution: ClaudePricingResolution, + input_tokens: u64, + cache_read_input_tokens: u64, + cache_creation_input_tokens: u64, + output_tokens: u64, ) -> f64 { match resolution { ClaudePricingResolution::BuiltIn(pricing) => { fn tiered( - tokens: i32, + tokens: u64, base: f64, above: Option, threshold: Option, ) -> f64 { - let tokens = tokens.max(0); match (threshold, above) { (Some(thresh), Some(above_rate)) => { + let thresh = u64::try_from(thresh).unwrap_or(0); let below = tokens.min(thresh); - let over = (tokens - thresh).max(0); + let over = tokens.saturating_sub(thresh); (below as f64) * base + (over as f64) * above_rate } _ => (tokens as f64) * base, @@ -131,14 +149,54 @@ impl CostUsagePricing { ClaudePricingResolution::ModelsDev { pricing, threshold_tokens, - } => claude_routed_pricing::cost_usd_from_pricing_with_threshold( - pricing, - threshold_tokens, - input_tokens, - cache_read_input_tokens, - cache_creation_input_tokens, - output_tokens, - ), + } => { + let use_tier = threshold_tokens.is_some_and(|threshold| { + input_tokens + .checked_add(cache_read_input_tokens) + .and_then(|value| value.checked_add(cache_creation_input_tokens)) + .is_none_or(|total| total > threshold) + }); + let pick = |base: f64, above: Option| { + if use_tier { + above.unwrap_or(base) + } else { + base + } + }; + let input_rate = pick( + pricing.input_cost_per_token, + pricing.input_cost_per_token_above_threshold, + ); + let cache_read_rate = if use_tier { + pricing + .cache_read_input_cost_per_token_above_threshold + .or(pricing.cache_read_input_cost_per_token) + .unwrap_or(input_rate) + } else { + pricing + .cache_read_input_cost_per_token + .unwrap_or(input_rate) + }; + let cache_write_rate = if use_tier { + pricing + .cache_write_input_cost_per_token_above_threshold + .or(pricing.cache_write_input_cost_per_token) + .unwrap_or(input_rate) + } else { + pricing + .cache_write_input_cost_per_token + .unwrap_or(input_rate) + }; + let output_rate = pick( + pricing.output_cost_per_token, + pricing.output_cost_per_token_above_threshold, + ); + + (input_tokens as f64) * input_rate + + (cache_read_input_tokens as f64) * cache_read_rate + + (cache_creation_input_tokens as f64) * cache_write_rate + + (output_tokens as f64) * output_rate + } } } diff --git a/rust/src/cost_scanner/claude_pricing.rs b/rust/src/cost_scanner/claude_pricing.rs index e8f3e66973..5d3d251036 100644 --- a/rust/src/cost_scanner/claude_pricing.rs +++ b/rust/src/cost_scanner/claude_pricing.rs @@ -142,7 +142,15 @@ impl ClaudeScanPricingResolver { let resolved = self.resolve(model); let billable = resolved.or_else(|| self.resolve(FALLBACK_CLAUDE_MODEL)); let base = billable - .map(|pricing| claude_cost_usd_u64(pricing, input, cache_read, cache_create_5m, output)) + .map(|pricing| { + CostUsagePricing::claude_cost_usd_u64_from_resolution( + pricing, + input, + cache_read, + cache_create_5m, + output, + ) + }) .unwrap_or(0.0); let input_rate = billable .map(CostUsagePricing::claude_input_cost_per_token_from_resolution) @@ -151,101 +159,3 @@ impl ClaudeScanPricingResolver { base + (cache_create_1h as f64) * input_rate * 2.0 } } - -/// Price transcript counters without narrowing them to `i32`. Local history is -/// untrusted input and can contain values far above the API's ordinary range; -/// narrowing those values silently understates spend before aggregation gets a -/// chance to mark non-finite results unavailable. -fn claude_cost_usd_u64( - resolution: ClaudePricingResolution, - input: u64, - cache_read: u64, - cache_write: u64, - output: u64, -) -> f64 { - match resolution { - ClaudePricingResolution::BuiltIn(pricing) => { - let tiered = |tokens: u64, base: f64, above: Option| { - let Some(threshold) = pricing.threshold_tokens.map(|value| value.max(0) as u64) - else { - return (tokens as f64) * base; - }; - let Some(above) = above else { - return (tokens as f64) * base; - }; - let below = tokens.min(threshold); - let over = tokens.saturating_sub(threshold); - (below as f64) * base + (over as f64) * above - }; - - tiered( - input, - pricing.input_cost_per_token, - pricing.input_cost_per_token_above_threshold, - ) + tiered( - cache_read, - pricing.cache_read_input_cost_per_token, - pricing.cache_read_input_cost_per_token_above_threshold, - ) + tiered( - cache_write, - pricing.cache_creation_input_cost_per_token, - pricing.cache_creation_input_cost_per_token_above_threshold, - ) + tiered( - output, - pricing.output_cost_per_token, - pricing.output_cost_per_token_above_threshold, - ) - } - ClaudePricingResolution::ModelsDev { - pricing, - threshold_tokens, - } => { - let use_tier = threshold_tokens.is_some_and(|threshold| { - input - .checked_add(cache_read) - .and_then(|value| value.checked_add(cache_write)) - .is_none_or(|total| total > threshold) - }); - let pick = |base: f64, above: Option| { - if use_tier { - above.unwrap_or(base) - } else { - base - } - }; - let input_rate = pick( - pricing.input_cost_per_token, - pricing.input_cost_per_token_above_threshold, - ); - let cache_read_rate = if use_tier { - pricing - .cache_read_input_cost_per_token_above_threshold - .or(pricing.cache_read_input_cost_per_token) - .unwrap_or(input_rate) - } else { - pricing - .cache_read_input_cost_per_token - .unwrap_or(input_rate) - }; - let cache_write_rate = if use_tier { - pricing - .cache_write_input_cost_per_token_above_threshold - .or(pricing.cache_write_input_cost_per_token) - .unwrap_or(input_rate) - } else { - pricing - .cache_write_input_cost_per_token - .unwrap_or(input_rate) - }; - let output_rate = pick( - pricing.output_cost_per_token, - pricing.output_cost_per_token_above_threshold, - ); - - (input as f64) * input_rate - + (cache_read as f64) * cache_read_rate - + (cache_write as f64) * cache_write_rate - + (output as f64) * output_rate - } - } -} From cce5b27c5eef2b97350bd648792b60e46a162e28 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:45:50 +0700 Subject: [PATCH 6/8] Preserve uncertainty in Claude history scans --- rust/src/core/claude_routed_pricing.rs | 102 ++++++++---- rust/src/core/cost_pricing/claude.rs | 138 ++++++++++------ rust/src/cost_scanner.rs | 213 +++++++++++++++---------- rust/src/cost_scanner/tests.rs | 97 +++++++++++ 4 files changed, 386 insertions(+), 164 deletions(-) diff --git a/rust/src/core/claude_routed_pricing.rs b/rust/src/core/claude_routed_pricing.rs index 1076992c9f..1f6a750b69 100644 --- a/rust/src/core/claude_routed_pricing.rs +++ b/rust/src/core/claude_routed_pricing.rs @@ -147,13 +147,52 @@ pub fn cost_usd_from_pricing_with_threshold( cache_write: i32, output: i32, ) -> f64 { - let input = input.max(0); - let cache_read = cache_read.max(0); - let cache_write = cache_write.max(0); - let output = output.max(0); + cost_usd_from_u64_counts_with_threshold( + pricing, + threshold_tokens, + input.max(0) as u64, + cache_read.max(0) as u64, + cache_write.max(0) as u64, + output.max(0) as u64, + ) +} + +/// Calculate routed cost for local history counters without narrowing them to +/// the signed API token-count type. +pub(crate) fn cost_usd_from_u64_counts_with_threshold( + pricing: models_dev_pricing::DynamicModelPricing, + threshold_tokens: Option, + input: u64, + cache_read: u64, + cache_write: u64, + output: u64, +) -> f64 { let use_tier = threshold_tokens.is_some_and(|threshold| { - (input as u64) + (cache_read as u64) + (cache_write as u64) > threshold + input + .checked_add(cache_read) + .and_then(|total| total.checked_add(cache_write)) + .is_none_or(|total| total > threshold) }); + let rates = selected_cost_rates(pricing, use_tier); + + (input as f64) * rates.input + + (cache_read as f64) * rates.cache_read + + (cache_write as f64) * rates.cache_write + + (output as f64) * rates.output +} + +#[derive(Debug, Clone, Copy)] +struct SelectedCostRates { + input: f64, + cache_read: f64, + cache_write: f64, + output: f64, +} + +fn selected_cost_rates( + pricing: models_dev_pricing::DynamicModelPricing, + use_tier: bool, +) -> SelectedCostRates { let pick = |base: f64, above: Option| { if use_tier { above.unwrap_or(base) @@ -161,39 +200,34 @@ pub fn cost_usd_from_pricing_with_threshold( base } }; - let input_rate = pick( + let input = pick( pricing.input_cost_per_token, pricing.input_cost_per_token_above_threshold, ); - let cache_read_rate = if use_tier { - pricing - .cache_read_input_cost_per_token_above_threshold - .or(pricing.cache_read_input_cost_per_token) - .unwrap_or(input_rate) - } else { - pricing - .cache_read_input_cost_per_token - .unwrap_or(input_rate) - }; - let cache_write_rate = if use_tier { - pricing - .cache_write_input_cost_per_token_above_threshold - .or(pricing.cache_write_input_cost_per_token) - .unwrap_or(input_rate) - } else { - pricing - .cache_write_input_cost_per_token - .unwrap_or(input_rate) - }; - let output_rate = pick( - pricing.output_cost_per_token, - pricing.output_cost_per_token_above_threshold, - ); - (input as f64) * input_rate - + (cache_read as f64) * cache_read_rate - + (cache_write as f64) * cache_write_rate - + (output as f64) * output_rate + SelectedCostRates { + input, + cache_read: if use_tier { + pricing + .cache_read_input_cost_per_token_above_threshold + .or(pricing.cache_read_input_cost_per_token) + .unwrap_or(input) + } else { + pricing.cache_read_input_cost_per_token.unwrap_or(input) + }, + cache_write: if use_tier { + pricing + .cache_write_input_cost_per_token_above_threshold + .or(pricing.cache_write_input_cost_per_token) + .unwrap_or(input) + } else { + pricing.cache_write_input_cost_per_token.unwrap_or(input) + }, + output: pick( + pricing.output_cost_per_token, + pricing.output_cost_per_token_above_threshold, + ), + } } fn effective_threshold(provider: &str, model: &str, catalog_threshold: Option) -> Option { diff --git a/rust/src/core/cost_pricing/claude.rs b/rust/src/core/cost_pricing/claude.rs index 05429d57a9..fd06061eeb 100644 --- a/rust/src/core/cost_pricing/claude.rs +++ b/rust/src/core/cost_pricing/claude.rs @@ -149,54 +149,14 @@ impl CostUsagePricing { ClaudePricingResolution::ModelsDev { pricing, threshold_tokens, - } => { - let use_tier = threshold_tokens.is_some_and(|threshold| { - input_tokens - .checked_add(cache_read_input_tokens) - .and_then(|value| value.checked_add(cache_creation_input_tokens)) - .is_none_or(|total| total > threshold) - }); - let pick = |base: f64, above: Option| { - if use_tier { - above.unwrap_or(base) - } else { - base - } - }; - let input_rate = pick( - pricing.input_cost_per_token, - pricing.input_cost_per_token_above_threshold, - ); - let cache_read_rate = if use_tier { - pricing - .cache_read_input_cost_per_token_above_threshold - .or(pricing.cache_read_input_cost_per_token) - .unwrap_or(input_rate) - } else { - pricing - .cache_read_input_cost_per_token - .unwrap_or(input_rate) - }; - let cache_write_rate = if use_tier { - pricing - .cache_write_input_cost_per_token_above_threshold - .or(pricing.cache_write_input_cost_per_token) - .unwrap_or(input_rate) - } else { - pricing - .cache_write_input_cost_per_token - .unwrap_or(input_rate) - }; - let output_rate = pick( - pricing.output_cost_per_token, - pricing.output_cost_per_token_above_threshold, - ); - - (input_tokens as f64) * input_rate - + (cache_read_input_tokens as f64) * cache_read_rate - + (cache_creation_input_tokens as f64) * cache_write_rate - + (output_tokens as f64) * output_rate - } + } => claude_routed_pricing::cost_usd_from_u64_counts_with_threshold( + pricing, + threshold_tokens, + input_tokens, + cache_read_input_tokens, + cache_creation_input_tokens, + output_tokens, + ), } } @@ -254,3 +214,85 @@ impl CostUsagePricing { claude_routed_pricing::input_cost_per_token(model, Self::normalize_claude_model(model)) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn routed_pricing() -> (models_dev_pricing::DynamicModelPricing, Option) { + let snapshot = models_dev_pricing::ModelsDevPricingSnapshot::from_catalog_json_for_tests( + r#"{ + "anthropic": {"models": {"threshold-fixture": {"id": "threshold-fixture", "cost": { + "input": 2, "output": 4, "cache_read": 0.25, "cache_write": 3, + "context_over_200k": {"input": 7, "output": 11, "cache_read": 0.5, "cache_write": 9} + }}}} + }"#, + ) + .expect("pricing fixture"); + let resolution = CostUsagePricing::resolve_claude_pricing( + "anthropic/threshold-fixture", + "anthropic/threshold-fixture", + Some(&snapshot), + ) + .expect("Models.dev pricing"); + let ClaudePricingResolution::ModelsDev { + pricing, + threshold_tokens, + } = resolution + else { + panic!("expected Models.dev pricing"); + }; + (pricing, threshold_tokens) + } + + #[test] + fn models_dev_u64_cost_uses_routed_rates_for_every_token_field() { + let (pricing, threshold) = routed_pricing(); + assert_eq!(threshold, Some(200_000)); + + for (input, cache_read, cache_write, output, expected_usd) in [ + (10_000, 2_000, 1_000, 500, 0.0255), + (199_999, 1, 0, 25, 0.400_098_25), + (200_000, 1, 0, 25, 1.400_275_5), + (220_000, 10_000, 2_000, 50, 1.563_55), + ] { + let actual = CostUsagePricing::claude_cost_usd_u64_from_resolution( + ClaudePricingResolution::ModelsDev { + pricing, + threshold_tokens: threshold, + }, + input, + cache_read, + cache_write, + output, + ); + let routed = claude_routed_pricing::cost_usd_from_pricing_with_threshold( + pricing, + threshold, + input as i32, + cache_read as i32, + cache_write as i32, + output as i32, + ); + assert!((actual - expected_usd).abs() < 1e-12); + assert!((actual - routed).abs() < 1e-12); + } + } + + #[test] + fn models_dev_u64_counter_overflow_selects_above_threshold_rates() { + let (pricing, threshold) = routed_pricing(); + let actual = CostUsagePricing::claude_cost_usd_u64_from_resolution( + ClaudePricingResolution::ModelsDev { + pricing, + threshold_tokens: threshold, + }, + u64::MAX, + 1, + 0, + 0, + ); + let expected = (u64::MAX as f64) * 7e-6 + 0.5e-6; + assert!((actual - expected).abs() < 1e-6); + } +} diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 2b730cdc01..3046996972 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -608,6 +608,7 @@ impl CostScanner { cancel, &mut pricing, |record| { + aggregation_complete &= record.timestamp.is_some(); aggregation_complete &= add_claude_record_to_summary(&mut summary, record); }, ); @@ -620,7 +621,12 @@ impl CostScanner { } claude_scan.absorb(file_result); }; - self.walk_claude_files(&projects_dir, &cutoff, cancel, &mut handle_file); + let traversal_read_failures = + self.walk_claude_files(&projects_dir, &cutoff, cancel, &mut handle_file); + drop(handle_file); + claude_scan.read_failures = claude_scan + .read_failures + .saturating_add(traversal_read_failures); } // OMP / pi-compatible anthropic rows, deduped across shared files. @@ -675,42 +681,45 @@ impl CostScanner { let mut quota_records = Vec::new(); let mut scan_result = ClaudeFileScanResult::default(); - let mut missing_timestamp = false; if projects_dir.exists() { let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); - self.walk_claude_files(&projects_dir, &cutoff, cancel, &mut |path| { - let mut file_has_usage = false; - let mut aggregation_complete = true; - let mut file_result = scan_claude_file_with_pricing( - path, - &cutoff, - &mut seen, - cancel, - &mut pricing, - |record| { - file_has_usage = true; - aggregation_complete &= add_claude_record_to_summary(&mut summary, record); - aggregation_complete &= - add_claude_record_to_daily_costs(&mut daily_cost, record); - aggregation_complete &= - add_claude_record_to_daily_tokens(&mut daily_tokens, record); - if let Some(quota_record) = quota_history_record_from_usage(record) { - quota_records.push(quota_record); - } else { - missing_timestamp = true; - } - }, - ); - if !aggregation_complete { - file_result.aggregation_failures = - file_result.aggregation_failures.saturating_add(1); - } - if file_has_usage { - summary.sessions_count += 1; - } - scan_result.absorb(file_result); - }); + let traversal_read_failures = + self.walk_claude_files(&projects_dir, &cutoff, cancel, &mut |path| { + let mut file_has_usage = false; + let mut aggregation_complete = true; + let mut file_result = scan_claude_file_with_pricing( + path, + &cutoff, + &mut seen, + cancel, + &mut pricing, + |record| { + file_has_usage = true; + aggregation_complete &= record.timestamp.is_some(); + aggregation_complete &= + add_claude_record_to_summary(&mut summary, record); + aggregation_complete &= + add_claude_record_to_daily_costs(&mut daily_cost, record); + aggregation_complete &= + add_claude_record_to_daily_tokens(&mut daily_tokens, record); + if let Some(quota_record) = quota_history_record_from_usage(record) { + quota_records.push(quota_record); + } + }, + ); + if !aggregation_complete { + file_result.aggregation_failures = + file_result.aggregation_failures.saturating_add(1); + } + if file_has_usage { + summary.sessions_count += 1; + } + scan_result.absorb(file_result); + }); + scan_result.read_failures = scan_result + .read_failures + .saturating_add(traversal_read_failures); } crate::pi_session_cost::scan_pi_compatible_into( @@ -721,10 +730,7 @@ impl CostScanner { &mut HashSet::new(), ); - let complete = projects_dir.exists() - && !is_cancelled(cancel) - && scan_result.is_complete() - && !missing_timestamp; + let complete = projects_dir.exists() && !is_cancelled(cancel) && scan_result.is_complete(); finalize_claude_summary( &mut summary, projects_dir.exists(), @@ -822,36 +828,52 @@ impl CostScanner { cutoff: &DateTime, cancel: Option<&AtomicBool>, on_file: &mut F, - ) where + ) -> u32 + where F: FnMut(&Path), { if is_cancelled(cancel) { - return; + return 0; } let entries = match fs::read_dir(dir) { Ok(e) => e, - Err(_) => return, + Err(_) => return 1, }; - for entry in entries.flatten() { + let mut read_failures = 0u32; + for entry in entries { if is_cancelled(cancel) { break; } + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + read_failures = read_failures.saturating_add(1); + continue; + } + }; let path = entry.path(); - if path.is_dir() { - self.walk_claude_files(&path, cutoff, cancel, on_file); - } else if path.extension().is_some_and(|e| e == "jsonl") { - // Check file modification time - if let Ok(metadata) = fs::metadata(&path) - && let Ok(modified) = metadata.modified() - { - let modified_dt: DateTime = modified.into(); - if modified_dt >= *cutoff { - on_file(&path); + match fs::metadata(&path) { + Ok(metadata) if metadata.is_dir() => { + read_failures = read_failures + .saturating_add(self.walk_claude_files(&path, cutoff, cancel, on_file)); + } + Ok(metadata) if path.extension().is_some_and(|e| e == "jsonl") => { + match metadata.modified() { + Ok(modified) => { + let modified_dt: DateTime = modified.into(); + if modified_dt >= *cutoff { + on_file(&path); + } + } + Err(_) => read_failures = read_failures.saturating_add(1), } } + Ok(_) => {} + Err(_) => read_failures = read_failures.saturating_add(1), } } + read_failures } } @@ -875,20 +897,6 @@ where scan_claude_file_with_pricing(path, cutoff, seen, cancel, &mut pricing, on_record).counted } -fn for_each_claude_usage_record_with_pricing( - path: &Path, - cutoff: &DateTime, - seen: &mut HashSet, - cancel: Option<&AtomicBool>, - pricing: &mut ClaudeScanPricingResolver, - on_record: F, -) -> usize -where - F: FnMut(&ClaudeUsageRecord), -{ - scan_claude_file_with_pricing(path, cutoff, seen, cancel, pricing, on_record).counted -} - fn scan_claude_file_with_pricing( path: &Path, cutoff: &DateTime, @@ -1248,19 +1256,31 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option< let mut pricing = ClaudeScanPricingResolver::default(); let mut claude_scan = ClaudeFileScanResult::default(); let mut handle_file = |path: &Path| { - let file_result = scan_claude_file_with_pricing( + let mut aggregation_complete = true; + let mut file_result = scan_claude_file_with_pricing( path, &cutoff, &mut seen, None, &mut pricing, |record| { - add_claude_record_to_daily_costs(&mut daily_costs, record); + aggregation_complete &= record.timestamp.is_some(); + aggregation_complete &= + add_claude_record_to_daily_costs(&mut daily_costs, record); }, ); + if !aggregation_complete { + file_result.aggregation_failures = + file_result.aggregation_failures.saturating_add(1); + } claude_scan.absorb(file_result); }; - scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file); + let traversal_read_failures = + scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file); + drop(handle_file); + claude_scan.read_failures = claude_scan + .read_failures + .saturating_add(traversal_read_failures); if claude_scan.is_complete() { for slot in daily_costs.values_mut() { if slot.is_none() { @@ -1353,26 +1373,24 @@ pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)> let cutoff = Utc::now() - Duration::days(days as i64); let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); - let mut aggregation_complete = true; + let mut claude_scan = ClaudeFileScanResult::default(); let mut handle_file = |path: &Path| { - for_each_claude_usage_record_with_pricing( + let file_result = scan_claude_file_for_daily_tokens( path, &cutoff, &mut seen, - None, &mut pricing, - |record| { - aggregation_complete &= - add_claude_record_to_daily_tokens(&mut daily_tokens, record); - }, + &mut daily_tokens, ); + claude_scan.absorb(file_result); }; - scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file); - if !aggregation_complete { - covered_days.clear(); - } else { - covered_days.extend(daily_tokens.keys().cloned()); - } + let traversal_read_failures = + scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file); + drop(handle_file); + claude_scan.read_failures = claude_scan + .read_failures + .saturating_add(traversal_read_failures); + mark_claude_daily_token_coverage(&mut covered_days, &daily_tokens, claude_scan); } } "pi" => { @@ -1434,3 +1452,34 @@ fn add_claude_record_to_daily_tokens( } true } + +fn scan_claude_file_for_daily_tokens( + path: &Path, + cutoff: &DateTime, + seen: &mut HashSet, + pricing: &mut ClaudeScanPricingResolver, + daily_tokens: &mut HashMap, +) -> ClaudeFileScanResult { + let mut aggregation_failures = 0u32; + let mut result = scan_claude_file_with_pricing(path, cutoff, seen, None, pricing, |record| { + if record.timestamp.is_none() || !add_claude_record_to_daily_tokens(daily_tokens, record) { + aggregation_failures = aggregation_failures.saturating_add(1); + } + }); + result.aggregation_failures = result + .aggregation_failures + .saturating_add(aggregation_failures); + result +} + +fn mark_claude_daily_token_coverage( + covered_days: &mut HashSet, + daily_tokens: &HashMap, + scan_result: ClaudeFileScanResult, +) { + if scan_result.is_complete() { + covered_days.extend(daily_tokens.keys().cloned()); + } else { + covered_days.clear(); + } +} diff --git a/rust/src/cost_scanner/tests.rs b/rust/src/cost_scanner/tests.rs index d20e770b26..221f9d2b8a 100644 --- a/rust/src/cost_scanner/tests.rs +++ b/rust/src/cost_scanner/tests.rs @@ -495,6 +495,103 @@ fn malformed_claude_history_stays_unknown_while_valid_empty_history_is_known_zer assert!(!malformed_summary.known_zero); } +#[test] +fn claude_daily_token_coverage_requires_a_complete_valid_scan() { + let root = tempfile::tempdir().unwrap(); + let cutoff = Utc::now() - Duration::days(1); + let valid_path = root.path().join("valid.jsonl"); + let timestamp = Utc::now() - Duration::hours(1); + let today = timestamp + .with_timezone(&Local) + .date_naive() + .format("%Y-%m-%d") + .to_string(); + std::fs::write( + &valid_path, + format!( + "{}\n", + claude_transcript_line( + ×tamp.to_rfc3339(), + "requestId", + "req_valid", + "msg_valid" + ) + ), + ) + .unwrap(); + + let mut valid_tokens = HashMap::from([(today.clone(), 0)]); + let valid_result = scan_claude_file_for_daily_tokens( + &valid_path, + &cutoff, + &mut HashSet::new(), + &mut ClaudeScanPricingResolver::default(), + &mut valid_tokens, + ); + assert!(valid_result.is_complete()); + let mut covered_days = HashSet::new(); + mark_claude_daily_token_coverage(&mut covered_days, &valid_tokens, valid_result); + assert!(covered_days.contains(&today)); + + let assert_uncovered = |path: &Path| { + let mut daily_tokens = HashMap::from([(today.clone(), 0)]); + let result = scan_claude_file_for_daily_tokens( + path, + &cutoff, + &mut HashSet::new(), + &mut ClaudeScanPricingResolver::default(), + &mut daily_tokens, + ); + assert!(!result.is_complete()); + let mut covered_days = HashSet::from(["stale-coverage".to_string()]); + mark_claude_daily_token_coverage(&mut covered_days, &daily_tokens, result); + assert!(covered_days.is_empty()); + result + }; + + let malformed_path = root.path().join("malformed.jsonl"); + std::fs::write(&malformed_path, b"{malformed\n").unwrap(); + assert_eq!(assert_uncovered(&malformed_path).malformed_lines, 1); + + let incomplete_path = root.path().join("incomplete.jsonl"); + std::fs::write( + &incomplete_path, + r#"{"type":"assistant","message":{"id":"msg_preliminary","model":"gpt-5.6-sol","stop_reason":null,"usage":{"input_tokens":1000}}}"#, + ) + .unwrap(); + assert_eq!(assert_uncovered(&incomplete_path).incomplete_requests, 1); + + let missing_timestamp_path = root.path().join("missing-timestamp.jsonl"); + std::fs::write( + &missing_timestamp_path, + r#"{"type":"assistant","requestId":"req_no_timestamp","message":{"id":"msg_no_timestamp","model":"claude-sonnet-4-6","usage":{"input_tokens":1000,"output_tokens":500}}}"#, + ) + .unwrap(); + assert_eq!( + assert_uncovered(&missing_timestamp_path).aggregation_failures, + 1 + ); + + let unreadable_path = root.path().join("missing.jsonl"); + assert_eq!(assert_uncovered(&unreadable_path).read_failures, 1); + + let scanner = CostScanner::new(1); + let missing_directory = root.path().join("missing-directory"); + let traversal_read_failures = + scanner.walk_claude_files(&missing_directory, &cutoff, None, &mut |_| {}); + assert_eq!(traversal_read_failures, 1); + let mut covered_days = HashSet::from([today]); + mark_claude_daily_token_coverage( + &mut covered_days, + &valid_tokens, + ClaudeFileScanResult { + read_failures: traversal_read_failures, + ..ClaudeFileScanResult::default() + }, + ); + assert!(covered_days.is_empty()); +} + #[test] fn classifies_vertex_ai_claude_metadata_without_changing_anthropic_rows() { let cases = [ From 69398db13302815d4b847803a1dfba6fff3490ba Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:07:01 +0700 Subject: [PATCH 7/8] Avoid Clippy warnings in Claude history scanning --- rust/src/core/cost_pricing/claude.rs | 10 +-- rust/src/cost_scanner.rs | 117 ++++++++++++++------------- 2 files changed, 64 insertions(+), 63 deletions(-) diff --git a/rust/src/core/cost_pricing/claude.rs b/rust/src/core/cost_pricing/claude.rs index fd06061eeb..32e4aa4e36 100644 --- a/rust/src/core/cost_pricing/claude.rs +++ b/rust/src/core/cost_pricing/claude.rs @@ -266,13 +266,13 @@ mod tests { cache_write, output, ); - let routed = claude_routed_pricing::cost_usd_from_pricing_with_threshold( + let routed = claude_routed_pricing::cost_usd_from_u64_counts_with_threshold( pricing, threshold, - input as i32, - cache_read as i32, - cache_write as i32, - output as i32, + input, + cache_read, + cache_write, + output, ); assert!((actual - expected_usd).abs() < 1e-12); assert!((actual - routed).abs() < 1e-12); diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 3046996972..f936ec34c7 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -599,31 +599,32 @@ impl CostScanner { if projects_dir.exists() { let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); - let mut handle_file = |path: &Path| { - let mut aggregation_complete = true; - let mut file_result = scan_claude_file_with_pricing( - path, - &cutoff, - &mut seen, - cancel, - &mut pricing, - |record| { - aggregation_complete &= record.timestamp.is_some(); - aggregation_complete &= add_claude_record_to_summary(&mut summary, record); - }, - ); - if !aggregation_complete { - file_result.aggregation_failures = - file_result.aggregation_failures.saturating_add(1); - } - if file_result.counted > 0 { - summary.sessions_count += 1; - } - claude_scan.absorb(file_result); + let traversal_read_failures = { + let mut handle_file = |path: &Path| { + let mut aggregation_complete = true; + let mut file_result = scan_claude_file_with_pricing( + path, + &cutoff, + &mut seen, + cancel, + &mut pricing, + |record| { + aggregation_complete &= record.timestamp.is_some(); + aggregation_complete &= + add_claude_record_to_summary(&mut summary, record); + }, + ); + if !aggregation_complete { + file_result.aggregation_failures = + file_result.aggregation_failures.saturating_add(1); + } + if file_result.counted > 0 { + summary.sessions_count += 1; + } + claude_scan.absorb(file_result); + }; + self.walk_claude_files(&projects_dir, &cutoff, cancel, &mut handle_file) }; - let traversal_read_failures = - self.walk_claude_files(&projects_dir, &cutoff, cancel, &mut handle_file); - drop(handle_file); claude_scan.read_failures = claude_scan .read_failures .saturating_add(traversal_read_failures); @@ -1255,29 +1256,29 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option< let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); let mut claude_scan = ClaudeFileScanResult::default(); - let mut handle_file = |path: &Path| { - let mut aggregation_complete = true; - let mut file_result = scan_claude_file_with_pricing( - path, - &cutoff, - &mut seen, - None, - &mut pricing, - |record| { - aggregation_complete &= record.timestamp.is_some(); - aggregation_complete &= - add_claude_record_to_daily_costs(&mut daily_costs, record); - }, - ); - if !aggregation_complete { - file_result.aggregation_failures = - file_result.aggregation_failures.saturating_add(1); - } - claude_scan.absorb(file_result); + let traversal_read_failures = { + let mut handle_file = |path: &Path| { + let mut aggregation_complete = true; + let mut file_result = scan_claude_file_with_pricing( + path, + &cutoff, + &mut seen, + None, + &mut pricing, + |record| { + aggregation_complete &= record.timestamp.is_some(); + aggregation_complete &= + add_claude_record_to_daily_costs(&mut daily_costs, record); + }, + ); + if !aggregation_complete { + file_result.aggregation_failures = + file_result.aggregation_failures.saturating_add(1); + } + claude_scan.absorb(file_result); + }; + scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file) }; - let traversal_read_failures = - scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file); - drop(handle_file); claude_scan.read_failures = claude_scan .read_failures .saturating_add(traversal_read_failures); @@ -1374,19 +1375,19 @@ pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)> let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); let mut claude_scan = ClaudeFileScanResult::default(); - let mut handle_file = |path: &Path| { - let file_result = scan_claude_file_for_daily_tokens( - path, - &cutoff, - &mut seen, - &mut pricing, - &mut daily_tokens, - ); - claude_scan.absorb(file_result); + let traversal_read_failures = { + let mut handle_file = |path: &Path| { + let file_result = scan_claude_file_for_daily_tokens( + path, + &cutoff, + &mut seen, + &mut pricing, + &mut daily_tokens, + ); + claude_scan.absorb(file_result); + }; + scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file) }; - let traversal_read_failures = - scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file); - drop(handle_file); claude_scan.read_failures = claude_scan .read_failures .saturating_add(traversal_read_failures); From 4f5a6614e40b26cee7460c5ddc9607f250e04982 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:19:30 +0700 Subject: [PATCH 8/8] Test Claude daily history public coverage --- rust/src/cost_scanner/tests.rs | 54 ++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/rust/src/cost_scanner/tests.rs b/rust/src/cost_scanner/tests.rs index 63e5478025..219aed2e48 100644 --- a/rust/src/cost_scanner/tests.rs +++ b/rust/src/cost_scanner/tests.rs @@ -592,6 +592,60 @@ fn claude_daily_token_coverage_requires_a_complete_valid_scan() { assert!(covered_days.is_empty()); } +#[test] +fn public_claude_daily_token_dispatch_reports_incomplete_fixture_scans() { + const CHILD_MARKER: &str = "CODEXBAR_CLAUDE_DAILY_TOKEN_TEST_CHILD"; + const CHILD_DONE: &str = "isolated Claude daily-history fixture verified"; + if std::env::var_os(CHILD_MARKER).is_some() { + let config_dir = std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .expect("child receives isolated Claude config directory"); + let projects_dir = config_dir.join("projects"); + let project_dir = projects_dir.join("fixture-project"); + let (complete_history, incomplete) = get_daily_token_history("claude", 1); + assert!(!incomplete, "valid fixture scan should establish coverage"); + assert!(complete_history.iter().any(|(_, tokens)| *tokens > 0)); + + std::fs::write(project_dir.join("malformed.jsonl"), b"{malformed\n").unwrap(); + let (partial_history, incomplete) = get_daily_token_history("claude", 1); + assert!( + incomplete, + "malformed fixture should leave coverage incomplete" + ); + assert_eq!(partial_history, complete_history); + println!("{CHILD_DONE}"); + return; + } + + let config_dir = tempfile::tempdir().unwrap(); + let project_dir = config_dir.path().join("projects").join("fixture-project"); + std::fs::create_dir_all(&project_dir).unwrap(); + let timestamp = Utc::now().to_rfc3339(); + std::fs::write( + project_dir.join("valid.jsonl"), + format!( + "{}\n", + claude_transcript_line(×tamp, "requestId", "req_public", "msg_public") + ), + ) + .unwrap(); + + let test_thread = std::thread::current(); + let test_name = test_thread.name().expect("test harness names this thread"); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test_name, "--nocapture", "--test-threads=1"]) + .env(CHILD_MARKER, "1") + .env("CLAUDE_CONFIG_DIR", config_dir.path()) + .output() + .expect("spawn isolated exact-test child"); + assert!( + output.status.success() && String::from_utf8_lossy(&output.stdout).contains(CHILD_DONE), + "fixture child failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn classifies_vertex_ai_claude_metadata_without_changing_anthropic_rows() { let cases = [