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 up to
5//! two 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 in minor currency units (historically always cents; see
60/// `ExtraUsage::decimal_places` for the actual scale) to dodge float roundoff.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct Cents(pub i64);
63
64impl Cents {
65    /// Format as `[-]$D.CC`. Negative values render `-$D.CC` (not `$-D.CC`),
66    /// matching claudebar's `_fmt_dollars` (claudebar:532-537).
67    pub fn fmt_dollars(self) -> String {
68        let (sign, abs) = if self.0 < 0 {
69            ("-", -self.0)
70        } else {
71            ("", self.0)
72        };
73        format!("{sign}${}.{:02}", abs / 100, abs % 100)
74    }
75}
76
77/// Anthropic-specific snapshot — three rolling windows plus optional
78/// pay-as-you-go credit balance.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct AnthropicSnapshot {
81    /// "Claude Pro", "Claude Max 5x", "Claude Max 20x", etc.
82    pub plan: String,
83    pub session: UsageWindow,
84    pub weekly: UsageWindow,
85    /// Some vendors of Claude (Pro, some Max tiers) don't have a separate
86    /// Sonnet bucket — in which case this is None.
87    pub sonnet: Option<UsageWindow>,
88    /// Model-scoped weekly windows from the newer `limits[]` array
89    /// (`kind == "weekly_scoped"`), e.g. the Fable weekly cap. Labels come
90    /// from the API (`scope.model.display_name`), so new models show up
91    /// without a code change. Empty when the account has none.
92    pub scoped: Vec<ScopedWindow>,
93    /// `None` when `extra_usage.is_enabled` is false or the block is absent.
94    pub extra: Option<ExtraUsage>,
95}
96
97/// A usage window scoped to a specific model, labeled by the API
98/// (e.g. "Fable"). Weekly (7d) duration.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ScopedWindow {
101    pub label: String,
102    pub window: UsageWindow,
103}
104
105/// "Extra usage" pay-as-you-go block (claudebar's `extra_usage`).
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ExtraUsage {
108    /// `None` when the payload carries no usable `monthly_limit` — an
109    /// explicit null (observed for plans without a spending cap, e.g. Claude
110    /// Pro, #30) or an absent field. Either way the spend is real and stays
111    /// visible; only the limit is unreported, and the renderers say exactly
112    /// that rather than inferring a plan tier from it.
113    pub limit: Option<Cents>,
114    pub spent: Cents,
115    /// ISO code from the block (`"BRL"`, `"USD"`, …). `None` on older payloads
116    /// that predate the field — formatted as `$` for back-compat, which was
117    /// the only behaviour before the field existed.
118    pub currency: Option<String>,
119    /// Minor-unit digits from the block's `decimal_places` (BRL/USD = 2,
120    /// JPY/KRW = 0). `None` means the wire did not report the scale. We keep
121    /// that absence instead of guessing from an incomplete currency table.
122    pub decimal_places: Option<u32>,
123}
124
125impl ExtraUsage {
126    /// Integer percentage of the monthly limit consumed (0..=100, saturating
127    /// at 0 when limit is non-positive — matches claudebar:540-542).
128    ///
129    /// With no cap there is no denominator, so no meaningful percentage
130    /// exists; 0 keeps the bar and severity calm rather than inventing one.
131    pub fn percent(&self) -> i32 {
132        match self.limit {
133            Some(l) if l.0 > 0 => ((self.spent.0 * 100) / l.0) as i32,
134            _ => 0,
135        }
136    }
137
138    pub fn fmt_spent(&self) -> String {
139        self.fmt_amount(self.spent)
140    }
141
142    pub fn fmt_limit(&self) -> Option<String> {
143        self.limit.map(|l| self.fmt_amount(l))
144    }
145
146    fn fmt_amount(&self, amount: Cents) -> String {
147        match (self.decimal_places, self.currency.as_deref()) {
148            (Some(decimal_places), currency) => fmt_minor(amount.0, decimal_places, currency),
149            // Legacy payloads predate both fields and were always cents/USD.
150            // Preserve that established behaviour only when neither field can
151            // tell us otherwise.
152            (None, None) => fmt_minor(amount.0, 2, None),
153            // A currency code alone does not determine its ISO minor-unit
154            // exponent. Keep the amount truthful instead of silently dividing
155            // zero-, three-, or four-decimal currencies by the wrong scale.
156            (None, Some(currency)) => fmt_minor_units(amount.0, currency),
157        }
158    }
159}
160
161fn fmt_minor_units(minor: i64, currency: &str) -> String {
162    let sign = if minor < 0 { "-" } else { "" };
163    format!("{sign}{} minor units {currency}", minor.unsigned_abs())
164}
165
166/// Format an amount in minor units with its own currency and scale.
167///
168/// The scale is this function's own — `money` cannot express a zero- or
169/// three-decimal currency — but the *symbol* comes from
170/// [`crate::format::with_currency`], so the two cannot disagree about what a
171/// given code looks like.
172pub fn fmt_minor(minor: i64, decimal_places: u32, currency: Option<&str>) -> String {
173    let scale = 10_u64.pow(decimal_places);
174    // `unsigned_abs`, not negation: `-i64::MIN` overflows. Unreachable from
175    // the wire (the parse gate rejects negatives) but this is a pub fn.
176    let sign = if minor < 0 { "-" } else { "" };
177    let abs = minor.unsigned_abs();
178    let number = if decimal_places == 0 {
179        format!("{abs}")
180    } else {
181        format!(
182            "{}.{:0width$}",
183            abs / scale,
184            abs % scale,
185            width = decimal_places as usize
186        )
187    };
188    crate::format::with_currency(sign, &number, currency)
189}
190
191/// DeepSeek — credit balance from `/user/balance`.
192#[derive(Debug, Clone, PartialEq)]
193pub struct DeepseekSnapshot {
194    pub is_available: bool,
195    /// Current balance (prefer USD, fallback to CNY).
196    pub balance: f64,
197    /// Free-granted credits component.
198    pub granted: f64,
199    /// Topped-up (purchased) credits component.
200    pub topped_up: f64,
201    /// The currency of the above amounts (currently "USD" or "CNY").
202    pub currency: String,
203}
204
205impl Eq for DeepseekSnapshot {}
206
207impl Default for DeepseekSnapshot {
208    fn default() -> Self {
209        Self {
210            is_available: false,
211            balance: 0.0,
212            granted: 0.0,
213            topped_up: 0.0,
214            currency: String::new(),
215        }
216    }
217}
218
219/// Cursor — the two included-usage pools the dashboard shows, from the
220/// undocumented `cursor.com/api/usage-summary` endpoint (the same one the
221/// dashboard's own frontend calls), authenticated with the session token the
222/// Cursor IDE wrote to its local `state.vscdb`.
223///
224/// Since Cursor's mid-2026 pricing, a plan's included compute is split into two
225/// quota pools, each shown as a percentage: **Cursor Models** (Auto + Composer,
226/// `autoPercentUsed`) and **Other Models** (named / third-party, `apiPercentUsed`).
227/// Overflow past either pool falls to on-demand spend. Percentages are integers
228/// (rounded from the wire floats) to match the dashboard and every other
229/// vendor's integer-percent convention; they can exceed 100 when a pool is over
230/// its included allowance.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct CursorSnapshot {
233    /// Membership label, title-cased from `membershipType` (e.g. "Ultra").
234    pub plan: String,
235    /// "Cursor Models" pool — Auto + Composer (`autoPercentUsed`, rounded).
236    pub auto_pct: i32,
237    /// "Other Models" pool — named / third-party (`apiPercentUsed`, rounded).
238    pub api_pct: i32,
239    /// Overall included usage (`totalPercentUsed`, rounded) — the dashboard's
240    /// "you've used N% of your included total usage" headline.
241    pub total_pct: i32,
242    /// `true` when the plan reports `isUnlimited` — the pools don't cap and the
243    /// percentages are not meaningful.
244    pub unlimited: bool,
245    /// Whether on-demand (overage) spend is turned on (`onDemand.enabled`).
246    pub on_demand_enabled: bool,
247    /// End of the current billing cycle (`billingCycleEnd`) — when the pools
248    /// reset.
249    pub reset_at: Option<DateTime<Utc>>,
250}
251
252impl CursorSnapshot {
253    /// The binding pool — whichever is closest to (or furthest past) its cap.
254    /// Drives the bar color and the single generic `session_pct` alias.
255    pub fn worst_pct(&self) -> i32 {
256        self.auto_pct.max(self.api_pct)
257    }
258}
259
260/// Kiro CLI (AWS CodeWhisperer / Q Developer backend) — a single credit pool
261/// from `AmazonCodeWhispererService.GetUsageLimits`, the same call kiro-cli's
262/// own `/usage` slash command makes. Authenticated with the AWS SSO OIDC
263/// bearer token kiro-cli already cached locally, refreshed with the paired
264/// refresh token when it's close to expiry — see `kiro::db` and `kiro::oauth`.
265#[derive(Debug, Clone, PartialEq)]
266pub struct KiroSnapshot {
267    /// Subscription tier label (`subscriptionInfo.subscriptionTitle`, e.g.
268    /// "KIRO POWER").
269    pub plan: String,
270    /// Credits consumed this cycle (`currentUsageWithPrecision`).
271    pub used: f64,
272    /// Credits included in the plan (`usageLimitWithPrecision`).
273    pub limit: f64,
274    /// When the credit pool resets (`nextDateReset`).
275    pub reset_at: Option<DateTime<Utc>>,
276}
277
278impl Eq for KiroSnapshot {}
279
280impl KiroSnapshot {
281    /// Percentage of the credit pool consumed, rounded. `0` when `limit` is
282    /// not positive — defensive; the API has not been observed to send that.
283    pub fn pct(&self) -> i32 {
284        if self.limit <= 0.0 {
285            return 0;
286        }
287        ((self.used / self.limit) * 100.0)
288            .round()
289            .clamp(0.0, 9999.0) as i32
290    }
291}
292
293/// Kimi Code — weekly subscription quota plus a 5h rolling rate-limit window.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct KimiSnapshot {
296    pub plan: Option<String>,
297    pub weekly_limit: u64,
298    pub weekly_used: u64,
299    pub weekly_remaining: u64,
300    pub weekly_reset_at: Option<DateTime<Utc>>,
301    pub window_limit: u64,
302    pub window_used: u64,
303    pub window_remaining: u64,
304    pub window_reset_at: Option<DateTime<Utc>>,
305}
306
307impl KimiSnapshot {
308    fn pct(used: u64, limit: u64) -> i32 {
309        if limit == 0 {
310            0
311        } else {
312            // Keep all quota values exact: f64 loses integer precision above
313            // 2^53. This is the integer equivalent of round(used / limit *
314            // 100), with saturation for inconsistent upstream counters.
315            let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
316            pct.min(100) as i32
317        }
318    }
319
320    /// Percentage of the weekly subscription quota consumed (0..=100).
321    pub fn weekly_pct(&self) -> i32 {
322        Self::pct(self.weekly_used, self.weekly_limit)
323    }
324
325    /// Percentage of the rolling rate-limit window consumed (0..=100).
326    pub fn window_pct(&self) -> i32 {
327        Self::pct(self.window_used, self.window_limit)
328    }
329}
330
331/// Discriminated union of vendor-specific snapshots. The widget and TUI match
332/// on this to pick a renderer.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub enum VendorSnapshot {
335    Anthropic(AnthropicSnapshot),
336    Openai(OpenAiSnapshot),
337    Copilot(crate::copilot::types::Snapshot),
338    Zai(ZaiSnapshot),
339    Openrouter(OpenRouterSnapshot),
340    Deepseek(DeepseekSnapshot),
341    Kimi(KimiSnapshot),
342    Kilo(KiloSnapshot),
343    Novita(NovitaSnapshot),
344    Moonshot(MoonshotSnapshot),
345    Grok(GrokSnapshot),
346    SuperGrok(SuperGrokSnapshot),
347    AnthropicApi(AnthropicApiSnapshot),
348    Antigravity(AntigravitySnapshot),
349    Cursor(CursorSnapshot),
350    Minimax(MinimaxSnapshot),
351    Kiro(KiroSnapshot),
352    NousResearch(crate::nous::types::AccountSnapshot),
353    OpenCodeGo(crate::opencode_go::types::Usage),
354    CommandCode(crate::commandcode::types::Snapshot),
355}
356
357/// Google Antigravity 2.0 / CLI snapshot. The API groups models into Gemini
358/// and third-party (Claude/GPT) buckets, and each group may carry a 5-hour and
359/// a weekly window — up to four, and not every product or plan offers all of
360/// them. Antigravity CLI 1.1.22 returns weekly buckets only, so every window is
361/// optional and a snapshot is valid when at least one arrived.
362#[derive(Debug, Clone, PartialEq)]
363pub struct AntigravitySnapshot {
364    pub plan: String,
365    /// Fingerprint of the signed-in account. Never displayed — it exists so a
366    /// cache written for one Google account is not served for another.
367    pub account: String,
368    /// Gemini group, 5-hour window.
369    pub session: Option<UsageWindow>,
370    /// Gemini group, weekly window.
371    pub weekly: Option<UsageWindow>,
372    /// Claude/GPT group, 5-hour window.
373    pub third_party_session: Option<UsageWindow>,
374    /// Claude/GPT group, weekly window.
375    pub third_party_weekly: Option<UsageWindow>,
376}
377
378impl Eq for AntigravitySnapshot {}
379
380/// MiniMax Token Plan — `/v1/token_plan/remains` returns one row per model
381/// bucket (`general` for text/coding, `video`), and each row carries its own
382/// rolling interval window plus a weekly window.
383///
384/// Two things the payload dictates rather than convention: the interval length
385/// is **not fixed** (`general` rolls every 5h, `video` every 24h), so the
386/// duration is derived from the row's own start/end rather than assumed; and
387/// the API reports the percentage **remaining**, which is inverted on the way
388/// in so these windows carry consumed-% like every other vendor's.
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct MinimaxSnapshot {
391    pub plan: String,
392    /// `general` bucket — rolling interval window (5h on the observed plans).
393    pub session: UsageWindow,
394    /// `general` bucket — weekly window.
395    pub weekly: UsageWindow,
396    /// `video` bucket, `None` on plans that carry no video quota.
397    pub video_session: Option<UsageWindow>,
398    pub video_weekly: Option<UsageWindow>,
399}
400
401/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
402/// monthly `limit` is supplied from config (the API exposes neither the limit
403/// nor the remaining prepaid credit balance).
404#[derive(Debug, Clone, PartialEq)]
405pub struct AnthropicApiSnapshot {
406    pub spent: f64,
407    pub limit: Option<f64>,
408}
409
410impl Eq for AnthropicApiSnapshot {}
411
412impl AnthropicApiSnapshot {
413    /// Spend as an integer percentage of the configured limit; `None` when no
414    /// positive limit is set.
415    pub fn pct(&self) -> Option<i32> {
416        self.limit
417            .filter(|l| l.is_finite() && *l > 0.0)
418            .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
419    }
420}
421
422/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
423/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
424#[derive(Debug, Clone, PartialEq)]
425pub struct KiloSnapshot {
426    pub label: String,
427    pub balance: f64,
428}
429
430impl Eq for KiloSnapshot {}
431
432/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
433/// all amounts already converted from the API's 1/10000-USD integers to USD.
434#[derive(Debug, Clone, PartialEq)]
435pub struct NovitaSnapshot {
436    /// Spendable credit balance (`availableBalance`).
437    pub available: f64,
438    /// Remaining top-up (`cashBalance`).
439    pub cash: f64,
440    /// Credit limit — max you can owe (`creditLimit`).
441    pub credit_limit: f64,
442    /// Amount currently owed (`outstandingInvoices`).
443    pub outstanding: f64,
444}
445
446impl Eq for NovitaSnapshot {}
447
448/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
449/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
450/// field in the response, so it's carried here from the region config.
451#[derive(Debug, Clone, PartialEq)]
452pub struct MoonshotSnapshot {
453    /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
454    /// the inference API.
455    pub available: f64,
456    /// Voucher credit (`voucher_balance`).
457    pub voucher: f64,
458    /// Cash balance (`cash_balance`); can be negative (debt).
459    pub cash: f64,
460    /// "USD" or "CNY", implied by the host.
461    pub currency: String,
462}
463
464impl Eq for MoonshotSnapshot {}
465
466/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
467/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
468#[derive(Debug, Clone, PartialEq)]
469pub struct GrokSnapshot {
470    pub balance: f64,
471}
472
473impl Eq for GrokSnapshot {}
474
475/// SuperGrok subscription usage returned by the official Grok Build CLI's
476/// credential-owning `x.ai/billing` ACP extension. Distinct from
477/// [`GrokSnapshot`] (Management API prepaid balance).
478#[derive(Debug, Clone, PartialEq)]
479pub struct SuperGrokSnapshot {
480    /// Subscription tier label when the billing response supplies one
481    /// (e.g. "SuperGrok", "SuperGrok Heavy"); otherwise `"SuperGrok"`.
482    pub plan: String,
483    /// Opaque digest of Grok auth/config state. Never displayed — cache
484    /// isolation only.
485    pub account: String,
486    /// Current included-credit usage percent. The field name is retained as a
487    /// compatibility alias for format/render code; [`Self::period`] says
488    /// whether the server's actual window is weekly or monthly.
489    pub weekly_pct: i32,
490    pub period: SuperGrokPeriod,
491    /// When the current usage period ends.
492    pub reset_at: Option<DateTime<Utc>>,
493    /// Remaining prepaid (purchased) API credit in USD, when present.
494    pub prepaid_balance: Option<f64>,
495}
496
497impl Eq for SuperGrokSnapshot {}
498
499#[derive(Debug, Clone, Copy, PartialEq, Eq)]
500pub enum SuperGrokPeriod {
501    Weekly,
502    Monthly,
503    Unknown,
504}
505
506impl SuperGrokPeriod {
507    pub fn label(self) -> &'static str {
508        match self {
509            Self::Weekly => "Weekly",
510            Self::Monthly => "Monthly",
511            Self::Unknown => "Current period",
512        }
513    }
514
515    pub fn short(self) -> &'static str {
516        match self {
517            Self::Weekly => "wk",
518            Self::Monthly => "mo",
519            Self::Unknown => "period",
520        }
521    }
522}
523
524/// OpenAI Codex OAuth — exposes whichever rolling windows the API reports.
525#[derive(Debug, Clone, PartialEq, Eq)]
526pub struct OpenAiSnapshot {
527    pub plan: String,
528    /// 5h window, identified by its duration rather than its wire position.
529    pub session: Option<UsageWindow>,
530    /// 7d window, identified by its duration rather than its wire position.
531    pub weekly: Option<UsageWindow>,
532    /// Optional 7d code-review bucket.
533    pub code_review: Option<UsageWindow>,
534    /// Optional credit balance + approximate message-count ranges.
535    pub credits: Option<OpenAiCredits>,
536    /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
537    /// the placeholder set and the "OpenAI does not expose this for Plus"
538    /// tooltip when the OAuth path isn't available.
539    pub source: OpenAiSource,
540}
541
542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
543pub enum OpenAiSource {
544    CodexOauth,
545    AdminKeyMtd,
546    Unavailable,
547}
548
549#[derive(Debug, Clone, PartialEq, Eq)]
550pub struct OpenAiCredits {
551    /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
552    /// a string because OpenAI returns it that way.
553    pub balance: String,
554    pub has_credits: bool,
555    pub unlimited: bool,
556    pub approx_local_messages: Option<(i64, i64)>,
557    pub approx_cloud_messages: Option<(i64, i64)>,
558}
559
560/// Z.AI / BigModel — list of buckets with discriminated types. We project the
561/// two we care about into named fields (5h tokens, weekly tokens, MCP).
562#[derive(Debug, Clone, PartialEq, Eq)]
563pub struct ZaiSnapshot {
564    pub plan: String,
565    pub session: Option<UsageWindow>,
566    pub weekly: Option<UsageWindow>,
567    pub mcp: Option<UsageWindow>,
568}
569
570/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
571/// `/api/v1/credits` and `/api/v1/key`.
572#[derive(Debug, Clone, PartialEq)]
573pub struct OpenRouterSnapshot {
574    pub label: String,
575    pub total_credits: f64,
576    pub total_usage: f64,
577    pub usage_daily: f64,
578    pub usage_weekly: f64,
579    pub usage_monthly: f64,
580    pub is_free_tier: bool,
581    pub limit: Option<f64>,
582    pub limit_remaining: Option<f64>,
583}
584
585impl Eq for OpenRouterSnapshot {}
586
587impl OpenRouterSnapshot {
588    /// Spendable credit, **which can be negative**: OpenRouter lets an account
589    /// run into debt, and clamping that to zero would report a healthy-looking
590    /// `$0.00` to someone who has to top up before anything works again. The
591    /// wire fields are each non-negative (see `openrouter::types`), so a
592    /// negative result only ever means usage has overrun credits.
593    pub fn balance(&self) -> f64 {
594        self.total_credits - self.total_usage
595    }
596    /// Percentage of total_credits consumed (0..=100). Returns 0 when
597    /// `total_credits` is 0 (free-tier-only accounts) — there is no
598    /// denominator to be a percentage of. Severity does not come from this
599    /// number alone: see [`crate::openrouter::vendor::severity`], which treats
600    /// a negative [`Self::balance`] as critical regardless of the percentage.
601    pub fn consumed_pct(&self) -> i32 {
602        if self.total_credits <= 0.0 {
603            return 0;
604        }
605        ((self.total_usage / self.total_credits) * 100.0)
606            .round()
607            .clamp(0.0, 100.0) as i32
608    }
609}
610
611/// Worst-of severity class for the Waybar bar text color. Mirrors
612/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
613pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
614    let mut max = snap.session.utilization_pct;
615    if snap.weekly.utilization_pct > max {
616        max = snap.weekly.utilization_pct;
617    }
618    if let Some(s) = &snap.sonnet
619        && s.utilization_pct > max
620    {
621        max = s.utilization_pct;
622    }
623    for sw in &snap.scoped {
624        if sw.window.utilization_pct > max {
625            max = sw.window.utilization_pct;
626        }
627    }
628    // Extra usage only promotes severity if a rate-limit window is at 100%.
629    let any_at_cap = snap.session.utilization_pct >= 100
630        || snap.weekly.utilization_pct >= 100
631        || snap
632            .sonnet
633            .as_ref()
634            .is_some_and(|s| s.utilization_pct >= 100)
635        || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
636    if any_at_cap && let Some(extra) = snap.extra.as_ref() {
637        let p = extra.percent();
638        if p > max {
639            max = p;
640        }
641    }
642    crate::pango::severity_for(max)
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use crate::pacing::PaceSeverity;
649    use chrono::Duration;
650
651    fn w(pct: i32) -> UsageWindow {
652        UsageWindow {
653            utilization_pct: pct,
654            resets_at: None,
655            window_duration: Duration::hours(5),
656        }
657    }
658
659    fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
660        AnthropicSnapshot {
661            plan: "Max 5x".into(),
662            session: w(s),
663            weekly: w(w_),
664            sonnet: sonnet.map(w),
665            scoped: vec![],
666            extra: extra.map(|(limit, spent)| ExtraUsage {
667                limit: Some(Cents(limit)),
668                spent: Cents(spent),
669                currency: None,
670                decimal_places: Some(2),
671            }),
672        }
673    }
674
675    #[test]
676    fn fmt_minor_honors_currency_and_scale() {
677        // No currency (older payloads) keeps the historical `$`.
678        assert_eq!(fmt_minor(250, 2, None), "$2.50");
679        // The #30 reporter's actual figures: BRL must not be claimed as `$`.
680        assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
681        assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
682        // Zero-exponent currency: no decimal point, no /100.
683        assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
684        // Sign precedes the symbol, matching `fmt_dollars`.
685        assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
686        // Unknown code stays truthful as a suffix rather than guessing a symbol.
687        assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
688    }
689
690    #[test]
691    fn extra_usage_formats_in_its_own_currency() {
692        let e = ExtraUsage {
693            limit: None,
694            spent: Cents(14157),
695            currency: Some("BRL".into()),
696            decimal_places: Some(2),
697        };
698        assert_eq!(e.fmt_spent(), "R$141.57");
699        assert_eq!(e.fmt_limit(), None);
700
701        let capped = ExtraUsage {
702            limit: Some(Cents(5000)),
703            spent: Cents(250),
704            currency: None,
705            decimal_places: Some(2),
706        };
707        assert_eq!(capped.fmt_spent(), "$2.50");
708        assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
709    }
710
711    #[test]
712    fn cents_format_positive() {
713        assert_eq!(Cents(0).fmt_dollars(), "$0.00");
714        assert_eq!(Cents(50).fmt_dollars(), "$0.50");
715        assert_eq!(Cents(250).fmt_dollars(), "$2.50");
716        assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
717    }
718
719    #[test]
720    fn cents_format_negative_uses_leading_sign() {
721        // claudebar bug-fix: never "$-1.-50" — sign goes before the dollar sign.
722        assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
723        assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
724    }
725
726    #[test]
727    fn extra_percent_with_zero_limit_is_zero() {
728        assert_eq!(
729            ExtraUsage {
730                limit: Some(Cents(0)),
731                spent: Cents(100),
732                currency: None,
733                decimal_places: Some(2),
734            }
735            .percent(),
736            0
737        );
738    }
739
740    #[test]
741    fn extra_percent_truncates() {
742        // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
743        assert_eq!(
744            ExtraUsage {
745                limit: Some(Cents(10000)),
746                spent: Cents(3333),
747                currency: None,
748                decimal_places: Some(2),
749            }
750            .percent(),
751            33
752        );
753    }
754
755    #[test]
756    fn severity_picks_worst_of_three_windows() {
757        let s = snap(40, 60, Some(80), None);
758        assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
759    }
760
761    #[test]
762    fn severity_ignores_extra_when_no_cap_hit() {
763        // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
764        let s = snap(50, 60, None, Some((10000, 9500)));
765        assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
766    }
767
768    #[test]
769    fn severity_promotes_extra_when_session_at_100() {
770        let s = snap(100, 50, None, Some((10000, 9500)));
771        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
772    }
773
774    #[test]
775    fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
776        // session = 100, weekly = 50, extra = 100% → max should be 100.
777        let s = snap(100, 50, None, Some((10000, 10000)));
778        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
779    }
780
781    fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
782        s.scoped.push(ScopedWindow {
783            label: "Fable".into(),
784            window: w(pct),
785        });
786        s
787    }
788
789    #[test]
790    fn severity_includes_scoped_windows() {
791        // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
792        // week at 84 → the bar class must escalate to High.
793        let s = with_scoped(snap(10, 55, None, None), 84);
794        assert_eq!(anthropic_severity(&s), PaceSeverity::High);
795    }
796
797    #[test]
798    fn severity_promotes_extra_when_scoped_at_100() {
799        // A scoped window at cap counts as a rate-limit cap hit, so extra
800        // usage above the window max is promoted — same rule as session/weekly.
801        let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
802        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
803    }
804
805    #[test]
806    fn kimi_percent_is_exact_above_f64_precision() {
807        let snap = KimiSnapshot {
808            plan: None,
809            weekly_limit: (1 << 53) + 1,
810            weekly_used: 1 << 52,
811            weekly_remaining: 0,
812            weekly_reset_at: None,
813            window_limit: u64::MAX,
814            window_used: u64::MAX - 1,
815            window_remaining: 0,
816            window_reset_at: None,
817        };
818        assert_eq!(snap.weekly_pct(), 50);
819        assert_eq!(snap.window_pct(), 100);
820    }
821
822    #[test]
823    fn kiro_pct_is_zero_without_a_positive_limit() {
824        let snap = KiroSnapshot {
825            plan: "FREE".into(),
826            used: 5.0,
827            limit: 0.0,
828            reset_at: None,
829        };
830        assert_eq!(snap.pct(), 0);
831    }
832
833    #[test]
834    fn kiro_pct_rounds_the_credit_ratio() {
835        let snap = KiroSnapshot {
836            plan: "KIRO POWER".into(),
837            used: 1.0,
838            limit: 3.0,
839            reset_at: None,
840        };
841        assert_eq!(snap.pct(), 33);
842    }
843}