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