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    /// On-demand spend in cents (`onDemand.used`), when Cursor reports it.
249    pub on_demand_used_cents: Option<i64>,
250    /// Configured on-demand spending limit in cents (`onDemand.limit`).
251    pub on_demand_limit_cents: Option<i64>,
252    /// End of the current billing cycle (`billingCycleEnd`) — when the pools
253    /// reset.
254    pub reset_at: Option<DateTime<Utc>>,
255    /// Start of the current billing cycle (`billingCycleStart`), when the API
256    /// sends it. With `reset_at` it gives the exact window length the pace
257    /// projection needs; absent, no window length is reported at all.
258    pub cycle_start: Option<DateTime<Utc>>,
259}
260
261impl CursorSnapshot {
262    /// Length of the current billing cycle, but only when the API stated both
263    /// ends and they are ordered. `None` otherwise — older responses and every
264    /// snapshot cached before `billingCycleStart` existed omit the start, and a
265    /// guessed month would reach a frontend as an exact window and be paced as
266    /// one. `window_secs` is absent instead; the reset time still shows.
267    pub fn cycle_window(&self) -> Option<chrono::Duration> {
268        match (self.cycle_start, self.reset_at) {
269            (Some(start), Some(end)) if end > start => Some(end - start),
270            _ => None,
271        }
272    }
273
274    /// The binding pool — whichever is closest to (or furthest past) its cap.
275    /// Drives the bar color and the single generic `session_pct` alias.
276    pub fn worst_pct(&self) -> i32 {
277        self.auto_pct.max(self.api_pct)
278    }
279}
280
281/// Kiro CLI (AWS CodeWhisperer / Q Developer backend) — a single credit pool
282/// from `AmazonCodeWhispererService.GetUsageLimits`, the same call kiro-cli's
283/// own `/usage` slash command makes. Authenticated with the AWS SSO OIDC
284/// bearer token kiro-cli already cached locally, refreshed with the paired
285/// refresh token when it's close to expiry — see `kiro::db` and `kiro::oauth`.
286#[derive(Debug, Clone, PartialEq)]
287pub struct KiroSnapshot {
288    /// Subscription tier label (`subscriptionInfo.subscriptionTitle`, e.g.
289    /// "KIRO POWER").
290    pub plan: String,
291    /// Credits consumed this cycle (`currentUsageWithPrecision`).
292    pub used: f64,
293    /// Credits included in the plan (`usageLimitWithPrecision`).
294    pub limit: f64,
295    /// When the credit pool resets (`nextDateReset`).
296    pub reset_at: Option<DateTime<Utc>>,
297}
298
299impl Eq for KiroSnapshot {}
300
301impl KiroSnapshot {
302    /// Percentage of the credit pool consumed, rounded. `0` when `limit` is
303    /// not positive — defensive; the API has not been observed to send that.
304    pub fn pct(&self) -> i32 {
305        if self.limit <= 0.0 {
306            return 0;
307        }
308        ((self.used / self.limit) * 100.0)
309            .round()
310            .clamp(0.0, 9999.0) as i32
311    }
312}
313
314/// Kimi Code — weekly subscription quota plus a 5h rolling rate-limit window.
315///
316/// Accounts on the newer `/coding/v1/usages` response shape have no weekly
317/// counters at all: the top-level `usage` block is replaced by a `usages` map
318/// of ratios, of which only `limit_month_total` — the combined monthly pool —
319/// is read. On such accounts `has_weekly` is false, the weekly counters stay
320/// zero (never fabricated), and the monthly pool arrives as
321/// `monthly_pct`/`monthly_reset_at`.
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct KimiSnapshot {
324    pub plan: Option<String>,
325    pub weekly_limit: u64,
326    pub weekly_used: u64,
327    pub weekly_remaining: u64,
328    pub weekly_reset_at: Option<DateTime<Utc>>,
329    /// `false` on the newer `usages`-map shape, which exposes no weekly
330    /// bucket; renderers must drop the weekly row rather than draw zeros.
331    pub has_weekly: bool,
332    /// Combined monthly pool usage (0..=100) on the newer shape. The
333    /// `limit_month_code` entry is the Code slice *inside* that pool, never
334    /// its own allowance, so it is not carried here.
335    pub monthly_pct: Option<i32>,
336    pub monthly_reset_at: Option<DateTime<Utc>>,
337    pub window_limit: u64,
338    pub window_used: u64,
339    pub window_remaining: u64,
340    pub window_reset_at: Option<DateTime<Utc>>,
341}
342
343impl KimiSnapshot {
344    fn pct(used: u64, limit: u64) -> i32 {
345        if limit == 0 {
346            0
347        } else {
348            // Keep all quota values exact: f64 loses integer precision above
349            // 2^53. This is the integer equivalent of round(used / limit *
350            // 100), with saturation for inconsistent upstream counters.
351            let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
352            pct.min(100) as i32
353        }
354    }
355
356    /// Percentage of the weekly subscription quota consumed (0..=100).
357    pub fn weekly_pct(&self) -> i32 {
358        Self::pct(self.weekly_used, self.weekly_limit)
359    }
360
361    /// Percentage of the rolling rate-limit window consumed (0..=100).
362    pub fn window_pct(&self) -> i32 {
363        Self::pct(self.window_used, self.window_limit)
364    }
365
366    /// Worst percentage across the windows this snapshot actually has: the
367    /// rolling window, the weekly quota when present, and the monthly pool
368    /// when present.
369    pub fn worst_pct(&self) -> i32 {
370        let mut worst = self.window_pct();
371        if self.has_weekly {
372            worst = worst.max(self.weekly_pct());
373        }
374        if let Some(monthly) = self.monthly_pct {
375            worst = worst.max(monthly);
376        }
377        worst
378    }
379}
380
381/// Discriminated union of vendor-specific snapshots. The widget and TUI match
382/// on this to pick a renderer.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub enum VendorSnapshot {
385    Anthropic(AnthropicSnapshot),
386    Openai(OpenAiSnapshot),
387    Copilot(crate::copilot::types::Snapshot),
388    Zai(ZaiSnapshot),
389    Openrouter(OpenRouterSnapshot),
390    Deepseek(DeepseekSnapshot),
391    Kimi(KimiSnapshot),
392    Kilo(KiloSnapshot),
393    Novita(NovitaSnapshot),
394    Moonshot(MoonshotSnapshot),
395    Grok(GrokSnapshot),
396    SuperGrok(SuperGrokSnapshot),
397    Grokbot(GrokbotSnapshot),
398    AnthropicApi(AnthropicApiSnapshot),
399    Antigravity(AntigravitySnapshot),
400    Cursor(CursorSnapshot),
401    Minimax(MinimaxSnapshot),
402    Kiro(KiroSnapshot),
403    NousResearch(crate::nous::types::AccountSnapshot),
404    OpenCodeGo(crate::opencode_go::types::Usage),
405    CommandCode(crate::commandcode::types::Snapshot),
406    Ollama(OllamaSnapshot),
407    OrcaRouter(OrcaRouterSnapshot),
408    ModelStudio(ModelStudioSnapshot),
409    /// A `[[custom]]` provider. Which one is not in the snapshot: the caller
410    /// that fetched it holds the `CustomProviderConfig`, and the cache
411    /// directory is keyed by its `id`.
412    Custom(crate::custom::types::CustomSnapshot),
413}
414
415/// Google Antigravity 2.0 / CLI snapshot. The API groups models into Gemini
416/// and third-party (Claude/GPT) buckets, and each group may carry a 5-hour and
417/// a weekly window — up to four, and not every product or plan offers all of
418/// them. Antigravity CLI 1.1.22 returns weekly buckets only, so every window is
419/// optional and a snapshot is valid when at least one arrived.
420#[derive(Debug, Clone, PartialEq)]
421pub struct AntigravitySnapshot {
422    pub plan: String,
423    /// Fingerprint of the signed-in account. Never displayed — it exists so a
424    /// cache written for one Google account is not served for another.
425    pub account: String,
426    /// Where the figures came from: a running local product, or the Cloud
427    /// Code API reached with the saved Google session when no local server
428    /// can answer — none is running, or `agy` withholds its CSRF token.
429    pub source: AntigravitySource,
430    /// Gemini group, 5-hour window.
431    pub session: Option<UsageWindow>,
432    /// Gemini group, weekly window.
433    pub weekly: Option<UsageWindow>,
434    /// Claude/GPT group, 5-hour window.
435    pub third_party_session: Option<UsageWindow>,
436    /// Claude/GPT group, weekly window.
437    pub third_party_weekly: Option<UsageWindow>,
438}
439
440impl Eq for AntigravitySnapshot {}
441
442/// Which path produced an [`AntigravitySnapshot`]. The local language server
443/// is the primary source; the remote API is the fallback for when no product
444/// is running, and the panel says so because the two can disagree briefly.
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
446pub enum AntigravitySource {
447    #[default]
448    Local,
449    Remote,
450}
451
452impl AntigravitySource {
453    pub fn as_str(self) -> &'static str {
454        match self {
455            AntigravitySource::Local => "local",
456            AntigravitySource::Remote => "remote",
457        }
458    }
459
460    /// Inverse of [`as_str`](Self::as_str); anything unrecognised is `None` so
461    /// a cache reader can fall back to the default rather than guess.
462    pub fn parse(s: &str) -> Option<Self> {
463        match s {
464            "local" => Some(AntigravitySource::Local),
465            "remote" => Some(AntigravitySource::Remote),
466            _ => None,
467        }
468    }
469}
470
471/// MiniMax Token Plan — `/v1/token_plan/remains` returns one row per model
472/// bucket (`general` for text/coding, `video`), and each row carries its own
473/// rolling interval window plus a weekly window.
474///
475/// Two things the payload dictates rather than convention: the interval length
476/// is **not fixed** (`general` rolls every 5h, `video` every 24h), so the
477/// duration is derived from the row's own start/end rather than assumed; and
478/// the API reports the percentage **remaining**, which is inverted on the way
479/// in so these windows carry consumed-% like every other vendor's.
480#[derive(Debug, Clone, PartialEq, Eq)]
481pub struct MinimaxSnapshot {
482    pub plan: String,
483    /// `general` bucket — rolling interval window (5h on the observed plans).
484    pub session: UsageWindow,
485    /// `general` bucket — weekly window.
486    pub weekly: UsageWindow,
487    /// `video` bucket, `None` on plans that carry no video quota.
488    pub video_session: Option<UsageWindow>,
489    pub video_weekly: Option<UsageWindow>,
490}
491
492/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
493/// monthly `limit` is supplied from config (the API exposes neither the limit
494/// nor the remaining prepaid credit balance).
495#[derive(Debug, Clone, PartialEq)]
496pub struct AnthropicApiSnapshot {
497    pub spent: f64,
498    pub limit: Option<f64>,
499}
500
501impl Eq for AnthropicApiSnapshot {}
502
503impl AnthropicApiSnapshot {
504    /// Spend as an integer percentage of the configured limit; `None` when no
505    /// positive limit is set.
506    pub fn pct(&self) -> Option<i32> {
507        self.limit
508            .filter(|l| l.is_finite() && *l > 0.0)
509            .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
510    }
511}
512
513/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
514/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
515#[derive(Debug, Clone, PartialEq)]
516pub struct KiloSnapshot {
517    pub label: String,
518    pub balance: f64,
519}
520
521impl Eq for KiloSnapshot {}
522
523/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
524/// all amounts already converted from the API's 1/10000-USD integers to USD.
525#[derive(Debug, Clone, PartialEq)]
526pub struct NovitaSnapshot {
527    /// Spendable credit balance (`availableBalance`).
528    pub available: f64,
529    /// Remaining top-up (`cashBalance`).
530    pub cash: f64,
531    /// Credit limit — max you can owe (`creditLimit`).
532    pub credit_limit: f64,
533    /// Amount currently owed (`outstandingInvoices`).
534    pub outstanding: f64,
535}
536
537impl Eq for NovitaSnapshot {}
538
539/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
540/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
541/// field in the response, so it's carried here from the region config.
542#[derive(Debug, Clone, PartialEq)]
543pub struct MoonshotSnapshot {
544    /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
545    /// the inference API.
546    pub available: f64,
547    /// Voucher credit (`voucher_balance`).
548    pub voucher: f64,
549    /// Cash balance (`cash_balance`); can be negative (debt).
550    pub cash: f64,
551    /// "USD" or "CNY", implied by the host.
552    pub currency: String,
553}
554
555impl Eq for MoonshotSnapshot {}
556
557/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
558/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
559#[derive(Debug, Clone, PartialEq)]
560pub struct GrokSnapshot {
561    pub balance: f64,
562}
563
564impl Eq for GrokSnapshot {}
565
566/// SuperGrok subscription usage from Grok Build's billing endpoint (ACP as
567/// fallback) plus banked remaining-resets. Distinct from [`GrokSnapshot`]
568/// (Management API prepaid balance).
569#[derive(Debug, Clone, PartialEq)]
570pub struct SuperGrokSnapshot {
571    /// Subscription tier label when the billing response supplies one
572    /// (e.g. "SuperGrok", "SuperGrok Heavy"); otherwise `"SuperGrok"`.
573    pub plan: String,
574    /// Opaque digest of Grok auth/config state. Never displayed — cache
575    /// isolation only.
576    pub account: String,
577    /// Current included-credit usage percent. The field name is retained as a
578    /// compatibility alias for format/render code; [`Self::period`] says
579    /// whether the server's actual window is weekly or monthly.
580    pub weekly_pct: i32,
581    pub period: SuperGrokPeriod,
582    /// When the current usage period ends.
583    pub reset_at: Option<DateTime<Utc>>,
584    /// Remaining prepaid (purchased) API credit in USD, when present.
585    pub prepaid_balance: Option<f64>,
586    pub reset_credits: ResetCredits,
587    /// Per-product slices of the same included-credit pool (`GrokBuild`,
588    /// `GrokChat`, `GrokImagine`, …). Empty when the billing document omits
589    /// `productUsage`.
590    pub products: Vec<SuperGrokProduct>,
591}
592
593/// One SuperGrok product's share of the current included-credit window.
594#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
595pub struct SuperGrokProduct {
596    pub label: String,
597    pub percent: i32,
598}
599
600impl Eq for SuperGrokSnapshot {}
601
602#[derive(Debug, Clone, Copy, PartialEq, Eq)]
603pub enum SuperGrokPeriod {
604    Weekly,
605    Monthly,
606    Unknown,
607}
608
609impl SuperGrokPeriod {
610    pub fn label(self) -> &'static str {
611        match self {
612            Self::Weekly => "Weekly",
613            Self::Monthly => "Monthly",
614            Self::Unknown => "Current period",
615        }
616    }
617
618    pub fn short(self) -> &'static str {
619        match self {
620            Self::Weekly => "wk",
621            Self::Monthly => "mo",
622            Self::Unknown => "period",
623        }
624    }
625}
626
627/// Grok Bot desktop app — the weekly included-usage pool from
628/// `aiserver.v1.DashboardService/GetSandUsageStatus` (Connect-RPC), read with
629/// the app's own OAuth session. Distinct from [`GrokSnapshot`] (Management
630/// API prepaid dollars) and [`SuperGrokSnapshot`] (Grok Build subscription).
631#[derive(Debug, Clone, PartialEq, Eq)]
632pub struct GrokbotSnapshot {
633    /// `grokPlanLabel`, falling back to `cursorPlanName`, then "Grok Bot".
634    pub plan: String,
635    /// `hasNonZeroIncludedLimit`. When false the account carries no included
636    /// allowance at all — a distinct "no included allowance" state, never a
637    /// fabricated 0% meter.
638    pub has_included_allowance: bool,
639    /// `usagePercent` of the included pool (0..=100). Meaningful only when
640    /// `has_included_allowance` is set.
641    pub weekly_pct: i32,
642    /// `hasAvailableUsage` — the account can still serve requests, which at
643    /// 100% of the included pool means on-demand is picking up the rest.
644    pub has_available_usage: bool,
645    /// `onDemandSettings.enabled` — pay-as-you-go past the included pool.
646    pub on_demand_enabled: bool,
647    /// `currentPeriodStart`.
648    pub period_start: Option<DateTime<Utc>>,
649    /// `nextResetTimestampUtc`.
650    pub reset_at: Option<DateTime<Utc>>,
651    /// `reset_at − period_start` when both are reported (7 days on the
652    /// captured account) — computed, never assumed.
653    pub window: Option<chrono::Duration>,
654}
655
656impl GrokbotSnapshot {
657    /// At 100% of the included pool, `hasAvailableUsage` can still be true
658    /// because on-demand keeps serving — say so, but only when the account
659    /// actually has on-demand switched on.
660    pub fn on_demand_note(&self) -> Option<&'static str> {
661        (self.has_included_allowance
662            && self.weekly_pct >= 100
663            && self.has_available_usage
664            && self.on_demand_enabled)
665            .then_some("included pool exhausted — on-demand may still be serving usage")
666    }
667}
668
669/// OpenAI Codex OAuth — exposes whichever rolling windows the API reports.
670#[derive(Debug, Clone, PartialEq, Eq)]
671pub struct OpenAiSnapshot {
672    pub plan: String,
673    /// 5h window, identified by its duration rather than its wire position.
674    pub session: Option<UsageWindow>,
675    /// 7d window, identified by its duration rather than its wire position.
676    pub weekly: Option<UsageWindow>,
677    /// Optional 7d code-review bucket.
678    pub code_review: Option<UsageWindow>,
679    /// Named limits beside the main one, each with its own windows. Empty for
680    /// an account that has none.
681    pub additional_limits: Vec<OpenAiNamedLimit>,
682    /// Models the account currently cannot dispatch to, with the time they
683    /// return when the API states one. Only unavailable models are kept: a
684    /// list of everything that *is* working is noise, and the reason this
685    /// exists is to explain a refusal no percentage accounts for.
686    pub unavailable_models: Vec<OpenAiUnavailableModel>,
687    /// Optional credit balance + approximate message-count ranges.
688    pub credits: Option<OpenAiCredits>,
689    pub reset_credits: ResetCredits,
690    /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
691    /// the placeholder set and the "OpenAI does not expose this for Plus"
692    /// tooltip when the OAuth path isn't available.
693    pub source: OpenAiSource,
694}
695
696/// A named limit that sits beside Codex's main window — a reserved pool or a
697/// model-specific allowance. It can be exhausted while the headline window is
698/// nearly untouched, which is the case it exists to make visible.
699#[derive(Debug, Clone, PartialEq, Eq)]
700pub struct OpenAiNamedLimit {
701    /// The API's own name for it, shown as given.
702    pub name: String,
703    pub session: Option<UsageWindow>,
704    pub weekly: Option<UsageWindow>,
705}
706
707/// A model the account cannot currently dispatch to.
708#[derive(Debug, Clone, PartialEq, Eq)]
709pub struct OpenAiUnavailableModel {
710    pub model: String,
711    /// When the API says it returns. `None` means it did not say.
712    pub available_at: Option<DateTime<Utc>>,
713}
714
715/// Banked, user-redeemable quota resets — Codex's "rate limit reset credits"
716/// and SuperGrok's "remaining resets" are the same idea under two names: a
717/// count you have earned, each with its own expiry, redeemed by hand rather
718/// than arriving on the window's own schedule. Distinct from a
719/// [`UsageWindow::resets_at`], which needs no action and cannot be banked.
720///
721/// The redemption identifier each provider returns alongside these
722/// (`credits[].id`, `tokens[].token_id`) is deliberately *not* carried here:
723/// it is the handle that spends the credit, and nothing that renders a status
724/// bar needs it.
725#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
726pub struct ResetCredits {
727    pub available: u32,
728    /// One row per credit the provider described. May be shorter than
729    /// `available` — Codex's usage endpoint gives the count without the
730    /// per-credit detail, and the detail call is allowed to fail on its own.
731    #[serde(default)]
732    pub credits: Vec<ResetCredit>,
733}
734
735#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
736pub struct ResetCredit {
737    /// Provider label when one exists ("Full reset (Weekly + 5 hr)"). SuperGrok
738    /// tokens have no title.
739    #[serde(default, skip_serializing_if = "Option::is_none")]
740    pub title: Option<String>,
741    #[serde(default, skip_serializing_if = "Option::is_none")]
742    pub expires_at: Option<DateTime<Utc>>,
743}
744
745impl ResetCredits {
746    pub fn is_empty(&self) -> bool {
747        self.available == 0
748    }
749
750    pub fn next_expiry(&self) -> Option<DateTime<Utc>> {
751        self.credits
752            .iter()
753            .filter_map(|credit| credit.expires_at)
754            .min()
755    }
756}
757
758#[derive(Debug, Clone, Copy, PartialEq, Eq)]
759pub enum OpenAiSource {
760    CodexOauth,
761    AdminKeyMtd,
762    Unavailable,
763}
764
765#[derive(Debug, Clone, PartialEq, Eq)]
766pub struct OpenAiCredits {
767    /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
768    /// a string because OpenAI returns it that way.
769    pub balance: String,
770    pub has_credits: bool,
771    pub unlimited: bool,
772    pub approx_local_messages: Option<(i64, i64)>,
773    pub approx_cloud_messages: Option<(i64, i64)>,
774}
775
776/// Z.AI / BigModel — list of buckets with discriminated types. We project the
777/// two we care about into named fields (5h tokens, weekly tokens, MCP).
778#[derive(Debug, Clone, PartialEq, Eq)]
779pub struct ZaiSnapshot {
780    pub plan: String,
781    pub session: Option<UsageWindow>,
782    pub weekly: Option<UsageWindow>,
783    pub mcp: Option<UsageWindow>,
784}
785
786/// Ollama Cloud — the session and weekly usage windows served by
787/// `ollama.com/api/usage`, plus a per-model breakdown. The response also
788/// carries an `activity.cost` string for the current period; we keep it raw
789/// (it is already dollar-formatted upstream) and let the renderer place it.
790#[derive(Debug, Clone, PartialEq, Eq)]
791pub struct OllamaSnapshot {
792    /// Display label, taken from the `[ollama] plan` config field. The API
793    /// itself does not report a plan name.
794    pub plan: String,
795    /// 5h rolling window (`limits.session`). `None` when the account has not
796    /// touched the cloud tier yet (the window is omitted from the response,
797    /// not reported as zero).
798    pub session: Option<UsageWindow>,
799    /// 7d rolling window (`limits.weekly`).
800    pub weekly: Option<UsageWindow>,
801    /// Calendar-month window (`limits.monthly`). Some Pro accounts report
802    /// this in place of `session`/`weekly` instead of alongside them.
803    pub monthly: Option<UsageWindow>,
804    /// Per-model request counts inside the session window, in the order the
805    /// API returned them. Renderers sort and truncate this for the tooltip.
806    pub session_models: Vec<OllamaModelUsage>,
807    /// Per-model request counts inside the weekly window.
808    pub weekly_models: Vec<OllamaModelUsage>,
809    /// Per-model request counts inside the monthly window.
810    pub monthly_models: Vec<OllamaModelUsage>,
811    /// `activity.cost` as a pre-formatted dollar string (`"0.00000"`,
812    /// `"1.23456"`). Already a string on the wire — the renderer decides
813    /// whether to keep it verbatim or reformat.
814    pub activity_cost: Option<String>,
815    /// `activity.period.type` (`"last_4_weeks"` and friends). A short
816    /// human-readable label the renderer can show next to the cost.
817    pub activity_period: Option<String>,
818}
819
820/// One row of `OllamaSnapshot::{session,weekly}_models`. The API carries the
821/// per-model request count; the percentage of the window that this single
822/// model represents is not reported, so the renderer derives it locally.
823#[derive(Debug, Clone, PartialEq, Eq)]
824pub struct OllamaModelUsage {
825    pub name: String,
826    pub request_count: u64,
827}
828
829/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
830/// `/api/v1/credits` and `/api/v1/key`.
831#[derive(Debug, Clone, PartialEq)]
832pub struct OpenRouterSnapshot {
833    pub label: String,
834    pub total_credits: f64,
835    pub total_usage: f64,
836    pub usage_daily: f64,
837    pub usage_weekly: f64,
838    pub usage_monthly: f64,
839    pub is_free_tier: bool,
840    pub limit: Option<f64>,
841    pub limit_remaining: Option<f64>,
842}
843
844impl Eq for OpenRouterSnapshot {}
845
846impl OpenRouterSnapshot {
847    /// Spendable credit, **which can be negative**: OpenRouter lets an account
848    /// run into debt, and clamping that to zero would report a healthy-looking
849    /// `$0.00` to someone who has to top up before anything works again. The
850    /// wire fields are each non-negative (see `openrouter::types`), so a
851    /// negative result only ever means usage has overrun credits.
852    pub fn balance(&self) -> f64 {
853        self.total_credits - self.total_usage
854    }
855    /// Percentage of total_credits consumed (0..=100). Returns 0 when
856    /// `total_credits` is 0 (free-tier-only accounts) — there is no
857    /// denominator to be a percentage of. Severity does not come from this
858    /// number alone: see [`crate::openrouter::vendor::severity`], which treats
859    /// a negative [`Self::balance`] as critical regardless of the percentage.
860    pub fn consumed_pct(&self) -> i32 {
861        if self.total_credits <= 0.0 {
862            return 0;
863        }
864        i32::from(crate::format::clamp_pct(
865            (self.total_usage / self.total_credits) * 100.0,
866        ))
867    }
868}
869
870/// OrcaRouter — prepaid credit card from the one-api compatible dashboard
871/// billing endpoints (`/v1/dashboard/billing/usage` + `/subscription`), over an
872/// API key. Usage arrives in **US cents** (`total_usage: 275` = $2.75); the
873/// subscription's limit fields are USD and mean the *total* credit limit
874/// (remaining + used), with `100000000` as the unlimited sentinel.
875#[derive(Debug, Clone, PartialEq, Eq)]
876pub struct OrcaRouterSnapshot {
877    /// Cumulative spend, exact US cents (`total_usage`).
878    pub spent_cents: i64,
879    /// Total credit limit in exact US cents. `None` for unlimited keys (the
880    /// `100000000` sentinel) or when the subscription response carried no
881    /// limit field at all — either way the card is spend-only.
882    pub limit_cents: Option<i64>,
883    /// Key expiry (`access_until`, Unix seconds); `None` = no expiry (a wire
884    /// `0` means the same thing).
885    pub access_until: Option<DateTime<Utc>>,
886}
887
888impl OrcaRouterSnapshot {
889    pub fn spent_usd(&self) -> f64 {
890        self.spent_cents as f64 / 100.0
891    }
892
893    pub fn limit_usd(&self) -> Option<f64> {
894        self.limit_cents.map(|c| c as f64 / 100.0)
895    }
896
897    /// Remaining credit in exact cents. Can be negative (spend past the
898    /// limit) — the sign belongs outside the symbol, like OpenRouter debt.
899    pub fn remaining_cents(&self) -> Option<i64> {
900        self.limit_cents.map(|limit| limit - self.spent_cents)
901    }
902
903    pub fn remaining_usd(&self) -> Option<f64> {
904        self.remaining_cents().map(|c| c as f64 / 100.0)
905    }
906
907    /// Integer-percentage of the limit consumed, computed in cents so no
908    /// float division is involved. `None` when there is no limit — an
909    /// unlimited key has no percentage to be exact *about*.
910    pub fn consumed_pct(&self) -> Option<i32> {
911        self.limit_cents.filter(|l| *l > 0).map(|limit| {
912            let pct = (self.spent_cents.saturating_mul(100)) / limit;
913            pct.clamp(0, 100) as i32
914        })
915    }
916}
917
918/// Alibaba Cloud Model Studio Token Plan — a 5-hour and a weekly ratio
919/// window, either of which the console account may not report. An absent
920/// window is no-data (possibly unlimited), never 0%.
921#[derive(Debug, Clone, PartialEq, Eq)]
922pub struct ModelStudioSnapshot {
923    /// 5-hour window. `None` when `per5HourPercentage` was absent.
924    pub session: Option<UsageWindow>,
925    /// Weekly window. `None` when `per1WeekPercentage` was absent.
926    pub weekly: Option<UsageWindow>,
927}
928
929/// Worst-of severity class for the Waybar bar text color. Mirrors
930/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
931pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
932    let mut max = snap.session.utilization_pct;
933    if snap.weekly.utilization_pct > max {
934        max = snap.weekly.utilization_pct;
935    }
936    if let Some(s) = &snap.sonnet
937        && s.utilization_pct > max
938    {
939        max = s.utilization_pct;
940    }
941    for sw in &snap.scoped {
942        if sw.window.utilization_pct > max {
943            max = sw.window.utilization_pct;
944        }
945    }
946    // Extra usage only promotes severity if a rate-limit window is at 100%.
947    let any_at_cap = snap.session.utilization_pct >= 100
948        || snap.weekly.utilization_pct >= 100
949        || snap
950            .sonnet
951            .as_ref()
952            .is_some_and(|s| s.utilization_pct >= 100)
953        || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
954    if any_at_cap && let Some(extra) = snap.extra.as_ref() {
955        let p = extra.percent();
956        if p > max {
957            max = p;
958        }
959    }
960    crate::pango::severity_for(max)
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966    use crate::pacing::PaceSeverity;
967    use chrono::Duration;
968
969    fn w(pct: i32) -> UsageWindow {
970        UsageWindow {
971            utilization_pct: pct,
972            resets_at: None,
973            window_duration: Duration::hours(5),
974        }
975    }
976
977    fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
978        AnthropicSnapshot {
979            plan: "Max 5x".into(),
980            session: w(s),
981            weekly: w(w_),
982            sonnet: sonnet.map(w),
983            scoped: vec![],
984            extra: extra.map(|(limit, spent)| ExtraUsage {
985                limit: Some(Cents(limit)),
986                spent: Cents(spent),
987                currency: None,
988                decimal_places: Some(2),
989            }),
990        }
991    }
992
993    #[test]
994    fn fmt_minor_honors_currency_and_scale() {
995        // No currency (older payloads) keeps the historical `$`.
996        assert_eq!(fmt_minor(250, 2, None), "$2.50");
997        // The #30 reporter's actual figures: BRL must not be claimed as `$`.
998        assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
999        assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
1000        // Zero-exponent currency: no decimal point, no /100.
1001        assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
1002        // Sign precedes the symbol, matching `fmt_dollars`.
1003        assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
1004        // Unknown code stays truthful as a suffix rather than guessing a symbol.
1005        assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
1006    }
1007
1008    #[test]
1009    fn extra_usage_formats_in_its_own_currency() {
1010        let e = ExtraUsage {
1011            limit: None,
1012            spent: Cents(14157),
1013            currency: Some("BRL".into()),
1014            decimal_places: Some(2),
1015        };
1016        assert_eq!(e.fmt_spent(), "R$141.57");
1017        assert_eq!(e.fmt_limit(), None);
1018
1019        let capped = ExtraUsage {
1020            limit: Some(Cents(5000)),
1021            spent: Cents(250),
1022            currency: None,
1023            decimal_places: Some(2),
1024        };
1025        assert_eq!(capped.fmt_spent(), "$2.50");
1026        assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
1027    }
1028
1029    #[test]
1030    fn cents_format_positive() {
1031        assert_eq!(Cents(0).fmt_dollars(), "$0.00");
1032        assert_eq!(Cents(50).fmt_dollars(), "$0.50");
1033        assert_eq!(Cents(250).fmt_dollars(), "$2.50");
1034        assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
1035    }
1036
1037    #[test]
1038    fn cents_format_negative_uses_leading_sign() {
1039        // claudebar bug-fix: never "$-1.-50" — sign goes before the dollar sign.
1040        assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
1041        assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
1042    }
1043
1044    #[test]
1045    fn extra_percent_with_zero_limit_is_zero() {
1046        assert_eq!(
1047            ExtraUsage {
1048                limit: Some(Cents(0)),
1049                spent: Cents(100),
1050                currency: None,
1051                decimal_places: Some(2),
1052            }
1053            .percent(),
1054            0
1055        );
1056    }
1057
1058    #[test]
1059    fn extra_percent_truncates() {
1060        // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
1061        assert_eq!(
1062            ExtraUsage {
1063                limit: Some(Cents(10000)),
1064                spent: Cents(3333),
1065                currency: None,
1066                decimal_places: Some(2),
1067            }
1068            .percent(),
1069            33
1070        );
1071    }
1072
1073    #[test]
1074    fn severity_picks_worst_of_three_windows() {
1075        let s = snap(40, 60, Some(80), None);
1076        assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
1077    }
1078
1079    #[test]
1080    fn severity_ignores_extra_when_no_cap_hit() {
1081        // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
1082        let s = snap(50, 60, None, Some((10000, 9500)));
1083        assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
1084    }
1085
1086    #[test]
1087    fn severity_promotes_extra_when_session_at_100() {
1088        let s = snap(100, 50, None, Some((10000, 9500)));
1089        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
1090    }
1091
1092    #[test]
1093    fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
1094        // session = 100, weekly = 50, extra = 100% → max should be 100.
1095        let s = snap(100, 50, None, Some((10000, 10000)));
1096        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
1097    }
1098
1099    fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
1100        s.scoped.push(ScopedWindow {
1101            label: "Fable".into(),
1102            window: w(pct),
1103        });
1104        s
1105    }
1106
1107    #[test]
1108    fn severity_includes_scoped_windows() {
1109        // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
1110        // week at 84 → the bar class must escalate to High.
1111        let s = with_scoped(snap(10, 55, None, None), 84);
1112        assert_eq!(anthropic_severity(&s), PaceSeverity::High);
1113    }
1114
1115    #[test]
1116    fn severity_promotes_extra_when_scoped_at_100() {
1117        // A scoped window at cap counts as a rate-limit cap hit, so extra
1118        // usage above the window max is promoted — same rule as session/weekly.
1119        let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
1120        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
1121    }
1122
1123    #[test]
1124    fn kimi_percent_is_exact_above_f64_precision() {
1125        let snap = KimiSnapshot {
1126            plan: None,
1127            weekly_limit: (1 << 53) + 1,
1128            weekly_used: 1 << 52,
1129            weekly_remaining: 0,
1130            weekly_reset_at: None,
1131            has_weekly: true,
1132            monthly_pct: None,
1133            monthly_reset_at: None,
1134            window_limit: u64::MAX,
1135            window_used: u64::MAX - 1,
1136            window_remaining: 0,
1137            window_reset_at: None,
1138        };
1139        assert_eq!(snap.weekly_pct(), 50);
1140        assert_eq!(snap.window_pct(), 100);
1141    }
1142
1143    #[test]
1144    fn kiro_pct_is_zero_without_a_positive_limit() {
1145        let snap = KiroSnapshot {
1146            plan: "FREE".into(),
1147            used: 5.0,
1148            limit: 0.0,
1149            reset_at: None,
1150        };
1151        assert_eq!(snap.pct(), 0);
1152    }
1153
1154    #[test]
1155    fn kiro_pct_rounds_the_credit_ratio() {
1156        let snap = KiroSnapshot {
1157            plan: "KIRO POWER".into(),
1158            used: 1.0,
1159            limit: 3.0,
1160            reset_at: None,
1161        };
1162        assert_eq!(snap.pct(), 33);
1163    }
1164}