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
17use crate::error::{AppError, Result};
18
19/// Reject a non-finite monetary value. A NaN or infinity reaching a balance
20/// field means the payload was not what we think it is; displaying it as money
21/// (or caching it as authoritative) is worse than failing loudly.
22pub fn finite_amount(vendor: &str, field: &str, v: f64) -> Result<f64> {
23    if v.is_finite() {
24        Ok(v)
25    } else {
26        Err(AppError::Schema(format!(
27            "{vendor}: `{field}` is not a finite number"
28        )))
29    }
30}
31
32/// Parse a monetary field that the wire encodes as a string. A malformed or
33/// empty value is a schema error, **not** a zero balance — silently reporting
34/// $0.00 for an error envelope is the failure mode this guards against.
35pub fn parse_amount(vendor: &str, field: &str, s: &str) -> Result<f64> {
36    let t = s.trim();
37    if t.is_empty() {
38        return Err(AppError::Schema(format!("{vendor}: `{field}` is empty")));
39    }
40    let v: f64 = t
41        .parse()
42        .map_err(|_| AppError::Schema(format!("{vendor}: `{field}` is not numeric (got {t:?})")))?;
43    finite_amount(vendor, field, v)
44}
45
46/// A single usage window — generic enough that every vendor with a notion of
47/// "% used vs. when does it reset" can express itself with it.
48///
49/// `utilization_pct` is `0..=100` (integer percent, matching claudebar's units).
50/// `resets_at` is `None` when the vendor doesn't report a reset time.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct UsageWindow {
53    pub utilization_pct: i32,
54    pub resets_at: Option<DateTime<Utc>>,
55    /// Window length (used for pacing math).
56    pub window_duration: chrono::Duration,
57}
58
59/// Money expressed in cents to dodge float roundoff.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct Cents(pub i64);
62
63impl Cents {
64    /// Format as `[-]$D.CC`. Negative values render `-$D.CC` (not `$-D.CC`),
65    /// matching claudebar's `_fmt_dollars` (claudebar:532-537).
66    pub fn fmt_dollars(self) -> String {
67        let (sign, abs) = if self.0 < 0 {
68            ("-", -self.0)
69        } else {
70            ("", self.0)
71        };
72        format!("{sign}${}.{:02}", abs / 100, abs % 100)
73    }
74}
75
76/// Anthropic-specific snapshot — three rolling windows plus optional
77/// pay-as-you-go credit balance.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct AnthropicSnapshot {
80    /// "Claude Pro", "Claude Max 5x", "Claude Max 20x", etc.
81    pub plan: String,
82    pub session: UsageWindow,
83    pub weekly: UsageWindow,
84    /// Some vendors of Claude (Pro, some Max tiers) don't have a separate
85    /// Sonnet bucket — in which case this is None.
86    pub sonnet: Option<UsageWindow>,
87    /// Model-scoped weekly windows from the newer `limits[]` array
88    /// (`kind == "weekly_scoped"`), e.g. the Fable weekly cap. Labels come
89    /// from the API (`scope.model.display_name`), so new models show up
90    /// without a code change. Empty when the account has none.
91    pub scoped: Vec<ScopedWindow>,
92    /// `None` when `extra_usage.is_enabled` is false or the block is absent.
93    pub extra: Option<ExtraUsage>,
94}
95
96/// A usage window scoped to a specific model, labeled by the API
97/// (e.g. "Fable"). Weekly (7d) duration.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ScopedWindow {
100    pub label: String,
101    pub window: UsageWindow,
102}
103
104/// "Extra usage" pay-as-you-go block (claudebar's `extra_usage`).
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct ExtraUsage {
107    pub limit: Cents,
108    pub spent: Cents,
109}
110
111impl ExtraUsage {
112    /// Integer percentage of the monthly limit consumed (0..=100, saturating
113    /// at 0 when limit is non-positive — matches claudebar:540-542).
114    pub fn percent(self) -> i32 {
115        if self.limit.0 <= 0 {
116            0
117        } else {
118            ((self.spent.0 * 100) / self.limit.0) as i32
119        }
120    }
121}
122
123/// DeepSeek — credit balance from `/user/balance`.
124#[derive(Debug, Clone, PartialEq)]
125pub struct DeepseekSnapshot {
126    pub is_available: bool,
127    /// Current balance (prefer USD, fallback to CNY).
128    pub balance: f64,
129    /// Free-granted credits component.
130    pub granted: f64,
131    /// Topped-up (purchased) credits component.
132    pub topped_up: f64,
133    /// The currency of the above amounts (currently "USD" or "CNY").
134    pub currency: String,
135}
136
137impl Eq for DeepseekSnapshot {}
138
139impl Default for DeepseekSnapshot {
140    fn default() -> Self {
141        Self {
142            is_available: false,
143            balance: 0.0,
144            granted: 0.0,
145            topped_up: 0.0,
146            currency: String::new(),
147        }
148    }
149}
150
151/// Kimi Code — weekly subscription quota plus a 5h rolling rate-limit window.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct KimiSnapshot {
154    pub plan: Option<String>,
155    pub weekly_limit: u64,
156    pub weekly_used: u64,
157    pub weekly_remaining: u64,
158    pub weekly_reset_at: Option<DateTime<Utc>>,
159    pub window_limit: u64,
160    pub window_used: u64,
161    pub window_remaining: u64,
162    pub window_reset_at: Option<DateTime<Utc>>,
163}
164
165impl KimiSnapshot {
166    fn pct(used: u64, limit: u64) -> i32 {
167        if limit == 0 {
168            0
169        } else {
170            // Keep all quota values exact: f64 loses integer precision above
171            // 2^53. This is the integer equivalent of round(used / limit *
172            // 100), with saturation for inconsistent upstream counters.
173            let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
174            pct.min(100) as i32
175        }
176    }
177
178    /// Percentage of the weekly subscription quota consumed (0..=100).
179    pub fn weekly_pct(&self) -> i32 {
180        Self::pct(self.weekly_used, self.weekly_limit)
181    }
182
183    /// Percentage of the rolling rate-limit window consumed (0..=100).
184    pub fn window_pct(&self) -> i32 {
185        Self::pct(self.window_used, self.window_limit)
186    }
187}
188
189/// Discriminated union of vendor-specific snapshots. The widget and TUI match
190/// on this to pick a renderer.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum VendorSnapshot {
193    Anthropic(AnthropicSnapshot),
194    Openai(OpenAiSnapshot),
195    Zai(ZaiSnapshot),
196    Openrouter(OpenRouterSnapshot),
197    Deepseek(DeepseekSnapshot),
198    Kimi(KimiSnapshot),
199    Kilo(KiloSnapshot),
200    Novita(NovitaSnapshot),
201    Moonshot(MoonshotSnapshot),
202    Grok(GrokSnapshot),
203    AnthropicApi(AnthropicApiSnapshot),
204}
205
206/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
207/// monthly `limit` is supplied from config (the API exposes neither the limit
208/// nor the remaining prepaid credit balance).
209#[derive(Debug, Clone, PartialEq)]
210pub struct AnthropicApiSnapshot {
211    pub spent: f64,
212    pub limit: Option<f64>,
213}
214
215impl Eq for AnthropicApiSnapshot {}
216
217impl AnthropicApiSnapshot {
218    /// Spend as an integer percentage of the configured limit; `None` when no
219    /// positive limit is set.
220    pub fn pct(&self) -> Option<i32> {
221        self.limit
222            .filter(|l| l.is_finite() && *l > 0.0)
223            .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
224    }
225}
226
227/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
228/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
229#[derive(Debug, Clone, PartialEq)]
230pub struct KiloSnapshot {
231    pub label: String,
232    pub balance: f64,
233}
234
235impl Eq for KiloSnapshot {}
236
237/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
238/// all amounts already converted from the API's 1/10000-USD integers to USD.
239#[derive(Debug, Clone, PartialEq)]
240pub struct NovitaSnapshot {
241    /// Spendable credit balance (`availableBalance`).
242    pub available: f64,
243    /// Remaining top-up (`cashBalance`).
244    pub cash: f64,
245    /// Credit limit — max you can owe (`creditLimit`).
246    pub credit_limit: f64,
247    /// Amount currently owed (`outstandingInvoices`).
248    pub outstanding: f64,
249}
250
251impl Eq for NovitaSnapshot {}
252
253/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
254/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
255/// field in the response, so it's carried here from the region config.
256#[derive(Debug, Clone, PartialEq)]
257pub struct MoonshotSnapshot {
258    /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
259    /// the inference API.
260    pub available: f64,
261    /// Voucher credit (`voucher_balance`).
262    pub voucher: f64,
263    /// Cash balance (`cash_balance`); can be negative (debt).
264    pub cash: f64,
265    /// "USD" or "CNY", implied by the host.
266    pub currency: String,
267}
268
269impl Eq for MoonshotSnapshot {}
270
271/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
272/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
273#[derive(Debug, Clone, PartialEq)]
274pub struct GrokSnapshot {
275    pub balance: f64,
276}
277
278impl Eq for GrokSnapshot {}
279
280/// OpenAI Codex OAuth — mirrors Anthropic's two-window + extras pattern.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct OpenAiSnapshot {
283    pub plan: String,
284    /// 5h window (Codex `rate_limit.primary_window`).
285    pub session: UsageWindow,
286    /// 7d window (Codex `rate_limit.secondary_window`).
287    pub weekly: UsageWindow,
288    /// Optional 7d code-review bucket.
289    pub code_review: Option<UsageWindow>,
290    /// Optional credit balance + approximate message-count ranges.
291    pub credits: Option<OpenAiCredits>,
292    /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
293    /// the placeholder set and the "OpenAI does not expose this for Plus"
294    /// tooltip when the OAuth path isn't available.
295    pub source: OpenAiSource,
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum OpenAiSource {
300    CodexOauth,
301    AdminKeyMtd,
302    Unavailable,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct OpenAiCredits {
307    /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
308    /// a string because OpenAI returns it that way.
309    pub balance: String,
310    pub has_credits: bool,
311    pub unlimited: bool,
312    pub approx_local_messages: Option<(i64, i64)>,
313    pub approx_cloud_messages: Option<(i64, i64)>,
314}
315
316/// Z.AI / BigModel — list of buckets with discriminated types. We project the
317/// two we care about into named fields (5h tokens, weekly tokens, MCP).
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct ZaiSnapshot {
320    pub plan: String,
321    pub session: Option<UsageWindow>,
322    pub weekly: Option<UsageWindow>,
323    pub mcp: Option<UsageWindow>,
324}
325
326/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
327/// `/api/v1/credits` and `/api/v1/key`.
328#[derive(Debug, Clone, PartialEq)]
329pub struct OpenRouterSnapshot {
330    pub label: String,
331    pub total_credits: f64,
332    pub total_usage: f64,
333    pub usage_daily: f64,
334    pub usage_weekly: f64,
335    pub usage_monthly: f64,
336    pub is_free_tier: bool,
337    pub limit: Option<f64>,
338    pub limit_remaining: Option<f64>,
339}
340
341impl Eq for OpenRouterSnapshot {}
342
343impl OpenRouterSnapshot {
344    pub fn balance(&self) -> f64 {
345        (self.total_credits - self.total_usage).max(0.0)
346    }
347    /// Percentage of total_credits consumed (0..=100). Returns 0 when
348    /// `total_credits` is 0 (free-tier-only accounts).
349    pub fn consumed_pct(&self) -> i32 {
350        if self.total_credits <= 0.0 {
351            return 0;
352        }
353        ((self.total_usage / self.total_credits) * 100.0)
354            .round()
355            .clamp(0.0, 100.0) as i32
356    }
357}
358
359/// Worst-of severity class for the Waybar bar text color. Mirrors
360/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
361pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
362    let mut max = snap.session.utilization_pct;
363    if snap.weekly.utilization_pct > max {
364        max = snap.weekly.utilization_pct;
365    }
366    if let Some(s) = &snap.sonnet
367        && s.utilization_pct > max
368    {
369        max = s.utilization_pct;
370    }
371    for sw in &snap.scoped {
372        if sw.window.utilization_pct > max {
373            max = sw.window.utilization_pct;
374        }
375    }
376    // Extra usage only promotes severity if a rate-limit window is at 100%.
377    let any_at_cap = snap.session.utilization_pct >= 100
378        || snap.weekly.utilization_pct >= 100
379        || snap
380            .sonnet
381            .as_ref()
382            .is_some_and(|s| s.utilization_pct >= 100)
383        || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
384    if any_at_cap && let Some(extra) = snap.extra {
385        let p = extra.percent();
386        if p > max {
387            max = p;
388        }
389    }
390    crate::pango::severity_for(max)
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::pacing::PaceSeverity;
397    use chrono::Duration;
398
399    fn w(pct: i32) -> UsageWindow {
400        UsageWindow {
401            utilization_pct: pct,
402            resets_at: None,
403            window_duration: Duration::hours(5),
404        }
405    }
406
407    fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
408        AnthropicSnapshot {
409            plan: "Max 5x".into(),
410            session: w(s),
411            weekly: w(w_),
412            sonnet: sonnet.map(w),
413            scoped: vec![],
414            extra: extra.map(|(limit, spent)| ExtraUsage {
415                limit: Cents(limit),
416                spent: Cents(spent),
417            }),
418        }
419    }
420
421    #[test]
422    fn cents_format_positive() {
423        assert_eq!(Cents(0).fmt_dollars(), "$0.00");
424        assert_eq!(Cents(50).fmt_dollars(), "$0.50");
425        assert_eq!(Cents(250).fmt_dollars(), "$2.50");
426        assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
427    }
428
429    #[test]
430    fn cents_format_negative_uses_leading_sign() {
431        // claudebar bug-fix: never "$-1.-50" — sign goes before the dollar sign.
432        assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
433        assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
434    }
435
436    #[test]
437    fn extra_percent_with_zero_limit_is_zero() {
438        assert_eq!(
439            ExtraUsage {
440                limit: Cents(0),
441                spent: Cents(100)
442            }
443            .percent(),
444            0
445        );
446    }
447
448    #[test]
449    fn extra_percent_truncates() {
450        // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
451        assert_eq!(
452            ExtraUsage {
453                limit: Cents(10000),
454                spent: Cents(3333)
455            }
456            .percent(),
457            33
458        );
459    }
460
461    #[test]
462    fn severity_picks_worst_of_three_windows() {
463        let s = snap(40, 60, Some(80), None);
464        assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
465    }
466
467    #[test]
468    fn severity_ignores_extra_when_no_cap_hit() {
469        // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
470        let s = snap(50, 60, None, Some((10000, 9500)));
471        assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
472    }
473
474    #[test]
475    fn severity_promotes_extra_when_session_at_100() {
476        let s = snap(100, 50, None, Some((10000, 9500)));
477        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
478    }
479
480    #[test]
481    fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
482        // session = 100, weekly = 50, extra = 100% → max should be 100.
483        let s = snap(100, 50, None, Some((10000, 10000)));
484        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
485    }
486
487    fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
488        s.scoped.push(ScopedWindow {
489            label: "Fable".into(),
490            window: w(pct),
491        });
492        s
493    }
494
495    #[test]
496    fn severity_includes_scoped_windows() {
497        // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
498        // week at 84 → the bar class must escalate to High.
499        let s = with_scoped(snap(10, 55, None, None), 84);
500        assert_eq!(anthropic_severity(&s), PaceSeverity::High);
501    }
502
503    #[test]
504    fn severity_promotes_extra_when_scoped_at_100() {
505        // A scoped window at cap counts as a rate-limit cap hit, so extra
506        // usage above the window max is promoted — same rule as session/weekly.
507        let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
508        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
509    }
510
511    #[test]
512    fn kimi_percent_is_exact_above_f64_precision() {
513        let snap = KimiSnapshot {
514            plan: None,
515            weekly_limit: (1 << 53) + 1,
516            weekly_used: 1 << 52,
517            weekly_remaining: 0,
518            weekly_reset_at: None,
519            window_limit: u64::MAX,
520            window_used: u64::MAX - 1,
521            window_remaining: 0,
522            window_reset_at: None,
523        };
524        assert_eq!(snap.weekly_pct(), 50);
525        assert_eq!(snap.window_pct(), 100);
526    }
527}