use chrono::{DateTime, Utc};
use crate::error::{AppError, Result};
pub fn finite_amount(vendor: &str, field: &str, v: f64) -> Result<f64> {
if v.is_finite() {
Ok(v)
} else {
Err(AppError::Schema(format!(
"{vendor}: `{field}` is not a finite number"
)))
}
}
pub fn parse_amount(vendor: &str, field: &str, s: &str) -> Result<f64> {
let t = s.trim();
if t.is_empty() {
return Err(AppError::Schema(format!("{vendor}: `{field}` is empty")));
}
let v: f64 = t
.parse()
.map_err(|_| AppError::Schema(format!("{vendor}: `{field}` is not numeric (got {t:?})")))?;
finite_amount(vendor, field, v)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageWindow {
pub utilization_pct: i32,
pub resets_at: Option<DateTime<Utc>>,
pub window_duration: chrono::Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cents(pub i64);
impl Cents {
pub fn fmt_dollars(self) -> String {
let (sign, abs) = if self.0 < 0 {
("-", -self.0)
} else {
("", self.0)
};
format!("{sign}${}.{:02}", abs / 100, abs % 100)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnthropicSnapshot {
pub plan: String,
pub session: UsageWindow,
pub weekly: UsageWindow,
pub sonnet: Option<UsageWindow>,
pub scoped: Vec<ScopedWindow>,
pub extra: Option<ExtraUsage>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopedWindow {
pub label: String,
pub window: UsageWindow,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtraUsage {
pub limit: Option<Cents>,
pub spent: Cents,
pub currency: Option<String>,
pub decimal_places: Option<u32>,
}
impl ExtraUsage {
pub fn percent(&self) -> i32 {
match self.limit {
Some(l) if l.0 > 0 => ((self.spent.0 * 100) / l.0) as i32,
_ => 0,
}
}
pub fn fmt_spent(&self) -> String {
self.fmt_amount(self.spent)
}
pub fn fmt_limit(&self) -> Option<String> {
self.limit.map(|l| self.fmt_amount(l))
}
fn fmt_amount(&self, amount: Cents) -> String {
match (self.decimal_places, self.currency.as_deref()) {
(Some(decimal_places), currency) => fmt_minor(amount.0, decimal_places, currency),
(None, None) => fmt_minor(amount.0, 2, None),
(None, Some(currency)) => fmt_minor_units(amount.0, currency),
}
}
}
fn fmt_minor_units(minor: i64, currency: &str) -> String {
let sign = if minor < 0 { "-" } else { "" };
format!("{sign}{} minor units {currency}", minor.unsigned_abs())
}
pub fn fmt_minor(minor: i64, decimal_places: u32, currency: Option<&str>) -> String {
let scale = 10_u64.pow(decimal_places);
let sign = if minor < 0 { "-" } else { "" };
let abs = minor.unsigned_abs();
let number = if decimal_places == 0 {
format!("{abs}")
} else {
format!(
"{}.{:0width$}",
abs / scale,
abs % scale,
width = decimal_places as usize
)
};
match currency {
None | Some("USD") => format!("{sign}${number}"),
Some("BRL") => format!("{sign}R${number}"),
Some("EUR") => format!("{sign}€{number}"),
Some("GBP") => format!("{sign}£{number}"),
Some("JPY") | Some("CNY") => format!("{sign}¥{number}"),
Some(other) => format!("{sign}{number} {other}"),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct DeepseekSnapshot {
pub is_available: bool,
pub balance: f64,
pub granted: f64,
pub topped_up: f64,
pub currency: String,
}
impl Eq for DeepseekSnapshot {}
impl Default for DeepseekSnapshot {
fn default() -> Self {
Self {
is_available: false,
balance: 0.0,
granted: 0.0,
topped_up: 0.0,
currency: String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KimiSnapshot {
pub plan: Option<String>,
pub weekly_limit: u64,
pub weekly_used: u64,
pub weekly_remaining: u64,
pub weekly_reset_at: Option<DateTime<Utc>>,
pub window_limit: u64,
pub window_used: u64,
pub window_remaining: u64,
pub window_reset_at: Option<DateTime<Utc>>,
}
impl KimiSnapshot {
fn pct(used: u64, limit: u64) -> i32 {
if limit == 0 {
0
} else {
let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
pct.min(100) as i32
}
}
pub fn weekly_pct(&self) -> i32 {
Self::pct(self.weekly_used, self.weekly_limit)
}
pub fn window_pct(&self) -> i32 {
Self::pct(self.window_used, self.window_limit)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VendorSnapshot {
Anthropic(AnthropicSnapshot),
Openai(OpenAiSnapshot),
Zai(ZaiSnapshot),
Openrouter(OpenRouterSnapshot),
Deepseek(DeepseekSnapshot),
Kimi(KimiSnapshot),
Kilo(KiloSnapshot),
Novita(NovitaSnapshot),
Moonshot(MoonshotSnapshot),
Grok(GrokSnapshot),
AnthropicApi(AnthropicApiSnapshot),
Antigravity(AntigravitySnapshot),
}
#[derive(Debug, Clone, PartialEq)]
pub struct AntigravitySnapshot {
pub plan: String,
pub account: String,
pub session: UsageWindow,
pub weekly: UsageWindow,
pub third_party_session: Option<UsageWindow>,
pub third_party_weekly: Option<UsageWindow>,
}
impl Eq for AntigravitySnapshot {}
#[derive(Debug, Clone, PartialEq)]
pub struct AnthropicApiSnapshot {
pub spent: f64,
pub limit: Option<f64>,
}
impl Eq for AnthropicApiSnapshot {}
impl AnthropicApiSnapshot {
pub fn pct(&self) -> Option<i32> {
self.limit
.filter(|l| l.is_finite() && *l > 0.0)
.map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct KiloSnapshot {
pub label: String,
pub balance: f64,
}
impl Eq for KiloSnapshot {}
#[derive(Debug, Clone, PartialEq)]
pub struct NovitaSnapshot {
pub available: f64,
pub cash: f64,
pub credit_limit: f64,
pub outstanding: f64,
}
impl Eq for NovitaSnapshot {}
#[derive(Debug, Clone, PartialEq)]
pub struct MoonshotSnapshot {
pub available: f64,
pub voucher: f64,
pub cash: f64,
pub currency: String,
}
impl Eq for MoonshotSnapshot {}
#[derive(Debug, Clone, PartialEq)]
pub struct GrokSnapshot {
pub balance: f64,
}
impl Eq for GrokSnapshot {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenAiSnapshot {
pub plan: String,
pub session: UsageWindow,
pub weekly: UsageWindow,
pub code_review: Option<UsageWindow>,
pub credits: Option<OpenAiCredits>,
pub source: OpenAiSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenAiSource {
CodexOauth,
AdminKeyMtd,
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenAiCredits {
pub balance: String,
pub has_credits: bool,
pub unlimited: bool,
pub approx_local_messages: Option<(i64, i64)>,
pub approx_cloud_messages: Option<(i64, i64)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ZaiSnapshot {
pub plan: String,
pub session: Option<UsageWindow>,
pub weekly: Option<UsageWindow>,
pub mcp: Option<UsageWindow>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct OpenRouterSnapshot {
pub label: String,
pub total_credits: f64,
pub total_usage: f64,
pub usage_daily: f64,
pub usage_weekly: f64,
pub usage_monthly: f64,
pub is_free_tier: bool,
pub limit: Option<f64>,
pub limit_remaining: Option<f64>,
}
impl Eq for OpenRouterSnapshot {}
impl OpenRouterSnapshot {
pub fn balance(&self) -> f64 {
(self.total_credits - self.total_usage).max(0.0)
}
pub fn consumed_pct(&self) -> i32 {
if self.total_credits <= 0.0 {
return 0;
}
((self.total_usage / self.total_credits) * 100.0)
.round()
.clamp(0.0, 100.0) as i32
}
}
pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
let mut max = snap.session.utilization_pct;
if snap.weekly.utilization_pct > max {
max = snap.weekly.utilization_pct;
}
if let Some(s) = &snap.sonnet
&& s.utilization_pct > max
{
max = s.utilization_pct;
}
for sw in &snap.scoped {
if sw.window.utilization_pct > max {
max = sw.window.utilization_pct;
}
}
let any_at_cap = snap.session.utilization_pct >= 100
|| snap.weekly.utilization_pct >= 100
|| snap
.sonnet
.as_ref()
.is_some_and(|s| s.utilization_pct >= 100)
|| snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
if any_at_cap && let Some(extra) = snap.extra.as_ref() {
let p = extra.percent();
if p > max {
max = p;
}
}
crate::pango::severity_for(max)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pacing::PaceSeverity;
use chrono::Duration;
fn w(pct: i32) -> UsageWindow {
UsageWindow {
utilization_pct: pct,
resets_at: None,
window_duration: Duration::hours(5),
}
}
fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
AnthropicSnapshot {
plan: "Max 5x".into(),
session: w(s),
weekly: w(w_),
sonnet: sonnet.map(w),
scoped: vec![],
extra: extra.map(|(limit, spent)| ExtraUsage {
limit: Some(Cents(limit)),
spent: Cents(spent),
currency: None,
decimal_places: Some(2),
}),
}
}
#[test]
fn fmt_minor_honors_currency_and_scale() {
assert_eq!(fmt_minor(250, 2, None), "$2.50");
assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
}
#[test]
fn extra_usage_formats_in_its_own_currency() {
let e = ExtraUsage {
limit: None,
spent: Cents(14157),
currency: Some("BRL".into()),
decimal_places: Some(2),
};
assert_eq!(e.fmt_spent(), "R$141.57");
assert_eq!(e.fmt_limit(), None);
let capped = ExtraUsage {
limit: Some(Cents(5000)),
spent: Cents(250),
currency: None,
decimal_places: Some(2),
};
assert_eq!(capped.fmt_spent(), "$2.50");
assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
}
#[test]
fn cents_format_positive() {
assert_eq!(Cents(0).fmt_dollars(), "$0.00");
assert_eq!(Cents(50).fmt_dollars(), "$0.50");
assert_eq!(Cents(250).fmt_dollars(), "$2.50");
assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
}
#[test]
fn cents_format_negative_uses_leading_sign() {
assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
}
#[test]
fn extra_percent_with_zero_limit_is_zero() {
assert_eq!(
ExtraUsage {
limit: Some(Cents(0)),
spent: Cents(100),
currency: None,
decimal_places: Some(2),
}
.percent(),
0
);
}
#[test]
fn extra_percent_truncates() {
assert_eq!(
ExtraUsage {
limit: Some(Cents(10000)),
spent: Cents(3333),
currency: None,
decimal_places: Some(2),
}
.percent(),
33
);
}
#[test]
fn severity_picks_worst_of_three_windows() {
let s = snap(40, 60, Some(80), None);
assert_eq!(anthropic_severity(&s), PaceSeverity::High); }
#[test]
fn severity_ignores_extra_when_no_cap_hit() {
let s = snap(50, 60, None, Some((10000, 9500)));
assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); }
#[test]
fn severity_promotes_extra_when_session_at_100() {
let s = snap(100, 50, None, Some((10000, 9500)));
assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); }
#[test]
fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
let s = snap(100, 50, None, Some((10000, 10000)));
assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
}
fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
s.scoped.push(ScopedWindow {
label: "Fable".into(),
window: w(pct),
});
s
}
#[test]
fn severity_includes_scoped_windows() {
let s = with_scoped(snap(10, 55, None, None), 84);
assert_eq!(anthropic_severity(&s), PaceSeverity::High);
}
#[test]
fn severity_promotes_extra_when_scoped_at_100() {
let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
}
#[test]
fn kimi_percent_is_exact_above_f64_precision() {
let snap = KimiSnapshot {
plan: None,
weekly_limit: (1 << 53) + 1,
weekly_used: 1 << 52,
weekly_remaining: 0,
weekly_reset_at: None,
window_limit: u64::MAX,
window_used: u64::MAX - 1,
window_remaining: 0,
window_reset_at: None,
};
assert_eq!(snap.weekly_pct(), 50);
assert_eq!(snap.window_pct(), 100);
}
}