Skip to main content

ai_usagebar/
usage.rs

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