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
65/// Anthropic-specific snapshot — three rolling windows plus optional
66/// pay-as-you-go credit balance.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct AnthropicSnapshot {
69    /// "Claude Pro", "Claude Max 5x", "Claude Max 20x", etc.
70    pub plan: String,
71    pub session: UsageWindow,
72    pub weekly: UsageWindow,
73    /// Some vendors of Claude (Pro, some Max tiers) don't have a separate
74    /// Sonnet bucket — in which case this is None.
75    pub sonnet: Option<UsageWindow>,
76    /// Model-scoped weekly windows from the newer `limits[]` array
77    /// (`kind == "weekly_scoped"`), e.g. the Fable weekly cap. Labels come
78    /// from the API (`scope.model.display_name`), so new models show up
79    /// without a code change. Empty when the account has none.
80    pub scoped: Vec<ScopedWindow>,
81    /// `None` when the block is absent, the user disabled extra usage, or the
82    /// response has no spend worth reporting. Provider-disabled exhausted
83    /// usage stays visible with an explicit status.
84    pub extra: Option<ExtraUsage>,
85    /// Banked limit resets from the `cedar_ember` block — the same idea as
86    /// Codex's rate-limit reset credits and SuperGrok's remaining resets.
87    /// Empty when the account has no grant or the endpoint withheld the block.
88    pub reset_credits: ResetCredits,
89}
90
91/// A usage window scoped to a specific model, labeled by the API
92/// (e.g. "Fable"). Weekly (7d) duration.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct ScopedWindow {
95    pub label: String,
96    pub window: UsageWindow,
97}
98
99/// "Extra usage" pay-as-you-go block (claudebar's `extra_usage`).
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ExtraUsage {
102    /// `None` when the payload carries no usable `monthly_limit` — an
103    /// explicit null (observed for plans without a spending cap, e.g. Claude
104    /// Pro, #30) or an absent field. Either way the spend is real and stays
105    /// visible; only the limit is unreported, and the renderers say exactly
106    /// that rather than inferring a plan tier from it.
107    pub limit: Option<Cents>,
108    pub spent: Cents,
109    /// ISO code from the block (`"BRL"`, `"USD"`, …). `None` on older payloads
110    /// that predate the field — formatted as `$` for back-compat, which was
111    /// the only behaviour before the field existed.
112    pub currency: Option<String>,
113    /// Minor-unit digits from the block's `decimal_places` (BRL/USD = 2,
114    /// JPY/KRW = 0). `None` means the wire did not report the scale. We keep
115    /// that absence instead of guessing from an incomplete currency table.
116    pub decimal_places: Option<u32>,
117    /// Why extra usage cannot currently continue. Enabled usage has `Active`.
118    pub status: ExtraUsageStatus,
119}
120
121#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
122pub enum ExtraUsageStatus {
123    #[default]
124    Active,
125    OutOfCredits,
126    MonthlyLimitReached,
127    Unavailable,
128}
129
130impl ExtraUsage {
131    /// Integer percentage of the monthly limit consumed (0..=100, saturating
132    /// at 0 when limit is non-positive — matches claudebar:540-542).
133    ///
134    /// With no cap there is no denominator, so no meaningful percentage
135    /// exists; 0 keeps the bar and severity calm rather than inventing one.
136    pub fn percent(&self) -> i32 {
137        match self.limit {
138            Some(l) if l.0 > 0 => ((self.spent.0 * 100) / l.0) as i32,
139            _ => 0,
140        }
141    }
142
143    pub fn fmt_spent(&self) -> String {
144        self.fmt_amount(self.spent)
145    }
146
147    pub fn fmt_limit(&self) -> Option<String> {
148        self.limit.map(|l| self.fmt_amount(l))
149    }
150
151    pub fn status_note(&self) -> Option<&'static str> {
152        match self.status {
153            ExtraUsageStatus::Active => None,
154            ExtraUsageStatus::OutOfCredits => Some("out of credits"),
155            ExtraUsageStatus::MonthlyLimitReached => Some("monthly limit reached"),
156            ExtraUsageStatus::Unavailable => Some("currently unavailable"),
157        }
158    }
159
160    fn fmt_amount(&self, amount: Cents) -> String {
161        match (self.decimal_places, self.currency.as_deref()) {
162            (Some(decimal_places), currency) => fmt_minor(amount.0, decimal_places, currency),
163            // Legacy payloads predate both fields and were always cents/USD.
164            // Preserve that established behaviour only when neither field can
165            // tell us otherwise.
166            (None, None) => fmt_minor(amount.0, 2, None),
167            // A currency code alone does not determine its ISO minor-unit
168            // exponent. Keep the amount truthful instead of silently dividing
169            // zero-, three-, or four-decimal currencies by the wrong scale.
170            (None, Some(currency)) => fmt_minor_units(amount.0, currency),
171        }
172    }
173}
174
175fn fmt_minor_units(minor: i64, currency: &str) -> String {
176    let sign = if minor < 0 { "-" } else { "" };
177    format!("{sign}{} minor units {currency}", minor.unsigned_abs())
178}
179
180/// Format an amount in minor units with its own currency and scale.
181///
182/// The scale is this function's own — `money` cannot express a zero- or
183/// three-decimal currency — but the *symbol* comes from
184/// [`crate::format::with_currency`], so the two cannot disagree about what a
185/// given code looks like.
186pub fn fmt_minor(minor: i64, decimal_places: u32, currency: Option<&str>) -> String {
187    let scale = 10_u64.pow(decimal_places);
188    // `unsigned_abs`, not negation: `-i64::MIN` overflows. Unreachable from
189    // the wire (the parse gate rejects negatives) but this is a pub fn.
190    let sign = if minor < 0 { "-" } else { "" };
191    let abs = minor.unsigned_abs();
192    let number = if decimal_places == 0 {
193        format!("{abs}")
194    } else {
195        format!(
196            "{}.{:0width$}",
197            abs / scale,
198            abs % scale,
199            width = decimal_places as usize
200        )
201    };
202    crate::format::with_currency(sign, &number, currency)
203}
204
205/// DeepSeek — credit balance from `/user/balance`.
206#[derive(Debug, Clone, PartialEq)]
207pub struct DeepseekSnapshot {
208    pub is_available: bool,
209    /// Current balance (prefer USD, fallback to CNY).
210    pub balance: f64,
211    /// Free-granted credits component.
212    pub granted: f64,
213    /// Topped-up (purchased) credits component.
214    pub topped_up: f64,
215    /// The currency of the above amounts (currently "USD" or "CNY").
216    pub currency: String,
217}
218
219impl Eq for DeepseekSnapshot {}
220
221impl Default for DeepseekSnapshot {
222    fn default() -> Self {
223        Self {
224            is_available: false,
225            balance: 0.0,
226            granted: 0.0,
227            topped_up: 0.0,
228            currency: String::new(),
229        }
230    }
231}
232
233/// DeepInfra prepaid balance and current-month usage from the documented
234/// `/payment/checklist` and `/payment/usage` billing endpoints.
235#[derive(Debug, Clone, PartialEq)]
236pub struct DeepInfraSnapshot {
237    /// General-purpose prepaid credit remaining, in US dollars.
238    pub balance: f64,
239    /// Current calendar-month spend, in US dollars.
240    pub monthly_spend: f64,
241    /// Optional monthly spending limit, in US dollars.
242    pub monthly_limit: Option<f64>,
243    /// Usage period in the API's `YYYY.MM` form.
244    pub period: String,
245}
246
247impl Eq for DeepInfraSnapshot {}
248
249impl DeepInfraSnapshot {
250    pub fn monthly_consumed_pct(&self) -> Option<i32> {
251        let limit = self.monthly_limit.filter(|limit| *limit > 0.0)?;
252        Some(
253            ((self.monthly_spend / limit) * 100.0)
254                .round()
255                .clamp(0.0, 9999.0) as i32,
256        )
257    }
258}
259
260/// One promo balance from Cursor's spending page
261/// (`GetClientVisibleCreditGrants`). Amounts are USD cents.
262/// `remaining_cents` is the figure to the left of the slash (`$21/$25`).
263///
264/// The grant may cover only one product (Cloud Agents, Bugbot, a single
265/// model). `display_name` is that product title when Cursor sent one. The
266/// row still says "Credits" when the name is not a title Cursor shows.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct CursorCreditGrant {
269    pub remaining_cents: i64,
270    pub total_cents: i64,
271    /// When the grant lapses. `None` when the payload has no expiry — some
272    /// account credits never do.
273    pub expires_at: Option<DateTime<Utc>>,
274    /// Vendor label. Empty when the grant has none.
275    pub display_name: String,
276}
277
278/// Cursor — the two included-usage pools the dashboard shows, from the
279/// undocumented `cursor.com/api/usage-summary` endpoint (the same one the
280/// dashboard's own frontend calls), authenticated with the session token the
281/// Cursor IDE wrote to its local `state.vscdb`.
282///
283/// Since Cursor's mid-2026 pricing, a plan's included compute is split into two
284/// quota pools, each shown as a percentage: **Cursor Models** (Auto + Composer,
285/// `autoPercentUsed`) and **Other Models** (named / third-party, `apiPercentUsed`).
286/// Overflow past either pool falls to on-demand spend. Percentages are integers
287/// (rounded from the wire floats) to match the dashboard and every other
288/// vendor's integer-percent convention; they can exceed 100 when a pool is over
289/// its included allowance.
290///
291/// [`CursorSnapshot::credits`] is a second, best-effort call
292/// (`GetClientVisibleCreditGrants`). An empty list means the account has no
293/// visible grant, or that call failed; either way the pools above still stand.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct CursorSnapshot {
296    /// Membership label, title-cased from `membershipType` (e.g. "Ultra").
297    pub plan: String,
298    /// "Cursor Models" pool — Auto + Composer (`autoPercentUsed`, rounded).
299    pub auto_pct: i32,
300    /// "Other Models" pool — named / third-party (`apiPercentUsed`, rounded).
301    pub api_pct: i32,
302    /// Overall included usage (`totalPercentUsed`, rounded) — the dashboard's
303    /// "you've used N% of your included total usage" headline.
304    pub total_pct: i32,
305    /// `true` when the plan reports `isUnlimited` — the pools don't cap and the
306    /// percentages are not meaningful.
307    pub unlimited: bool,
308    /// Whether on-demand (overage) spend is turned on (`onDemand.enabled`).
309    pub on_demand_enabled: bool,
310    /// On-demand spend in cents (`onDemand.used`), when Cursor reports it.
311    pub on_demand_used_cents: Option<i64>,
312    /// Configured on-demand spending limit in cents (`onDemand.limit`).
313    pub on_demand_limit_cents: Option<i64>,
314    /// End of the current billing cycle (`billingCycleEnd`) — when the pools
315    /// reset.
316    pub reset_at: Option<DateTime<Utc>>,
317    /// Start of the current billing cycle (`billingCycleStart`), when the API
318    /// sends it. With `reset_at` it gives the exact window length the pace
319    /// projection needs; absent, no window length is reported at all.
320    pub cycle_start: Option<DateTime<Utc>>,
321    /// Spending-page credit grants, soonest expiry first. Empty when the
322    /// account has none or the grant call did not succeed.
323    pub credits: Vec<CursorCreditGrant>,
324}
325
326impl CursorSnapshot {
327    /// Length of the current billing cycle, but only when the API stated both
328    /// ends and they are ordered. `None` otherwise — older responses and every
329    /// snapshot cached before `billingCycleStart` existed omit the start, and a
330    /// guessed month would reach a frontend as an exact window and be paced as
331    /// one. `window_secs` is absent instead; the reset time still shows.
332    pub fn cycle_window(&self) -> Option<chrono::Duration> {
333        match (self.cycle_start, self.reset_at) {
334            (Some(start), Some(end)) if end > start => Some(end - start),
335            _ => None,
336        }
337    }
338
339    /// The binding pool — whichever is closest to (or furthest past) its cap.
340    /// Drives the bar color and the single generic `session_pct` alias.
341    pub fn worst_pct(&self) -> i32 {
342        self.auto_pct.max(self.api_pct)
343    }
344}
345
346/// Kiro CLI (AWS CodeWhisperer / Q Developer backend) — a single credit pool
347/// from `AmazonCodeWhispererService.GetUsageLimits`, the same call kiro-cli's
348/// own `/usage` slash command makes. Authenticated with the AWS SSO OIDC
349/// bearer token kiro-cli already cached locally, refreshed with the paired
350/// refresh token when it's close to expiry — see `kiro::db` and `kiro::oauth`.
351#[derive(Debug, Clone, PartialEq)]
352pub struct KiroSnapshot {
353    /// Subscription tier label (`subscriptionInfo.subscriptionTitle`, e.g.
354    /// "KIRO POWER").
355    pub plan: String,
356    /// Credits consumed this cycle (`currentUsageWithPrecision`).
357    pub used: f64,
358    /// Credits included in the plan (`usageLimitWithPrecision`).
359    pub limit: f64,
360    /// When the credit pool resets (`nextDateReset`).
361    pub reset_at: Option<DateTime<Utc>>,
362}
363
364impl Eq for KiroSnapshot {}
365
366impl KiroSnapshot {
367    /// Percentage of the credit pool consumed, rounded. `0` when `limit` is
368    /// not positive — defensive; the API has not been observed to send that.
369    pub fn pct(&self) -> i32 {
370        if self.limit <= 0.0 {
371            return 0;
372        }
373        ((self.used / self.limit) * 100.0)
374            .round()
375            .clamp(0.0, 9999.0) as i32
376    }
377}
378
379/// Kimi Code — weekly subscription quota plus a 5h rolling rate-limit window.
380///
381/// Accounts on the newer `/coding/v1/usages` response shape have no weekly
382/// counters at all: the top-level `usage` block is replaced by a `usages` map
383/// of ratios, of which only `limit_month_total` — the combined monthly pool —
384/// is read. On such accounts `has_weekly` is false, the weekly counters stay
385/// zero (never fabricated), and the monthly pool arrives as
386/// `monthly_pct`/`monthly_reset_at`.
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct KimiSnapshot {
389    pub plan: Option<String>,
390    pub weekly_limit: u64,
391    pub weekly_used: u64,
392    pub weekly_remaining: u64,
393    pub weekly_reset_at: Option<DateTime<Utc>>,
394    /// `false` on the newer `usages`-map shape, which exposes no weekly
395    /// bucket; renderers must drop the weekly row rather than draw zeros.
396    pub has_weekly: bool,
397    /// Combined monthly pool usage (0..=100) on the newer shape. The
398    /// `limit_month_code` entry is the Code slice *inside* that pool, never
399    /// its own allowance, so it is not carried here.
400    pub monthly_pct: Option<i32>,
401    pub monthly_reset_at: Option<DateTime<Utc>>,
402    pub window_limit: u64,
403    pub window_used: u64,
404    pub window_remaining: u64,
405    pub window_reset_at: Option<DateTime<Utc>>,
406}
407
408impl KimiSnapshot {
409    fn pct(used: u64, limit: u64) -> i32 {
410        if limit == 0 {
411            0
412        } else {
413            // Keep all quota values exact: f64 loses integer precision above
414            // 2^53. This is the integer equivalent of round(used / limit *
415            // 100), with saturation for inconsistent upstream counters.
416            let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
417            pct.min(100) as i32
418        }
419    }
420
421    /// Percentage of the weekly subscription quota consumed (0..=100).
422    pub fn weekly_pct(&self) -> i32 {
423        Self::pct(self.weekly_used, self.weekly_limit)
424    }
425
426    /// Percentage of the rolling rate-limit window consumed (0..=100).
427    pub fn window_pct(&self) -> i32 {
428        Self::pct(self.window_used, self.window_limit)
429    }
430
431    /// Worst percentage across the windows this snapshot actually has: the
432    /// rolling window, the weekly quota when present, and the monthly pool
433    /// when present.
434    pub fn worst_pct(&self) -> i32 {
435        let mut worst = self.window_pct();
436        if self.has_weekly {
437            worst = worst.max(self.weekly_pct());
438        }
439        if let Some(monthly) = self.monthly_pct {
440            worst = worst.max(monthly);
441        }
442        worst
443    }
444}
445
446/// Discriminated union of vendor-specific snapshots. The widget and TUI match
447/// on this to pick a renderer.
448#[derive(Debug, Clone, PartialEq, Eq)]
449pub enum VendorSnapshot {
450    Anthropic(AnthropicSnapshot),
451    Openai(OpenAiSnapshot),
452    Copilot(crate::copilot::types::Snapshot),
453    Zai(ZaiSnapshot),
454    Openrouter(OpenRouterSnapshot),
455    Deepseek(DeepseekSnapshot),
456    Deepinfra(DeepInfraSnapshot),
457    Kimi(KimiSnapshot),
458    Kilo(KiloSnapshot),
459    Novita(NovitaSnapshot),
460    Synthetic(SyntheticSnapshot),
461    Moonshot(MoonshotSnapshot),
462    Grok(GrokSnapshot),
463    SuperGrok(SuperGrokSnapshot),
464    Grokbot(GrokbotSnapshot),
465    AnthropicApi(AnthropicApiSnapshot),
466    Antigravity(AntigravitySnapshot),
467    Cursor(CursorSnapshot),
468    Minimax(MinimaxSnapshot),
469    Kiro(KiroSnapshot),
470    NousResearch(crate::nous::types::AccountSnapshot),
471    OpenCodeGo(crate::opencode_go::types::Usage),
472    CommandCode(crate::commandcode::types::Snapshot),
473    Ollama(OllamaSnapshot),
474    OrcaRouter(OrcaRouterSnapshot),
475    ModelStudio(ModelStudioSnapshot),
476    Lyceum(LyceumSnapshot),
477    Devin(DevinSnapshot),
478    Droid(DroidSnapshot),
479    MuseCode(MuseSnapshot),
480    /// A `[[custom]]` provider. Which one is not in the snapshot: the caller
481    /// that fetched it holds the `CustomProviderConfig`, and the cache
482    /// directory is keyed by its `id`.
483    Custom(crate::custom::types::CustomSnapshot),
484    Cline(crate::cline::types::Snapshot),
485}
486
487impl VendorSnapshot {
488    /// Banked, user-redeemable resets, for the vendors that have them.
489    ///
490    /// The one place this table lives. It had already been written twice —
491    /// once for the report's `reset_credits` field and once for the
492    /// expiry notifications — and adding a third provider to only one of them
493    /// is a silent half-feature: the sidebar lists a grant the notifier never
494    /// warns about. `None` is the honest answer for every other vendor, and
495    /// for Claude and SuperGrok while they hold none. Codex's usage response
496    /// states its count, so an empty [`ResetCredits`] there means "this
497    /// account banks resets and currently holds none" and is shown as such;
498    /// `None` for Codex means the response omitted the block.
499    pub fn reset_credits(&self) -> Option<&ResetCredits> {
500        match self {
501            Self::Anthropic(snapshot) => Some(&snapshot.reset_credits).filter(|c| !c.is_empty()),
502            Self::Openai(snapshot) => snapshot.reset_credits.as_ref(),
503            Self::SuperGrok(snapshot) => Some(&snapshot.reset_credits).filter(|c| !c.is_empty()),
504            _ => None,
505        }
506    }
507}
508
509/// Google Antigravity 2.0 / CLI snapshot. The API groups models into Gemini
510/// and third-party (Claude/GPT) buckets, and each group may carry a 5-hour and
511/// a weekly window — up to four, and not every product or plan offers all of
512/// them. Antigravity CLI 1.1.22 returns weekly buckets only, so every window is
513/// optional and a snapshot is valid when at least one arrived.
514#[derive(Debug, Clone, PartialEq)]
515pub struct AntigravitySnapshot {
516    pub plan: String,
517    /// Fingerprint of the signed-in account. Never displayed — it exists so a
518    /// cache written for one Google account is not served for another.
519    pub account: String,
520    /// Where the figures came from: a running local product, or the Cloud
521    /// Code API reached with the saved Google session when no local server
522    /// can answer — none is running, or `agy` withholds its CSRF token.
523    pub source: AntigravitySource,
524    /// Gemini group, 5-hour window.
525    pub session: Option<UsageWindow>,
526    /// Gemini group, weekly window.
527    pub weekly: Option<UsageWindow>,
528    /// Claude/GPT group, 5-hour window.
529    pub third_party_session: Option<UsageWindow>,
530    /// Claude/GPT group, weekly window.
531    pub third_party_weekly: Option<UsageWindow>,
532    /// Pools kept apart from the two shared groups, such as the weekly
533    /// "Gemini 4 Argon" limit on Pro plans, in the order the response listed
534    /// them. Empty for a product or plan that reports none.
535    pub model_pools: Vec<ModelPoolWindow>,
536}
537
538impl Eq for AntigravitySnapshot {}
539
540/// One window of an Antigravity per-model pool, named by the response's group
541/// display name. Its cadence is `window.window_duration`.
542#[derive(Debug, Clone, PartialEq)]
543pub struct ModelPoolWindow {
544    pub name: String,
545    pub window: UsageWindow,
546}
547
548impl Eq for ModelPoolWindow {}
549
550impl ModelPoolWindow {
551    /// Pools report the shared windows' two cadences, 5-hour and weekly.
552    pub fn is_weekly(&self) -> bool {
553        self.window.window_duration > chrono::Duration::hours(5)
554    }
555}
556
557/// Which path produced an [`AntigravitySnapshot`]. The local language server
558/// is the primary source; the remote API is the fallback for when no product
559/// is running, and the panel says so because the two can disagree briefly.
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
561pub enum AntigravitySource {
562    #[default]
563    Local,
564    Remote,
565    /// The running `agy` CLI's official status-line payload.
566    Statusline,
567    /// `agy`'s own reading, through `agy -p "/quota"`: the figures its Models
568    /// & Quota page shows, so the panel has nothing to qualify.
569    Agy,
570}
571
572impl AntigravitySource {
573    pub fn as_str(self) -> &'static str {
574        match self {
575            AntigravitySource::Local => "local",
576            AntigravitySource::Remote => "remote",
577            AntigravitySource::Statusline => "statusline",
578            AntigravitySource::Agy => "agy",
579        }
580    }
581
582    /// Inverse of [`as_str`](Self::as_str); anything unrecognised is `None` so
583    /// a cache reader can fall back to the default rather than guess.
584    pub fn parse(s: &str) -> Option<Self> {
585        match s {
586            "local" => Some(AntigravitySource::Local),
587            "remote" => Some(AntigravitySource::Remote),
588            "statusline" => Some(AntigravitySource::Statusline),
589            "agy" => Some(AntigravitySource::Agy),
590            _ => None,
591        }
592    }
593}
594
595/// MiniMax Token Plan — `/v1/token_plan/remains` returns one row per model
596/// bucket (`general` for text/coding, `video`), and each row carries its own
597/// rolling interval window plus a weekly window.
598///
599/// Two things the payload dictates rather than convention: the interval length
600/// is **not fixed** (`general` rolls every 5h, `video` every 24h), so the
601/// duration is derived from the row's own start/end rather than assumed; and
602/// the API reports the percentage **remaining**, which is inverted on the way
603/// in so these windows carry consumed-% like every other vendor's.
604#[derive(Debug, Clone, PartialEq, Eq)]
605pub struct MinimaxSnapshot {
606    pub plan: String,
607    /// `general` bucket — rolling interval window (5h on the observed plans).
608    pub session: UsageWindow,
609    /// `general` bucket — weekly window.
610    pub weekly: UsageWindow,
611    /// `video` bucket, `None` on plans that carry no video quota.
612    pub video_session: Option<UsageWindow>,
613    pub video_weekly: Option<UsageWindow>,
614}
615
616/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
617/// monthly `limit` is supplied from config (the API exposes neither the limit
618/// nor the remaining prepaid credit balance).
619#[derive(Debug, Clone, PartialEq)]
620pub struct AnthropicApiSnapshot {
621    pub spent: f64,
622    pub limit: Option<f64>,
623    /// Last UTC calendar day fully covered by the daily cost buckets.
624    pub covered_through: Option<chrono::NaiveDate>,
625}
626
627impl Eq for AnthropicApiSnapshot {}
628
629impl AnthropicApiSnapshot {
630    /// Spend as an integer percentage of the configured limit; `None` when no
631    /// positive limit is set.
632    pub fn pct(&self) -> Option<i32> {
633        self.limit
634            .filter(|l| l.is_finite() && *l > 0.0)
635            .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
636    }
637
638    pub fn coverage_label(&self) -> String {
639        match self.covered_through_day() {
640            Some(day) => format!("Month to date (through {day})"),
641            None => "Completed daily buckets this month".into(),
642        }
643    }
644
645    /// The last fully covered UTC day as the card prints it, `Oct 17`.
646    pub fn covered_through_day(&self) -> Option<String> {
647        let date = self.covered_through?;
648        const MONTHS: [&str; 12] = [
649            "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
650        ];
651        let month = MONTHS[chrono::Datelike::month0(&date) as usize];
652        Some(format!("{month} {}", chrono::Datelike::day(&date)))
653    }
654}
655
656/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
657/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
658#[derive(Debug, Clone, PartialEq)]
659pub struct KiloSnapshot {
660    pub label: String,
661    pub balance: f64,
662}
663
664impl Eq for KiloSnapshot {}
665
666/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
667/// all amounts already converted from the API's 1/10000-USD integers to USD.
668#[derive(Debug, Clone, PartialEq)]
669pub struct NovitaSnapshot {
670    /// Spendable credit balance (`availableBalance`).
671    pub available: f64,
672    /// Remaining top-up (`cashBalance`).
673    pub cash: f64,
674    /// Credit limit — max you can owe (`creditLimit`).
675    pub credit_limit: f64,
676    /// Amount currently owed (`outstandingInvoices`).
677    pub outstanding: f64,
678}
679
680impl Eq for NovitaSnapshot {}
681
682/// Synthetic subscription quota from the documented `/v2/quotas`
683/// `subscription` object.
684#[derive(Debug, Clone, PartialEq)]
685pub struct SyntheticSnapshot {
686    pub subscription: UsageWindow,
687    pub requests: f64,
688    pub limit: f64,
689}
690
691impl Eq for SyntheticSnapshot {}
692
693/// Lyceum's platform credit balance is denominated in USD, not a percentage quota.
694#[derive(Debug, Clone, PartialEq, Serialize)]
695pub struct LyceumSnapshot {
696    pub available_credits: f64,
697    pub used_credits: f64,
698    pub total_credits_used: f64,
699    pub remaining_credits: f64,
700    pub monthly_free_credits: f64,
701    pub purchased_credits: f64,
702}
703impl Eq for LyceumSnapshot {}
704
705/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
706/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
707/// field in the response, so it's carried here from the region config.
708#[derive(Debug, Clone, PartialEq)]
709pub struct MoonshotSnapshot {
710    /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
711    /// the inference API.
712    pub available: f64,
713    /// Voucher credit (`voucher_balance`).
714    pub voucher: f64,
715    /// Cash balance (`cash_balance`); can be negative (debt).
716    pub cash: f64,
717    /// "USD" or "CNY", implied by the host.
718    pub currency: String,
719}
720
721impl Eq for MoonshotSnapshot {}
722
723/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
724/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
725#[derive(Debug, Clone, PartialEq)]
726pub struct GrokSnapshot {
727    pub balance: f64,
728}
729
730impl Eq for GrokSnapshot {}
731
732/// SuperGrok subscription usage from Grok Build's billing endpoint (ACP as
733/// fallback) plus banked remaining-resets. Distinct from [`GrokSnapshot`]
734/// (Management API prepaid balance).
735#[derive(Debug, Clone, PartialEq)]
736pub struct SuperGrokSnapshot {
737    /// Subscription tier label when the billing response supplies one
738    /// (e.g. "SuperGrok", "SuperGrok Heavy"); otherwise `"SuperGrok"`.
739    pub plan: String,
740    /// Opaque digest of Grok auth/config state. Never displayed — cache
741    /// isolation only.
742    pub account: String,
743    /// Current included-credit usage percent. The field name is retained as a
744    /// compatibility alias for format/render code; [`Self::period`] says
745    /// whether the server's actual window is weekly or monthly.
746    pub weekly_pct: i32,
747    pub period: SuperGrokPeriod,
748    /// When the current usage period ends.
749    pub reset_at: Option<DateTime<Utc>>,
750    /// Remaining prepaid (purchased) API credit in USD, when present.
751    pub prepaid_balance: Option<f64>,
752    pub reset_credits: ResetCredits,
753    /// Per-product slices of the same included-credit pool (`GrokBuild`,
754    /// `GrokChat`, `GrokImagine`, …). Empty when the billing document omits
755    /// `productUsage`.
756    pub products: Vec<SuperGrokProduct>,
757    /// On-demand spend beyond the included pool, in USD cents. From the Grok
758    /// CLI billing document's `onDemandUsed`. `None` when the field is absent.
759    pub on_demand_used_cents: Option<i64>,
760    /// On-demand spending cap for that same pool, in USD cents
761    /// (`onDemandCap`). `None` when the field is absent.
762    pub on_demand_cap_cents: Option<i64>,
763}
764
765impl SuperGrokSnapshot {
766    /// Additional usage beyond the included pool, as dollars.
767    ///
768    /// A cap renders as `$used / $cap`. Spend with no cap renders as the
769    /// amount alone. Both absent or zero is the proto default — nothing was
770    /// configured — so the line stays hidden.
771    pub fn additional_usage_text(&self) -> Option<String> {
772        let used = self.on_demand_used_cents.unwrap_or(0);
773        let cap = self.on_demand_cap_cents;
774        if used == 0 && cap.unwrap_or(0) == 0 {
775            return None;
776        }
777        let used_s = crate::format::usd(used as f64 / 100.0);
778        match cap {
779            Some(cap) if cap > 0 => Some(format!(
780                "{used_s} / {}",
781                crate::format::usd(cap as f64 / 100.0)
782            )),
783            _ => Some(used_s),
784        }
785    }
786}
787
788/// One SuperGrok product's share of the current included-credit window.
789#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
790pub struct SuperGrokProduct {
791    pub label: String,
792    pub percent: i32,
793}
794
795impl Eq for SuperGrokSnapshot {}
796
797#[derive(Debug, Clone, Copy, PartialEq, Eq)]
798pub enum SuperGrokPeriod {
799    Weekly,
800    Monthly,
801    Unknown,
802}
803
804impl SuperGrokPeriod {
805    pub fn label(self) -> &'static str {
806        match self {
807            Self::Weekly => "Weekly",
808            Self::Monthly => "Monthly",
809            Self::Unknown => "Current period",
810        }
811    }
812
813    pub fn short(self) -> &'static str {
814        match self {
815            Self::Weekly => "wk",
816            Self::Monthly => "mo",
817            Self::Unknown => "period",
818        }
819    }
820}
821
822/// Grok Bot desktop app — the weekly included-usage pool from
823/// `aiserver.v1.DashboardService/GetSandUsageStatus` (Connect-RPC), read with
824/// the app's own OAuth session. Distinct from [`GrokSnapshot`] (Management
825/// API prepaid dollars) and [`SuperGrokSnapshot`] (Grok Build subscription).
826#[derive(Debug, Clone, PartialEq, Eq)]
827pub struct GrokbotSnapshot {
828    /// `grokPlanLabel`, falling back to `cursorPlanName`, then "Grok Bot".
829    pub plan: String,
830    /// The subscription that bills the pool, from `billingBrand` and the plan
831    /// reported for it ("Cursor Ultra"). `None` for a brand not recognized yet,
832    /// which is left unnamed rather than guessed.
833    pub billed_by: Option<String>,
834    /// `hasNonZeroIncludedLimit`. When false the account carries no included
835    /// allowance at all — a distinct "no included allowance" state, never a
836    /// fabricated 0% meter.
837    pub has_included_allowance: bool,
838    /// `usagePercent` of the included pool (0..=100). Meaningful only when
839    /// `has_included_allowance` is set.
840    pub weekly_pct: i32,
841    /// `hasAvailableUsage` — the account can still serve requests, which at
842    /// 100% of the included pool means on-demand is picking up the rest.
843    pub has_available_usage: bool,
844    /// `onDemandSettings.enabled` — pay-as-you-go past the included pool.
845    pub on_demand_enabled: bool,
846    /// `currentPeriodStart`.
847    pub period_start: Option<DateTime<Utc>>,
848    /// `nextResetTimestampUtc`.
849    pub reset_at: Option<DateTime<Utc>>,
850    /// `reset_at − period_start` when both are reported (7 days on the
851    /// captured account) — computed, never assumed.
852    pub window: Option<chrono::Duration>,
853}
854
855impl GrokbotSnapshot {
856    /// The plan a frontend shows: the subscription that bills the pool
857    /// ("Cursor Ultra") over the app's own label, which reads "Grok Bot Plan"
858    /// on every account.
859    pub fn display_plan(&self) -> &str {
860        self.billed_by.as_deref().unwrap_or(&self.plan)
861    }
862
863    /// At 100% of the included pool, `hasAvailableUsage` can still be true
864    /// because on-demand keeps serving — say so, but only when the account
865    /// actually has on-demand switched on.
866    pub fn on_demand_note(&self) -> Option<&'static str> {
867        (self.has_included_allowance
868            && self.weekly_pct >= 100
869            && self.has_available_usage
870            && self.on_demand_enabled)
871            .then_some("included pool exhausted — on-demand may still be serving usage")
872    }
873}
874
875/// OpenAI Codex OAuth — exposes whichever rolling windows the API reports.
876#[derive(Debug, Clone, PartialEq, Eq)]
877pub struct OpenAiSnapshot {
878    pub plan: String,
879    /// 5h window, identified by its duration rather than its wire position.
880    pub session: Option<UsageWindow>,
881    /// 7d window, identified by its duration rather than its wire position.
882    pub weekly: Option<UsageWindow>,
883    /// Optional 7d code-review bucket.
884    pub code_review: Option<UsageWindow>,
885    /// Named limits beside the main one, each with its own windows. Empty for
886    /// an account that has none.
887    pub additional_limits: Vec<OpenAiNamedLimit>,
888    /// Models the account currently cannot dispatch to, with the time they
889    /// return when the API states one. Only unavailable models are kept: a
890    /// list of everything that *is* working is noise, and the reason this
891    /// exists is to explain a refusal no percentage accounts for.
892    pub unavailable_models: Vec<OpenAiUnavailableModel>,
893    /// Optional credit balance + approximate message-count ranges.
894    pub credits: Option<OpenAiCredits>,
895    /// `None` when the usage response omits the block (a Codex build that
896    /// predates banked resets); `Some` with `available == 0` when it states
897    /// that the account holds none, which the report shows as such.
898    pub reset_credits: Option<ResetCredits>,
899    /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
900    /// the placeholder set and the "OpenAI does not expose this for Plus"
901    /// tooltip when the OAuth path isn't available.
902    pub source: OpenAiSource,
903}
904
905/// A named limit that sits beside Codex's main window — a reserved pool or a
906/// model-specific allowance. It can be exhausted while the headline window is
907/// nearly untouched, which is the case it exists to make visible.
908#[derive(Debug, Clone, PartialEq, Eq)]
909pub struct OpenAiNamedLimit {
910    /// The API's own name for it, shown as given.
911    pub name: String,
912    pub session: Option<UsageWindow>,
913    pub weekly: Option<UsageWindow>,
914}
915
916/// A model the account cannot currently dispatch to.
917#[derive(Debug, Clone, PartialEq, Eq)]
918pub struct OpenAiUnavailableModel {
919    pub model: String,
920    /// When the API says it returns. `None` means it did not say.
921    pub available_at: Option<DateTime<Utc>>,
922}
923
924/// Banked, user-redeemable quota resets — Codex's "rate limit reset credits"
925/// and SuperGrok's "remaining resets" are the same idea under two names: a
926/// count you have earned, each with its own expiry, redeemed by hand rather
927/// than arriving on the window's own schedule. Distinct from a
928/// [`UsageWindow::resets_at`], which needs no action and cannot be banked.
929///
930/// The redemption identifier each provider returns alongside these
931/// (`credits[].id`, `tokens[].token_id`) is deliberately *not* carried here:
932/// it is the handle that spends the credit, and nothing that renders a status
933/// bar needs it.
934#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
935pub struct ResetCredits {
936    pub available: u32,
937    /// One row per credit the provider described. May be shorter than
938    /// `available` — Codex's usage endpoint gives the count without the
939    /// per-credit detail, and the detail call is allowed to fail on its own.
940    #[serde(default)]
941    pub credits: Vec<ResetCredit>,
942}
943
944#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
945pub struct ResetCredit {
946    /// Provider label when one exists ("Full reset (Weekly + 5 hr)"). SuperGrok
947    /// tokens have no title.
948    #[serde(default, skip_serializing_if = "Option::is_none")]
949    pub title: Option<String>,
950    #[serde(default, skip_serializing_if = "Option::is_none")]
951    pub expires_at: Option<DateTime<Utc>>,
952}
953
954/// A provider's own label for a banked reset, rendered verbatim in Pango bar
955/// markup and in the `;;`-delimited desktop FORMAT protocol. Both vendors that
956/// carry one gate it here rather than each keeping a copy: an over-long or
957/// control-character-bearing title is dropped, leaving the expiry line alone,
958/// which still says everything the user has to act on.
959pub fn checked_reset_title(value: Option<String>) -> Option<String> {
960    const MAX_RESET_TITLE_CHARS: usize = 80;
961    let value = value
962        .map(|s| s.trim().to_string())
963        .filter(|s| !s.is_empty())?;
964    if value.chars().count() > MAX_RESET_TITLE_CHARS || value.chars().any(char::is_control) {
965        None
966    } else {
967        Some(value)
968    }
969}
970
971impl ResetCredits {
972    pub fn is_empty(&self) -> bool {
973        self.available == 0
974    }
975
976    pub fn next_expiry(&self) -> Option<DateTime<Utc>> {
977        self.credits
978            .iter()
979            .filter_map(|credit| credit.expires_at)
980            .min()
981    }
982}
983
984#[derive(Debug, Clone, Copy, PartialEq, Eq)]
985pub enum OpenAiSource {
986    CodexOauth,
987    AdminKeyMtd,
988    Unavailable,
989}
990
991#[derive(Debug, Clone, PartialEq, Eq)]
992pub struct OpenAiCredits {
993    /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
994    /// a string because OpenAI returns it that way.
995    pub balance: String,
996    pub has_credits: bool,
997    pub unlimited: bool,
998    pub approx_local_messages: Option<(i64, i64)>,
999    pub approx_cloud_messages: Option<(i64, i64)>,
1000}
1001
1002/// Z.AI / BigModel — list of buckets with discriminated types. We project the
1003/// two we care about into named fields (5h tokens, weekly tokens, MCP).
1004#[derive(Debug, Clone, PartialEq, Eq)]
1005pub struct ZaiSnapshot {
1006    pub plan: String,
1007    pub session: Option<UsageWindow>,
1008    pub weekly: Option<UsageWindow>,
1009    pub mcp: Option<UsageWindow>,
1010}
1011
1012/// Ollama Cloud — credit balances or legacy quota windows from
1013/// `ollama.com/api/balance`. Older cached `/api/usage` payloads can also
1014/// carry per-model counts and activity cost.
1015#[derive(Debug, Clone, PartialEq, Eq)]
1016pub struct OllamaSnapshot {
1017    /// Display label, taken from the `[ollama] plan` config field. The API
1018    /// itself does not report a plan name.
1019    pub plan: String,
1020    /// 5h rolling window (`limits.session`). `None` when the account has not
1021    /// touched the cloud tier yet (the window is omitted from the response,
1022    /// not reported as zero).
1023    pub session: Option<UsageWindow>,
1024    /// 7d rolling window (`limits.weekly`).
1025    pub weekly: Option<UsageWindow>,
1026    /// Calendar-month window (`limits.monthly`). Some Pro accounts report
1027    /// this in place of `session`/`weekly` instead of alongside them.
1028    pub monthly: Option<UsageWindow>,
1029    /// Per-model request counts inside the session window, in the order the
1030    /// API returned them. Renderers sort and truncate this for the tooltip.
1031    pub session_models: Vec<OllamaModelUsage>,
1032    /// Per-model request counts inside the weekly window.
1033    pub weekly_models: Vec<OllamaModelUsage>,
1034    /// Per-model request counts inside the monthly window.
1035    pub monthly_models: Vec<OllamaModelUsage>,
1036    /// `activity.cost` as a pre-formatted dollar string (`"0.00000"`,
1037    /// `"1.23456"`). Already a string on the wire — the renderer decides
1038    /// whether to keep it verbatim or reformat.
1039    pub activity_cost: Option<String>,
1040    /// `activity.period.type` (`"last_4_weeks"` and friends). A short
1041    /// human-readable label the renderer can show next to the cost.
1042    pub activity_period: Option<String>,
1043    /// Monthly included credits and optional purchased credits, formatted
1044    /// through the shared money formatter for all desktop surfaces.
1045    pub credits: Option<OllamaCredits>,
1046}
1047
1048#[derive(Debug, Clone, PartialEq, Eq)]
1049pub struct OllamaCredits {
1050    pub balance: String,
1051    pub allowance: String,
1052    pub purchased: Option<String>,
1053}
1054
1055/// One row of `OllamaSnapshot::{session,weekly}_models`. The API carries the
1056/// per-model request count; the percentage of the window that this single
1057/// model represents is not reported, so the renderer derives it locally.
1058#[derive(Debug, Clone, PartialEq, Eq)]
1059pub struct OllamaModelUsage {
1060    pub name: String,
1061    pub request_count: u64,
1062}
1063
1064/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
1065/// `/api/v1/credits` and `/api/v1/key`.
1066#[derive(Debug, Clone, PartialEq)]
1067pub struct OpenRouterSnapshot {
1068    pub label: String,
1069    pub total_credits: f64,
1070    pub total_usage: f64,
1071    pub usage_daily: f64,
1072    pub usage_weekly: f64,
1073    pub usage_monthly: f64,
1074    pub is_free_tier: bool,
1075    pub limit: Option<f64>,
1076    pub limit_remaining: Option<f64>,
1077    pub recent_models: Vec<String>,
1078}
1079
1080impl Eq for OpenRouterSnapshot {}
1081
1082impl OpenRouterSnapshot {
1083    /// Spendable credit, **which can be negative**: OpenRouter lets an account
1084    /// run into debt, and clamping that to zero would report a healthy-looking
1085    /// `$0.00` to someone who has to top up before anything works again. The
1086    /// wire fields are each non-negative (see `openrouter::types`), so a
1087    /// negative result only ever means usage has overrun credits.
1088    pub fn balance(&self) -> f64 {
1089        self.total_credits - self.total_usage
1090    }
1091    /// Percentage of total_credits consumed (0..=100). Returns 0 when
1092    /// `total_credits` is 0 (free-tier-only accounts) — there is no
1093    /// denominator to be a percentage of. Severity does not come from this
1094    /// number alone: see [`crate::openrouter::vendor::severity`], which treats
1095    /// a negative [`Self::balance`] as critical regardless of the percentage.
1096    pub fn consumed_pct(&self) -> i32 {
1097        if self.total_credits <= 0.0 {
1098            return 0;
1099        }
1100        i32::from(crate::format::clamp_pct(
1101            (self.total_usage / self.total_credits) * 100.0,
1102        ))
1103    }
1104}
1105
1106/// OrcaRouter — prepaid credit card from the one-api compatible dashboard
1107/// billing endpoints (`/v1/dashboard/billing/usage` + `/subscription`), over an
1108/// API key. Usage arrives in **US cents** (`total_usage: 275` = $2.75); the
1109/// subscription's limit fields are USD and mean the *total* credit limit
1110/// (remaining + used), with `100000000` as the unlimited sentinel.
1111#[derive(Debug, Clone, PartialEq, Eq)]
1112pub struct OrcaRouterSnapshot {
1113    /// Cumulative spend, exact US cents (`total_usage`).
1114    pub spent_cents: i64,
1115    /// Total credit limit in exact US cents. `None` for unlimited keys (the
1116    /// `100000000` sentinel) or when the subscription response carried no
1117    /// limit field at all — either way the card is spend-only.
1118    pub limit_cents: Option<i64>,
1119    /// Key expiry (`access_until`, Unix seconds); `None` = no expiry (a wire
1120    /// `0` means the same thing).
1121    pub access_until: Option<DateTime<Utc>>,
1122}
1123
1124impl OrcaRouterSnapshot {
1125    pub fn spent_usd(&self) -> f64 {
1126        self.spent_cents as f64 / 100.0
1127    }
1128
1129    pub fn limit_usd(&self) -> Option<f64> {
1130        self.limit_cents.map(|c| c as f64 / 100.0)
1131    }
1132
1133    /// Remaining credit in exact cents. Can be negative (spend past the
1134    /// limit) — the sign belongs outside the symbol, like OpenRouter debt.
1135    pub fn remaining_cents(&self) -> Option<i64> {
1136        self.limit_cents.map(|limit| limit - self.spent_cents)
1137    }
1138
1139    pub fn remaining_usd(&self) -> Option<f64> {
1140        self.remaining_cents().map(|c| c as f64 / 100.0)
1141    }
1142
1143    /// Integer-percentage of the limit consumed, computed in cents so no
1144    /// float division is involved. `None` when there is no limit — an
1145    /// unlimited key has no percentage to be exact *about*.
1146    pub fn consumed_pct(&self) -> Option<i32> {
1147        self.limit_cents.filter(|l| *l > 0).map(|limit| {
1148            let pct = (self.spent_cents.saturating_mul(100)) / limit;
1149            pct.clamp(0, 100) as i32
1150        })
1151    }
1152}
1153
1154/// Alibaba Cloud Model Studio Token Plan — a 5-hour, a weekly and a monthly
1155/// ratio window, any of which the console account may not report. An absent
1156/// window is no-data (possibly unlimited), never 0%.
1157#[derive(Debug, Clone, PartialEq, Eq)]
1158pub struct ModelStudioSnapshot {
1159    /// 5-hour window. `None` when `per5HourPercentage` was absent.
1160    pub session: Option<UsageWindow>,
1161    /// Weekly window. `None` when `per1WeekPercentage` was absent.
1162    pub weekly: Option<UsageWindow>,
1163    /// Monthly window. `None` when `per1MonthPercentage` was absent. The
1164    /// reset is exact; the cycle length is plan-dependent, so no exact
1165    /// window duration is declared for it (same rule as Ollama's calendar
1166    /// month) and pacing is omitted wherever it would guess a denominator.
1167    pub monthly: Option<UsageWindow>,
1168}
1169
1170/// Muse Code subscription windows. Missing observations stay absent.
1171#[derive(Debug, Clone, PartialEq, Eq)]
1172pub struct MuseSnapshot {
1173    pub plan: String,
1174    pub session: Option<UsageWindow>,
1175    pub weekly: Option<UsageWindow>,
1176}
1177
1178/// Devin CLI quota snapshot. The CLI reports remaining percentages, while
1179/// renderers and report consumers use consumed percentages consistently.
1180#[derive(Debug, Clone, PartialEq, Eq)]
1181pub struct DevinSnapshot {
1182    pub daily: Option<UsageWindow>,
1183    pub weekly: Option<UsageWindow>,
1184    /// Exact value from the CLI's overageBalanceMicros field. Its USD meaning
1185    /// is empirically observed for the tested account, not a universal API
1186    /// contract; only renderers perform that display conversion.
1187    pub overage_balance_micros: Option<i64>,
1188}
1189
1190/// Factory `droid` CLI subscription quota. The limits endpoint reports a
1191/// `usedPercent` per window in two buckets — Standard and Droid Core — each
1192/// with a 5-hour, a weekly and a monthly window. A window the account has not
1193/// started still reports a percentage with `windowEnd: null`, so it is present
1194/// with no reset; an absent window stays absent.
1195#[derive(Debug, Clone, PartialEq, Eq)]
1196pub struct DroidSnapshot {
1197    /// Standard quota, 5-hour window.
1198    pub session: Option<UsageWindow>,
1199    /// Standard quota, weekly (7d) window.
1200    pub weekly: Option<UsageWindow>,
1201    /// Standard quota, monthly (30d) window.
1202    pub monthly: Option<UsageWindow>,
1203    /// Droid Core quota, 5-hour window.
1204    pub core_session: Option<UsageWindow>,
1205    /// Droid Core quota, weekly window.
1206    pub core_weekly: Option<UsageWindow>,
1207    /// Droid Core quota, monthly window.
1208    pub core_monthly: Option<UsageWindow>,
1209    /// `extraUsageBalanceCents`, verbatim; renderers convert cents to dollars.
1210    pub extra_balance_cents: Option<i64>,
1211}
1212
1213/// Worst-of severity class for the Waybar bar text color. Mirrors
1214/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
1215pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
1216    let mut max = snap.session.utilization_pct;
1217    if snap.weekly.utilization_pct > max {
1218        max = snap.weekly.utilization_pct;
1219    }
1220    if let Some(s) = &snap.sonnet
1221        && s.utilization_pct > max
1222    {
1223        max = s.utilization_pct;
1224    }
1225    for sw in &snap.scoped {
1226        if sw.window.utilization_pct > max {
1227            max = sw.window.utilization_pct;
1228        }
1229    }
1230    // Extra usage only promotes severity if a rate-limit window is at 100%.
1231    let any_at_cap = snap.session.utilization_pct >= 100
1232        || snap.weekly.utilization_pct >= 100
1233        || snap
1234            .sonnet
1235            .as_ref()
1236            .is_some_and(|s| s.utilization_pct >= 100)
1237        || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
1238    if any_at_cap && let Some(extra) = snap.extra.as_ref() {
1239        let p = extra.percent();
1240        if p > max {
1241            max = p;
1242        }
1243    }
1244    crate::pango::severity_for(max)
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use super::*;
1250    use crate::pacing::PaceSeverity;
1251    use chrono::Duration;
1252
1253    fn w(pct: i32) -> UsageWindow {
1254        UsageWindow {
1255            utilization_pct: pct,
1256            resets_at: None,
1257            window_duration: Duration::hours(5),
1258        }
1259    }
1260
1261    fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
1262        AnthropicSnapshot {
1263            plan: "Max 5x".into(),
1264            session: w(s),
1265            weekly: w(w_),
1266            sonnet: sonnet.map(w),
1267            scoped: vec![],
1268            extra: extra.map(|(limit, spent)| ExtraUsage {
1269                limit: Some(Cents(limit)),
1270                spent: Cents(spent),
1271                currency: None,
1272                decimal_places: Some(2),
1273                status: ExtraUsageStatus::Active,
1274            }),
1275            reset_credits: Default::default(),
1276        }
1277    }
1278
1279    #[test]
1280    fn fmt_minor_honors_currency_and_scale() {
1281        // No currency (older payloads) keeps the historical `$`.
1282        assert_eq!(fmt_minor(250, 2, None), "$2.50");
1283        // The #30 reporter's actual figures: BRL must not be claimed as `$`.
1284        assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
1285        assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
1286        // Zero-exponent currency: no decimal point, no /100.
1287        assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
1288        // Sign precedes the symbol, as `format::money` spells it.
1289        assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
1290        // Unknown code stays truthful as a suffix rather than guessing a symbol.
1291        assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
1292    }
1293
1294    #[test]
1295    fn extra_usage_formats_in_its_own_currency() {
1296        let e = ExtraUsage {
1297            limit: None,
1298            spent: Cents(14157),
1299            currency: Some("BRL".into()),
1300            decimal_places: Some(2),
1301            status: ExtraUsageStatus::Active,
1302        };
1303        assert_eq!(e.fmt_spent(), "R$141.57");
1304        assert_eq!(e.fmt_limit(), None);
1305
1306        let capped = ExtraUsage {
1307            limit: Some(Cents(5000)),
1308            spent: Cents(250),
1309            currency: None,
1310            decimal_places: Some(2),
1311            status: ExtraUsageStatus::Active,
1312        };
1313        assert_eq!(capped.fmt_spent(), "$2.50");
1314        assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
1315    }
1316
1317    #[test]
1318    fn extra_percent_with_zero_limit_is_zero() {
1319        assert_eq!(
1320            ExtraUsage {
1321                limit: Some(Cents(0)),
1322                spent: Cents(100),
1323                currency: None,
1324                decimal_places: Some(2),
1325                status: ExtraUsageStatus::Active,
1326            }
1327            .percent(),
1328            0
1329        );
1330    }
1331
1332    #[test]
1333    fn extra_percent_truncates() {
1334        // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
1335        assert_eq!(
1336            ExtraUsage {
1337                limit: Some(Cents(10000)),
1338                spent: Cents(3333),
1339                currency: None,
1340                decimal_places: Some(2),
1341                status: ExtraUsageStatus::Active,
1342            }
1343            .percent(),
1344            33
1345        );
1346    }
1347
1348    #[test]
1349    fn severity_picks_worst_of_three_windows() {
1350        let s = snap(40, 60, Some(80), None);
1351        assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
1352    }
1353
1354    #[test]
1355    fn severity_ignores_extra_when_no_cap_hit() {
1356        // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
1357        let s = snap(50, 60, None, Some((10000, 9500)));
1358        assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
1359    }
1360
1361    #[test]
1362    fn severity_promotes_extra_when_session_at_100() {
1363        let s = snap(100, 50, None, Some((10000, 9500)));
1364        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
1365    }
1366
1367    #[test]
1368    fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
1369        // session = 100, weekly = 50, extra = 100% → max should be 100.
1370        let s = snap(100, 50, None, Some((10000, 10000)));
1371        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
1372    }
1373
1374    fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
1375        s.scoped.push(ScopedWindow {
1376            label: "Fable".into(),
1377            window: w(pct),
1378        });
1379        s
1380    }
1381
1382    #[test]
1383    fn severity_includes_scoped_windows() {
1384        // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
1385        // week at 84 → the bar class must escalate to High.
1386        let s = with_scoped(snap(10, 55, None, None), 84);
1387        assert_eq!(anthropic_severity(&s), PaceSeverity::High);
1388    }
1389
1390    #[test]
1391    fn severity_promotes_extra_when_scoped_at_100() {
1392        // A scoped window at cap counts as a rate-limit cap hit, so extra
1393        // usage above the window max is promoted — same rule as session/weekly.
1394        let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
1395        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
1396    }
1397
1398    #[test]
1399    fn kimi_percent_is_exact_above_f64_precision() {
1400        let snap = KimiSnapshot {
1401            plan: None,
1402            weekly_limit: (1 << 53) + 1,
1403            weekly_used: 1 << 52,
1404            weekly_remaining: 0,
1405            weekly_reset_at: None,
1406            has_weekly: true,
1407            monthly_pct: None,
1408            monthly_reset_at: None,
1409            window_limit: u64::MAX,
1410            window_used: u64::MAX - 1,
1411            window_remaining: 0,
1412            window_reset_at: None,
1413        };
1414        assert_eq!(snap.weekly_pct(), 50);
1415        assert_eq!(snap.window_pct(), 100);
1416    }
1417
1418    #[test]
1419    fn kiro_pct_is_zero_without_a_positive_limit() {
1420        let snap = KiroSnapshot {
1421            plan: "FREE".into(),
1422            used: 5.0,
1423            limit: 0.0,
1424            reset_at: None,
1425        };
1426        assert_eq!(snap.pct(), 0);
1427    }
1428
1429    #[test]
1430    fn kiro_pct_rounds_the_credit_ratio() {
1431        let snap = KiroSnapshot {
1432            plan: "KIRO POWER".into(),
1433            used: 1.0,
1434            limit: 3.0,
1435            reset_at: None,
1436        };
1437        assert_eq!(snap.pct(), 33);
1438    }
1439}