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