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