Skip to main content

ai_usagebar/
usage.rs

1//! Canonical in-memory representation of "how much have I used my plan".
2//!
3//! Each vendor's snapshot lives in its own variant — this is deliberate.
4//! Anthropic exposes three windows + extra credits; OpenAI Codex exposes two
5//! windows + credit balance + message-count ranges; OpenRouter is a single
6//! credit-balance number with daily/weekly/monthly totals; Z.AI is a list of
7//! token + MCP buckets; DeepSeek is a credit balance; Kimi is a weekly quota
8//! plus a 5h rolling rate-limit window. Forcing them into a shared shape would
9//! either drop information or paper over genuine differences.
10//!
11//! Renderers (widget tooltip, TUI tab) consume a `VendorSnapshot` directly,
12//! not a flattened shape — so each vendor controls its own presentation while
13//! sharing the pacing math, color thresholds, and Pango primitives.
14
15use chrono::{DateTime, Utc};
16
17/// A single usage window — generic enough that every vendor with a notion of
18/// "% used vs. when does it reset" can express itself with it.
19///
20/// `utilization_pct` is `0..=100` (integer percent, matching claudebar's units).
21/// `resets_at` is `None` when the vendor doesn't report a reset time.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct UsageWindow {
24    pub utilization_pct: i32,
25    pub resets_at: Option<DateTime<Utc>>,
26    /// Window length (used for pacing math).
27    pub window_duration: chrono::Duration,
28}
29
30/// Money expressed in cents to dodge float roundoff.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Cents(pub i64);
33
34impl Cents {
35    /// Format as `[-]$D.CC`. Negative values render `-$D.CC` (not `$-D.CC`),
36    /// matching claudebar's `_fmt_dollars` (claudebar:532-537).
37    pub fn fmt_dollars(self) -> String {
38        let (sign, abs) = if self.0 < 0 {
39            ("-", -self.0)
40        } else {
41            ("", self.0)
42        };
43        format!("{sign}${}.{:02}", abs / 100, abs % 100)
44    }
45}
46
47/// Anthropic-specific snapshot — three rolling windows plus optional
48/// pay-as-you-go credit balance.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct AnthropicSnapshot {
51    /// "Claude Pro", "Claude Max 5x", "Claude Max 20x", etc.
52    pub plan: String,
53    pub session: UsageWindow,
54    pub weekly: UsageWindow,
55    /// Some vendors of Claude (Pro, some Max tiers) don't have a separate
56    /// Sonnet bucket — in which case this is None.
57    pub sonnet: Option<UsageWindow>,
58    /// Model-scoped weekly windows from the newer `limits[]` array
59    /// (`kind == "weekly_scoped"`), e.g. the Fable weekly cap. Labels come
60    /// from the API (`scope.model.display_name`), so new models show up
61    /// without a code change. Empty when the account has none.
62    pub scoped: Vec<ScopedWindow>,
63    /// `None` when `extra_usage.is_enabled` is false or the block is absent.
64    pub extra: Option<ExtraUsage>,
65}
66
67/// A usage window scoped to a specific model, labeled by the API
68/// (e.g. "Fable"). Weekly (7d) duration.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ScopedWindow {
71    pub label: String,
72    pub window: UsageWindow,
73}
74
75/// "Extra usage" pay-as-you-go block (claudebar's `extra_usage`).
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct ExtraUsage {
78    pub limit: Cents,
79    pub spent: Cents,
80}
81
82impl ExtraUsage {
83    /// Integer percentage of the monthly limit consumed (0..=100, saturating
84    /// at 0 when limit is non-positive — matches claudebar:540-542).
85    pub fn percent(self) -> i32 {
86        if self.limit.0 <= 0 {
87            0
88        } else {
89            ((self.spent.0 * 100) / self.limit.0) as i32
90        }
91    }
92}
93
94/// DeepSeek — credit balance from `/user/balance`.
95#[derive(Debug, Clone, PartialEq)]
96pub struct DeepseekSnapshot {
97    pub is_available: bool,
98    /// Current balance (prefer USD, fallback to CNY).
99    pub balance: f64,
100    /// Free-granted credits component.
101    pub granted: f64,
102    /// Topped-up (purchased) credits component.
103    pub topped_up: f64,
104    /// The currency of the above amounts ("USD", "CNY", etc.).
105    pub currency: String,
106}
107
108impl Eq for DeepseekSnapshot {}
109
110impl Default for DeepseekSnapshot {
111    fn default() -> Self {
112        Self {
113            is_available: false,
114            balance: 0.0,
115            granted: 0.0,
116            topped_up: 0.0,
117            currency: String::new(),
118        }
119    }
120}
121
122/// Kimi Code — weekly subscription quota plus a 5h rolling rate-limit window.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct KimiSnapshot {
125    pub plan: Option<String>,
126    pub weekly_limit: u64,
127    pub weekly_used: u64,
128    pub weekly_remaining: u64,
129    pub weekly_reset_at: Option<DateTime<Utc>>,
130    pub window_limit: u64,
131    pub window_used: u64,
132    pub window_remaining: u64,
133    pub window_reset_at: Option<DateTime<Utc>>,
134}
135
136impl KimiSnapshot {
137    fn pct(used: u64, limit: u64) -> i32 {
138        if limit == 0 {
139            0
140        } else {
141            // Keep all quota values exact: f64 loses integer precision above
142            // 2^53. This is the integer equivalent of round(used / limit *
143            // 100), with saturation for inconsistent upstream counters.
144            let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
145            pct.min(100) as i32
146        }
147    }
148
149    /// Percentage of the weekly subscription quota consumed (0..=100).
150    pub fn weekly_pct(&self) -> i32 {
151        Self::pct(self.weekly_used, self.weekly_limit)
152    }
153
154    /// Percentage of the rolling rate-limit window consumed (0..=100).
155    pub fn window_pct(&self) -> i32 {
156        Self::pct(self.window_used, self.window_limit)
157    }
158}
159
160/// Discriminated union of vendor-specific snapshots. The widget and TUI match
161/// on this to pick a renderer.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum VendorSnapshot {
164    Anthropic(AnthropicSnapshot),
165    Openai(OpenAiSnapshot),
166    Zai(ZaiSnapshot),
167    Openrouter(OpenRouterSnapshot),
168    Deepseek(DeepseekSnapshot),
169    Kimi(KimiSnapshot),
170}
171
172/// OpenAI Codex OAuth — mirrors Anthropic's two-window + extras pattern.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct OpenAiSnapshot {
175    pub plan: String,
176    /// 5h window (Codex `rate_limit.primary_window`).
177    pub session: UsageWindow,
178    /// 7d window (Codex `rate_limit.secondary_window`).
179    pub weekly: UsageWindow,
180    /// Optional 7d code-review bucket.
181    pub code_review: Option<UsageWindow>,
182    /// Optional credit balance + approximate message-count ranges.
183    pub credits: Option<OpenAiCredits>,
184    /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
185    /// the placeholder set and the "OpenAI does not expose this for Plus"
186    /// tooltip when the OAuth path isn't available.
187    pub source: OpenAiSource,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum OpenAiSource {
192    CodexOauth,
193    AdminKeyMtd,
194    Unavailable,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct OpenAiCredits {
199    /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
200    /// a string because OpenAI returns it that way.
201    pub balance: String,
202    pub has_credits: bool,
203    pub unlimited: bool,
204    pub approx_local_messages: Option<(i64, i64)>,
205    pub approx_cloud_messages: Option<(i64, i64)>,
206}
207
208/// Z.AI / BigModel — list of buckets with discriminated types. We project the
209/// two we care about into named fields (5h tokens, weekly tokens, MCP).
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct ZaiSnapshot {
212    pub plan: String,
213    pub session: Option<UsageWindow>,
214    pub weekly: Option<UsageWindow>,
215    pub mcp: Option<UsageWindow>,
216}
217
218/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
219/// `/api/v1/credits` and `/api/v1/key`.
220#[derive(Debug, Clone, PartialEq)]
221pub struct OpenRouterSnapshot {
222    pub label: String,
223    pub total_credits: f64,
224    pub total_usage: f64,
225    pub usage_daily: f64,
226    pub usage_weekly: f64,
227    pub usage_monthly: f64,
228    pub is_free_tier: bool,
229    pub limit: Option<f64>,
230    pub limit_remaining: Option<f64>,
231}
232
233impl Eq for OpenRouterSnapshot {}
234
235impl OpenRouterSnapshot {
236    pub fn balance(&self) -> f64 {
237        (self.total_credits - self.total_usage).max(0.0)
238    }
239    /// Percentage of total_credits consumed (0..=100). Returns 0 when
240    /// `total_credits` is 0 (free-tier-only accounts).
241    pub fn consumed_pct(&self) -> i32 {
242        if self.total_credits <= 0.0 {
243            return 0;
244        }
245        ((self.total_usage / self.total_credits) * 100.0)
246            .round()
247            .clamp(0.0, 100.0) as i32
248    }
249}
250
251/// Worst-of severity class for the Waybar bar text color. Mirrors
252/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
253pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
254    let mut max = snap.session.utilization_pct;
255    if snap.weekly.utilization_pct > max {
256        max = snap.weekly.utilization_pct;
257    }
258    if let Some(s) = &snap.sonnet
259        && s.utilization_pct > max
260    {
261        max = s.utilization_pct;
262    }
263    for sw in &snap.scoped {
264        if sw.window.utilization_pct > max {
265            max = sw.window.utilization_pct;
266        }
267    }
268    // Extra usage only promotes severity if a rate-limit window is at 100%.
269    let any_at_cap = snap.session.utilization_pct >= 100
270        || snap.weekly.utilization_pct >= 100
271        || snap
272            .sonnet
273            .as_ref()
274            .is_some_and(|s| s.utilization_pct >= 100)
275        || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
276    if any_at_cap && let Some(extra) = snap.extra {
277        let p = extra.percent();
278        if p > max {
279            max = p;
280        }
281    }
282    crate::pango::severity_for(max)
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::pacing::PaceSeverity;
289    use chrono::Duration;
290
291    fn w(pct: i32) -> UsageWindow {
292        UsageWindow {
293            utilization_pct: pct,
294            resets_at: None,
295            window_duration: Duration::hours(5),
296        }
297    }
298
299    fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
300        AnthropicSnapshot {
301            plan: "Max 5x".into(),
302            session: w(s),
303            weekly: w(w_),
304            sonnet: sonnet.map(w),
305            scoped: vec![],
306            extra: extra.map(|(limit, spent)| ExtraUsage {
307                limit: Cents(limit),
308                spent: Cents(spent),
309            }),
310        }
311    }
312
313    #[test]
314    fn cents_format_positive() {
315        assert_eq!(Cents(0).fmt_dollars(), "$0.00");
316        assert_eq!(Cents(50).fmt_dollars(), "$0.50");
317        assert_eq!(Cents(250).fmt_dollars(), "$2.50");
318        assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
319    }
320
321    #[test]
322    fn cents_format_negative_uses_leading_sign() {
323        // claudebar bug-fix: never "$-1.-50" — sign goes before the dollar sign.
324        assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
325        assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
326    }
327
328    #[test]
329    fn extra_percent_with_zero_limit_is_zero() {
330        assert_eq!(
331            ExtraUsage {
332                limit: Cents(0),
333                spent: Cents(100)
334            }
335            .percent(),
336            0
337        );
338    }
339
340    #[test]
341    fn extra_percent_truncates() {
342        // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
343        assert_eq!(
344            ExtraUsage {
345                limit: Cents(10000),
346                spent: Cents(3333)
347            }
348            .percent(),
349            33
350        );
351    }
352
353    #[test]
354    fn severity_picks_worst_of_three_windows() {
355        let s = snap(40, 60, Some(80), None);
356        assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
357    }
358
359    #[test]
360    fn severity_ignores_extra_when_no_cap_hit() {
361        // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
362        let s = snap(50, 60, None, Some((10000, 9500)));
363        assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
364    }
365
366    #[test]
367    fn severity_promotes_extra_when_session_at_100() {
368        let s = snap(100, 50, None, Some((10000, 9500)));
369        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
370    }
371
372    #[test]
373    fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
374        // session = 100, weekly = 50, extra = 100% → max should be 100.
375        let s = snap(100, 50, None, Some((10000, 10000)));
376        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
377    }
378
379    fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
380        s.scoped.push(ScopedWindow {
381            label: "Fable".into(),
382            window: w(pct),
383        });
384        s
385    }
386
387    #[test]
388    fn severity_includes_scoped_windows() {
389        // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
390        // week at 84 → the bar class must escalate to High.
391        let s = with_scoped(snap(10, 55, None, None), 84);
392        assert_eq!(anthropic_severity(&s), PaceSeverity::High);
393    }
394
395    #[test]
396    fn severity_promotes_extra_when_scoped_at_100() {
397        // A scoped window at cap counts as a rate-limit cap hit, so extra
398        // usage above the window max is promoted — same rule as session/weekly.
399        let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
400        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
401    }
402
403    #[test]
404    fn kimi_percent_is_exact_above_f64_precision() {
405        let snap = KimiSnapshot {
406            plan: None,
407            weekly_limit: (1 << 53) + 1,
408            weekly_used: 1 << 52,
409            weekly_remaining: 0,
410            weekly_reset_at: None,
411            window_limit: u64::MAX,
412            window_used: u64::MAX - 1,
413            window_remaining: 0,
414            window_reset_at: None,
415        };
416        assert_eq!(snap.weekly_pct(), 50);
417        assert_eq!(snap.window_pct(), 100);
418    }
419}