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