use chrono::{DateTime, Utc};
#[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 extra: Option<ExtraUsage>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExtraUsage {
pub limit: Cents,
pub spent: Cents,
}
impl ExtraUsage {
pub fn percent(self) -> i32 {
if self.limit.0 <= 0 {
0
} else {
((self.spent.0 * 100) / self.limit.0) as i32
}
}
}
#[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 enum VendorSnapshot {
Anthropic(AnthropicSnapshot),
Openai(OpenAiSnapshot),
Zai(ZaiSnapshot),
Openrouter(OpenRouterSnapshot),
Deepseek(DeepseekSnapshot),
}
#[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 {
if s.utilization_pct > max {
max = s.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);
if any_at_cap {
if let Some(extra) = snap.extra {
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),
extra: extra.map(|(limit, spent)| ExtraUsage {
limit: Cents(limit),
spent: Cents(spent),
}),
}
}
#[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: Cents(0),
spent: Cents(100)
}
.percent(),
0
);
}
#[test]
fn extra_percent_truncates() {
assert_eq!(
ExtraUsage {
limit: Cents(10000),
spent: Cents(3333)
}
.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);
}
}