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    DeepRelay(DeepRelaySnapshot),
519    MuseCode(MuseSnapshot),
520    /// A `[[custom]]` provider. Which one is not in the snapshot: the caller
521    /// that fetched it holds the `CustomProviderConfig`, and the cache
522    /// directory is keyed by its `id`.
523    Custom(crate::custom::types::CustomSnapshot),
524    Cline(crate::cline::types::Snapshot),
525}
526
527impl VendorSnapshot {
528    /// Banked, user-redeemable resets, for the vendors that have them.
529    ///
530    /// The one place this table lives. It had already been written twice —
531    /// once for the report's `reset_credits` field and once for the
532    /// expiry notifications — and adding a third provider to only one of them
533    /// is a silent half-feature: the sidebar lists a grant the notifier never
534    /// warns about. `None` is the honest answer for every other vendor, and
535    /// for Claude and SuperGrok while they hold none. Codex's usage response
536    /// states its count, so an empty [`ResetCredits`] there means "this
537    /// account banks resets and currently holds none" and is shown as such;
538    /// `None` for Codex means the response omitted the block.
539    pub fn reset_credits(&self) -> Option<&ResetCredits> {
540        match self {
541            Self::Anthropic(snapshot) => Some(&snapshot.reset_credits).filter(|c| !c.is_empty()),
542            Self::Openai(snapshot) => snapshot.reset_credits.as_ref(),
543            Self::SuperGrok(snapshot) => Some(&snapshot.reset_credits).filter(|c| !c.is_empty()),
544            _ => None,
545        }
546    }
547}
548
549/// Google Antigravity 2.0 / CLI snapshot. The API groups models into Gemini
550/// and third-party (Claude/GPT) buckets, and each group may carry a 5-hour and
551/// a weekly window — up to four, and not every product or plan offers all of
552/// them. Antigravity CLI 1.1.22 returns weekly buckets only, so every window is
553/// optional and a snapshot is valid when at least one arrived.
554#[derive(Debug, Clone, PartialEq)]
555pub struct AntigravitySnapshot {
556    pub plan: String,
557    /// Fingerprint of the signed-in account. Never displayed — it exists so a
558    /// cache written for one Google account is not served for another.
559    pub account: String,
560    /// Where the figures came from: a running local product, or the Cloud
561    /// Code API reached with the saved Google session when no local server
562    /// can answer — none is running, or `agy` withholds its CSRF token.
563    pub source: AntigravitySource,
564    /// Gemini group, 5-hour window.
565    pub session: Option<UsageWindow>,
566    /// Gemini group, weekly window.
567    pub weekly: Option<UsageWindow>,
568    /// Claude/GPT group, 5-hour window.
569    pub third_party_session: Option<UsageWindow>,
570    /// Claude/GPT group, weekly window.
571    pub third_party_weekly: Option<UsageWindow>,
572    /// Pools kept apart from the two shared groups, such as the weekly
573    /// "Gemini 4 Argon" limit on Pro plans, in the order the response listed
574    /// them. Empty for a product or plan that reports none.
575    pub model_pools: Vec<ModelPoolWindow>,
576}
577
578impl Eq for AntigravitySnapshot {}
579
580/// One window of an Antigravity per-model pool, named by the response's group
581/// display name. Its cadence is `window.window_duration`.
582#[derive(Debug, Clone, PartialEq)]
583pub struct ModelPoolWindow {
584    pub name: String,
585    pub window: UsageWindow,
586}
587
588impl Eq for ModelPoolWindow {}
589
590impl ModelPoolWindow {
591    /// Pools report the shared windows' two cadences, 5-hour and weekly.
592    pub fn is_weekly(&self) -> bool {
593        self.window.window_duration > chrono::Duration::hours(5)
594    }
595}
596
597/// Which path produced an [`AntigravitySnapshot`]. The local language server
598/// is the primary source; the remote API is the fallback for when no product
599/// is running, and the panel says so because the two can disagree briefly.
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
601pub enum AntigravitySource {
602    #[default]
603    Local,
604    Remote,
605    /// The running `agy` CLI's official status-line payload.
606    Statusline,
607    /// `agy`'s own reading, through `agy -p "/quota"`: the figures its Models
608    /// & Quota page shows, so the panel has nothing to qualify.
609    Agy,
610}
611
612impl AntigravitySource {
613    pub fn as_str(self) -> &'static str {
614        match self {
615            AntigravitySource::Local => "local",
616            AntigravitySource::Remote => "remote",
617            AntigravitySource::Statusline => "statusline",
618            AntigravitySource::Agy => "agy",
619        }
620    }
621
622    /// Inverse of [`as_str`](Self::as_str); anything unrecognised is `None` so
623    /// a cache reader can fall back to the default rather than guess.
624    pub fn parse(s: &str) -> Option<Self> {
625        match s {
626            "local" => Some(AntigravitySource::Local),
627            "remote" => Some(AntigravitySource::Remote),
628            "statusline" => Some(AntigravitySource::Statusline),
629            "agy" => Some(AntigravitySource::Agy),
630            _ => None,
631        }
632    }
633}
634
635/// MiniMax Token Plan — `/v1/token_plan/remains` returns one row per model
636/// bucket (`general` for text/coding, `video`), and each row carries its own
637/// rolling interval window plus a weekly window.
638///
639/// Two things the payload dictates rather than convention: the interval length
640/// is **not fixed** (`general` rolls every 5h, `video` every 24h), so the
641/// duration is derived from the row's own start/end rather than assumed; and
642/// the API reports the percentage **remaining**, which is inverted on the way
643/// in so these windows carry consumed-% like every other vendor's.
644#[derive(Debug, Clone, PartialEq, Eq)]
645pub struct MinimaxSnapshot {
646    pub plan: String,
647    /// `general` bucket — rolling interval window (5h on the observed plans).
648    pub session: UsageWindow,
649    /// `general` bucket — weekly window.
650    pub weekly: UsageWindow,
651    /// `video` bucket, `None` on plans that carry no video quota.
652    pub video_session: Option<UsageWindow>,
653    pub video_weekly: Option<UsageWindow>,
654}
655
656/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
657/// monthly `limit` is supplied from config (the API exposes neither the limit
658/// nor the remaining prepaid credit balance).
659#[derive(Debug, Clone, PartialEq)]
660pub struct AnthropicApiSnapshot {
661    pub spent: f64,
662    pub limit: Option<f64>,
663    /// Last UTC calendar day fully covered by the daily cost buckets.
664    pub covered_through: Option<chrono::NaiveDate>,
665}
666
667impl Eq for AnthropicApiSnapshot {}
668
669impl AnthropicApiSnapshot {
670    /// Spend as an integer percentage of the configured limit; `None` when no
671    /// positive limit is set.
672    pub fn pct(&self) -> Option<i32> {
673        self.limit
674            .filter(|l| l.is_finite() && *l > 0.0)
675            .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
676    }
677
678    pub fn coverage_label(&self) -> String {
679        match self.covered_through_day() {
680            Some(day) => format!("Month to date (through {day})"),
681            None => "Completed daily buckets this month".into(),
682        }
683    }
684
685    /// The last fully covered UTC day as the card prints it, `Oct 17`.
686    pub fn covered_through_day(&self) -> Option<String> {
687        let date = self.covered_through?;
688        const MONTHS: [&str; 12] = [
689            "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
690        ];
691        let month = MONTHS[chrono::Datelike::month0(&date) as usize];
692        Some(format!("{month} {}", chrono::Datelike::day(&date)))
693    }
694}
695
696/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
697/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
698#[derive(Debug, Clone, PartialEq)]
699pub struct KiloSnapshot {
700    pub label: String,
701    pub balance: f64,
702}
703
704impl Eq for KiloSnapshot {}
705
706/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
707/// all amounts already converted from the API's 1/10000-USD integers to USD.
708#[derive(Debug, Clone, PartialEq)]
709pub struct NovitaSnapshot {
710    /// Spendable credit balance (`availableBalance`).
711    pub available: f64,
712    /// Remaining top-up (`cashBalance`).
713    pub cash: f64,
714    /// Credit limit — max you can owe (`creditLimit`).
715    pub credit_limit: f64,
716    /// Amount currently owed (`outstandingInvoices`).
717    pub outstanding: f64,
718}
719
720impl Eq for NovitaSnapshot {}
721
722/// Synthetic's sole limit from `/v2/quotas`: the regenerating weekly credit
723/// budget (`planWeeklyLimit`, percentage-only) or its credit-denominated
724/// variant (`weeklyTokenLimit`). The legacy five-hour `subscription` counter
725/// stopped being tracked when the weekly window became the only metric.
726#[derive(Debug, Clone, PartialEq)]
727pub struct SyntheticSnapshot {
728    /// Configured plan tier label (go/standard/big) — the quotas API has no
729    /// tier identifier, so this is display-only config.
730    pub plan: Option<String>,
731    pub weekly: UsageWindow,
732    /// Credit spend figures when the plan reports a credit budget.
733    pub credits: Option<WeeklyCredits>,
734    /// Percentage points restored at the next regeneration tick.
735    pub next_regen_percent: Option<f64>,
736    /// The used share with the API's decimal precision preserved (the
737    /// integer `weekly.utilization_pct` is what meters and severity colors
738    /// use); frontends that print a figure read this so "70.5" does not
739    /// collapse to "70".
740    pub utilization_precise: f64,
741}
742
743impl Eq for SyntheticSnapshot {}
744
745/// Credit figures for Synthetic's weekly token budget.
746#[derive(Debug, Clone, PartialEq)]
747pub struct WeeklyCredits {
748    pub used: f64,
749    pub max: f64,
750    /// Credits restored at the next regeneration tick.
751    pub next_regen: f64,
752}
753
754impl Eq for WeeklyCredits {}
755
756/// Lyceum's platform credit balance is denominated in USD, not a percentage quota.
757#[derive(Debug, Clone, PartialEq, Serialize)]
758pub struct LyceumSnapshot {
759    pub available_credits: f64,
760    pub used_credits: f64,
761    pub total_credits_used: f64,
762    pub remaining_credits: f64,
763    pub monthly_free_credits: f64,
764    pub purchased_credits: f64,
765}
766impl Eq for LyceumSnapshot {}
767
768/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
769/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
770/// field in the response, so it's carried here from the region config.
771#[derive(Debug, Clone, PartialEq)]
772pub struct MoonshotSnapshot {
773    /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
774    /// the inference API.
775    pub available: f64,
776    /// Voucher credit (`voucher_balance`).
777    pub voucher: f64,
778    /// Cash balance (`cash_balance`); can be negative (debt).
779    pub cash: f64,
780    /// "USD" or "CNY", implied by the host.
781    pub currency: String,
782}
783
784impl Eq for MoonshotSnapshot {}
785
786/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
787/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
788#[derive(Debug, Clone, PartialEq)]
789pub struct GrokSnapshot {
790    pub balance: f64,
791}
792
793impl Eq for GrokSnapshot {}
794
795/// SuperGrok subscription usage from Grok Build's billing endpoint (ACP as
796/// fallback) plus banked remaining-resets. Distinct from [`GrokSnapshot`]
797/// (Management API prepaid balance).
798#[derive(Debug, Clone, PartialEq)]
799pub struct SuperGrokSnapshot {
800    /// Subscription tier label when the billing response supplies one
801    /// (e.g. "SuperGrok", "SuperGrok Heavy"); otherwise `"SuperGrok"`.
802    pub plan: String,
803    /// Opaque digest of Grok auth/config state. Never displayed — cache
804    /// isolation only.
805    pub account: String,
806    /// Current included-credit usage percent. The field name is retained as a
807    /// compatibility alias for format/render code; [`Self::period`] says
808    /// whether the server's actual window is weekly or monthly.
809    pub weekly_pct: i32,
810    pub period: SuperGrokPeriod,
811    /// When the current usage period ends.
812    pub reset_at: Option<DateTime<Utc>>,
813    /// Remaining prepaid (purchased) API credit in USD, when present.
814    pub prepaid_balance: Option<f64>,
815    pub reset_credits: ResetCredits,
816    /// Per-product slices of the same included-credit pool (`GrokBuild`,
817    /// `GrokChat`, `GrokImagine`, …). Empty when the billing document omits
818    /// `productUsage`.
819    pub products: Vec<SuperGrokProduct>,
820    /// On-demand spend beyond the included pool, in USD cents. From the Grok
821    /// CLI billing document's `onDemandUsed`. `None` when the field is absent.
822    pub on_demand_used_cents: Option<i64>,
823    /// On-demand spending cap for that same pool, in USD cents
824    /// (`onDemandCap`). `None` when the field is absent.
825    pub on_demand_cap_cents: Option<i64>,
826}
827
828impl SuperGrokSnapshot {
829    /// Additional usage beyond the included pool, as dollars.
830    ///
831    /// A cap renders as `$used / $cap`. Spend with no cap renders as the
832    /// amount alone. Both absent or zero is the proto default — nothing was
833    /// configured — so the line stays hidden.
834    pub fn additional_usage_text(&self) -> Option<String> {
835        let used = self.on_demand_used_cents.unwrap_or(0);
836        let cap = self.on_demand_cap_cents;
837        if used == 0 && cap.unwrap_or(0) == 0 {
838            return None;
839        }
840        let used_s = crate::format::usd(used as f64 / 100.0);
841        match cap {
842            Some(cap) if cap > 0 => Some(format!(
843                "{used_s} / {}",
844                crate::format::usd(cap as f64 / 100.0)
845            )),
846            _ => Some(used_s),
847        }
848    }
849}
850
851/// One SuperGrok product's share of the current included-credit window.
852#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
853pub struct SuperGrokProduct {
854    pub label: String,
855    pub percent: i32,
856}
857
858impl Eq for SuperGrokSnapshot {}
859
860#[derive(Debug, Clone, Copy, PartialEq, Eq)]
861pub enum SuperGrokPeriod {
862    Weekly,
863    Monthly,
864    Unknown,
865}
866
867impl SuperGrokPeriod {
868    pub fn label(self) -> &'static str {
869        match self {
870            Self::Weekly => "Weekly",
871            Self::Monthly => "Monthly",
872            Self::Unknown => "Current period",
873        }
874    }
875
876    pub fn short(self) -> &'static str {
877        match self {
878            Self::Weekly => "wk",
879            Self::Monthly => "mo",
880            Self::Unknown => "period",
881        }
882    }
883}
884
885/// Grok Bot desktop app — the weekly included-usage pool from
886/// `aiserver.v1.DashboardService/GetSandUsageStatus` (Connect-RPC), read with
887/// the app's own OAuth session. Distinct from [`GrokSnapshot`] (Management
888/// API prepaid dollars) and [`SuperGrokSnapshot`] (Grok Build subscription).
889#[derive(Debug, Clone, PartialEq, Eq)]
890pub struct GrokbotSnapshot {
891    /// `grokPlanLabel`, falling back to `cursorPlanName`, then "Grok Bot".
892    pub plan: String,
893    /// The subscription that bills the pool, from `billingBrand` and the plan
894    /// reported for it ("Cursor Ultra"). `None` for a brand not recognized yet,
895    /// which is left unnamed rather than guessed.
896    pub billed_by: Option<String>,
897    /// `hasNonZeroIncludedLimit`. When false the account carries no included
898    /// allowance at all — a distinct "no included allowance" state, never a
899    /// fabricated 0% meter.
900    pub has_included_allowance: bool,
901    /// `usagePercent` of the included pool (0..=100). Meaningful only when
902    /// `has_included_allowance` is set.
903    pub weekly_pct: i32,
904    /// `hasAvailableUsage` — the account can still serve requests, which at
905    /// 100% of the included pool means on-demand is picking up the rest.
906    pub has_available_usage: bool,
907    /// `onDemandSettings.enabled` — pay-as-you-go past the included pool.
908    pub on_demand_enabled: bool,
909    /// `currentPeriodStart`.
910    pub period_start: Option<DateTime<Utc>>,
911    /// `nextResetTimestampUtc`.
912    pub reset_at: Option<DateTime<Utc>>,
913    /// `reset_at − period_start` when both are reported (7 days on the
914    /// captured account) — computed, never assumed.
915    pub window: Option<chrono::Duration>,
916}
917
918impl GrokbotSnapshot {
919    /// The plan a frontend shows: the subscription that bills the pool
920    /// ("Cursor Ultra") over the app's own label, which reads "Grok Bot Plan"
921    /// on every account.
922    pub fn display_plan(&self) -> &str {
923        self.billed_by.as_deref().unwrap_or(&self.plan)
924    }
925
926    /// At 100% of the included pool, `hasAvailableUsage` can still be true
927    /// because on-demand keeps serving — say so, but only when the account
928    /// actually has on-demand switched on.
929    pub fn on_demand_note(&self) -> Option<&'static str> {
930        (self.has_included_allowance
931            && self.weekly_pct >= 100
932            && self.has_available_usage
933            && self.on_demand_enabled)
934            .then_some("included pool exhausted — on-demand may still be serving usage")
935    }
936}
937
938/// OpenAI Codex OAuth — exposes whichever rolling windows the API reports.
939#[derive(Debug, Clone, PartialEq, Eq)]
940pub struct OpenAiSnapshot {
941    pub plan: String,
942    /// 5h window, identified by its duration rather than its wire position.
943    pub session: Option<UsageWindow>,
944    /// 7d window, identified by its duration rather than its wire position.
945    pub weekly: Option<UsageWindow>,
946    /// Optional 7d code-review bucket.
947    pub code_review: Option<UsageWindow>,
948    /// Named limits beside the main one, each with its own windows. Empty for
949    /// an account that has none.
950    pub additional_limits: Vec<OpenAiNamedLimit>,
951    /// Models the account currently cannot dispatch to, with the time they
952    /// return when the API states one. Only unavailable models are kept: a
953    /// list of everything that *is* working is noise, and the reason this
954    /// exists is to explain a refusal no percentage accounts for.
955    pub unavailable_models: Vec<OpenAiUnavailableModel>,
956    /// Optional credit balance + approximate message-count ranges.
957    pub credits: Option<OpenAiCredits>,
958    /// `None` when the usage response omits the block (a Codex build that
959    /// predates banked resets); `Some` with `available == 0` when it states
960    /// that the account holds none, which the report shows as such.
961    pub reset_credits: Option<ResetCredits>,
962    /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
963    /// the placeholder set and the "OpenAI does not expose this for Plus"
964    /// tooltip when the OAuth path isn't available.
965    pub source: OpenAiSource,
966}
967
968/// A named limit that sits beside Codex's main window — a reserved pool or a
969/// model-specific allowance. It can be exhausted while the headline window is
970/// nearly untouched, which is the case it exists to make visible.
971#[derive(Debug, Clone, PartialEq, Eq)]
972pub struct OpenAiNamedLimit {
973    /// The API's own name for it, shown as given.
974    pub name: String,
975    pub session: Option<UsageWindow>,
976    pub weekly: Option<UsageWindow>,
977}
978
979/// A model the account cannot currently dispatch to.
980#[derive(Debug, Clone, PartialEq, Eq)]
981pub struct OpenAiUnavailableModel {
982    pub model: String,
983    /// When the API says it returns. `None` means it did not say.
984    pub available_at: Option<DateTime<Utc>>,
985}
986
987/// Banked, user-redeemable quota resets — Codex's "rate limit reset credits"
988/// and SuperGrok's "remaining resets" are the same idea under two names: a
989/// count you have earned, each with its own expiry, redeemed by hand rather
990/// than arriving on the window's own schedule. Distinct from a
991/// [`UsageWindow::resets_at`], which needs no action and cannot be banked.
992///
993/// The redemption identifier each provider returns alongside these
994/// (`credits[].id`, `tokens[].token_id`) is deliberately *not* carried here:
995/// it is the handle that spends the credit, and nothing that renders a status
996/// bar needs it.
997#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
998pub struct ResetCredits {
999    pub available: u32,
1000    /// One row per credit the provider described. May be shorter than
1001    /// `available` — Codex's usage endpoint gives the count without the
1002    /// per-credit detail, and the detail call is allowed to fail on its own.
1003    #[serde(default)]
1004    pub credits: Vec<ResetCredit>,
1005}
1006
1007#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1008pub struct ResetCredit {
1009    /// Provider label when one exists ("Full reset (Weekly + 5 hr)"). SuperGrok
1010    /// tokens have no title.
1011    #[serde(default, skip_serializing_if = "Option::is_none")]
1012    pub title: Option<String>,
1013    #[serde(default, skip_serializing_if = "Option::is_none")]
1014    pub expires_at: Option<DateTime<Utc>>,
1015}
1016
1017/// A provider's own label for a banked reset, rendered verbatim in Pango bar
1018/// markup and in the `;;`-delimited desktop FORMAT protocol. Both vendors that
1019/// carry one gate it here rather than each keeping a copy: an over-long or
1020/// control-character-bearing title is dropped, leaving the expiry line alone,
1021/// which still says everything the user has to act on.
1022pub fn checked_reset_title(value: Option<String>) -> Option<String> {
1023    const MAX_RESET_TITLE_CHARS: usize = 80;
1024    let value = value
1025        .map(|s| s.trim().to_string())
1026        .filter(|s| !s.is_empty())?;
1027    if value.chars().count() > MAX_RESET_TITLE_CHARS || value.chars().any(char::is_control) {
1028        None
1029    } else {
1030        Some(value)
1031    }
1032}
1033
1034impl ResetCredits {
1035    pub fn is_empty(&self) -> bool {
1036        self.available == 0
1037    }
1038
1039    pub fn next_expiry(&self) -> Option<DateTime<Utc>> {
1040        self.credits
1041            .iter()
1042            .filter_map(|credit| credit.expires_at)
1043            .min()
1044    }
1045}
1046
1047#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1048pub enum OpenAiSource {
1049    CodexOauth,
1050    AdminKeyMtd,
1051    Unavailable,
1052}
1053
1054#[derive(Debug, Clone, PartialEq, Eq)]
1055pub struct OpenAiCredits {
1056    /// Credit balance as a bare count of Codex credits ("62500", "12.5"),
1057    /// not dollars. A value the vendor sends that is not a number is kept as
1058    /// it came, so this stays a string.
1059    pub balance: String,
1060    pub has_credits: bool,
1061    pub unlimited: bool,
1062    /// OpenAI's estimate of how many messages the balance buys. Only the
1063    /// `{oai_local_msgs}`/`{oai_cloud_msgs}` placeholders carry them now.
1064    pub approx_local_messages: Option<(i64, i64)>,
1065    pub approx_cloud_messages: Option<(i64, i64)>,
1066}
1067
1068/// Z.AI / BigModel — list of buckets with discriminated types. We project the
1069/// two we care about into named fields (5h tokens, weekly tokens, MCP).
1070#[derive(Debug, Clone, PartialEq, Eq)]
1071pub struct ZaiSnapshot {
1072    pub plan: String,
1073    pub session: Option<UsageWindow>,
1074    pub weekly: Option<UsageWindow>,
1075    pub mcp: Option<UsageWindow>,
1076}
1077
1078/// Ollama Cloud — credit balances or legacy quota windows from
1079/// `ollama.com/api/balance`. Older cached `/api/usage` payloads can also
1080/// carry per-model counts and activity cost.
1081#[derive(Debug, Clone, PartialEq, Eq)]
1082pub struct OllamaSnapshot {
1083    /// Display label, taken from the `[ollama] plan` config field. The API
1084    /// itself does not report a plan name.
1085    pub plan: String,
1086    /// 5h rolling window (`limits.session`). `None` when the account has not
1087    /// touched the cloud tier yet (the window is omitted from the response,
1088    /// not reported as zero).
1089    pub session: Option<UsageWindow>,
1090    /// 7d rolling window (`limits.weekly`).
1091    pub weekly: Option<UsageWindow>,
1092    /// Calendar-month window (`limits.monthly`). Some Pro accounts report
1093    /// this in place of `session`/`weekly` instead of alongside them.
1094    pub monthly: Option<UsageWindow>,
1095    /// Per-model request counts inside the session window, in the order the
1096    /// API returned them. Renderers sort and truncate this for the tooltip.
1097    pub session_models: Vec<OllamaModelUsage>,
1098    /// Per-model request counts inside the weekly window.
1099    pub weekly_models: Vec<OllamaModelUsage>,
1100    /// Per-model request counts inside the monthly window.
1101    pub monthly_models: Vec<OllamaModelUsage>,
1102    /// `activity.cost` as a pre-formatted dollar string (`"0.00000"`,
1103    /// `"1.23456"`). Already a string on the wire — the renderer decides
1104    /// whether to keep it verbatim or reformat.
1105    pub activity_cost: Option<String>,
1106    /// `activity.period.type` (`"last_4_weeks"` and friends). A short
1107    /// human-readable label the renderer can show next to the cost.
1108    pub activity_period: Option<String>,
1109    /// Monthly included credits and optional purchased credits, formatted
1110    /// through the shared money formatter for all desktop surfaces.
1111    pub credits: Option<OllamaCredits>,
1112}
1113
1114#[derive(Debug, Clone, PartialEq, Eq)]
1115pub struct OllamaCredits {
1116    pub balance: String,
1117    pub allowance: String,
1118    pub purchased: Option<String>,
1119}
1120
1121/// One row of `OllamaSnapshot::{session,weekly}_models`. The API carries the
1122/// per-model request count; the percentage of the window that this single
1123/// model represents is not reported, so the renderer derives it locally.
1124#[derive(Debug, Clone, PartialEq, Eq)]
1125pub struct OllamaModelUsage {
1126    pub name: String,
1127    pub request_count: u64,
1128}
1129
1130/// One DeepRelay plan meter: weighted tokens used against the allowance.
1131///
1132/// DeepRelay multiplies served tokens by each model's usage factor before they
1133/// are metered, so every figure here is already in allowance units and is
1134/// comparable with its own limit and with the other three meters.
1135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1136pub struct DeepRelayMeter {
1137    pub used: i64,
1138    pub limit: i64,
1139}
1140
1141impl DeepRelayMeter {
1142    /// Used share of the allowance. `None` only for a zero ceiling, which is
1143    /// "uncapped", not "0% used".
1144    pub fn pct(&self) -> Option<i32> {
1145        if self.limit <= 0 {
1146            return None;
1147        }
1148        let pct = (self.used as f64 / self.limit as f64 * 100.0).round();
1149        Some(pct.clamp(0.0, 100.0) as i32)
1150    }
1151
1152    /// `"812.4M / 2.5B"` — the raw figure next to its ceiling.
1153    pub fn used_of_limit(&self) -> String {
1154        format!(
1155            "{} / {}",
1156            crate::format::tokens(self.used),
1157            crate::format::tokens(self.limit)
1158        )
1159    }
1160}
1161
1162/// One row of [`DeepRelaySnapshot::models`].
1163#[derive(Debug, Clone, PartialEq, Eq)]
1164pub struct DeepRelayModelUsage {
1165    pub model: String,
1166    pub weighted_input_tokens: i64,
1167    pub weighted_output_tokens: i64,
1168}
1169
1170impl DeepRelayModelUsage {
1171    pub fn weighted_total(&self) -> i64 {
1172        self.weighted_input_tokens
1173            .saturating_add(self.weighted_output_tokens)
1174    }
1175}
1176
1177/// DeepRelay — a monthly plan with four weighted-token meters, a dollar usage
1178/// ceiling, and an optional pay-as-you-go wallet.
1179///
1180/// The monthly meters reset at the end of the billing period. The weekly caps
1181/// **roll** (seven days, no reset instant), so a `None` reset there is the
1182/// vendor's answer, not missing data.
1183#[derive(Debug, Clone, PartialEq)]
1184pub struct DeepRelaySnapshot {
1185    /// `plan.name`, or "Pay as you go" when the organization has no plan.
1186    pub plan: String,
1187    pub plan_price_cents: Option<i64>,
1188    /// `"month"`, `"3 months"`, … — already pluralized from `interval_count`.
1189    pub plan_interval: Option<String>,
1190    pub subscribed: bool,
1191    pub cancel_at_period_end: bool,
1192    pub monthly_input: Option<DeepRelayMeter>,
1193    pub monthly_output: Option<DeepRelayMeter>,
1194    pub weekly_input: Option<DeepRelayMeter>,
1195    pub weekly_output: Option<DeepRelayMeter>,
1196    /// Plan spend against the per-period usage ceiling, in dollars.
1197    pub backstop_used_usd: Option<f64>,
1198    pub backstop_limit_usd: Option<f64>,
1199    /// `(used, included)` web searches this period.
1200    pub searches: Option<(u64, u64)>,
1201    /// Calls billed to the wallet in the last 24 h while subscribed.
1202    pub payg_calls_24h: Option<u64>,
1203    /// Wallet balance in dollars. Absent unless the balance request was made
1204    /// and answered.
1205    pub balance_usd: Option<f64>,
1206    pub period_start: Option<chrono::DateTime<chrono::Utc>>,
1207    pub period_end: Option<chrono::DateTime<chrono::Utc>>,
1208    pub models: Vec<DeepRelayModelUsage>,
1209}
1210
1211impl DeepRelaySnapshot {
1212    /// The monthly meter closest to its ceiling — the one that will cap the
1213    /// account. Drives the bar headline, its severity class and the TUI
1214    /// overview cell.
1215    pub fn monthly_worst(&self) -> Option<&DeepRelayMeter> {
1216        worst(&self.monthly_input, &self.monthly_output)
1217    }
1218
1219    /// The rolling-weekly meter closest to its ceiling.
1220    pub fn weekly_worst(&self) -> Option<&DeepRelayMeter> {
1221        worst(&self.weekly_input, &self.weekly_output)
1222    }
1223
1224    /// Monthly allowance as the shared window shape, for `--format` pacing.
1225    ///
1226    /// Both period ends come from the server, so the length is exact rather
1227    /// than a guessed "30 days" — but the report still exposes no `window_secs`
1228    /// for these rows (they go through `push_metric`), because a frontend reads
1229    /// that field as a rolling window. Pacing here is local to a placeholder.
1230    pub fn monthly_window(&self) -> Option<UsageWindow> {
1231        let meter = self.monthly_worst()?;
1232        Some(UsageWindow {
1233            utilization_pct: meter.pct()?,
1234            resets_at: self.period_end,
1235            window_duration: match (self.period_start, self.period_end) {
1236                (Some(start), Some(end)) if end > start => end - start,
1237                _ => chrono::Duration::zero(),
1238            },
1239        })
1240    }
1241
1242    /// Rolling weekly caps as the shared window shape. The window rolls, so it
1243    /// carries no reset instant; pacing stays neutral rather than guessing.
1244    pub fn weekly_window(&self) -> Option<UsageWindow> {
1245        let meter = self.weekly_worst()?;
1246        Some(UsageWindow {
1247            utilization_pct: meter.pct()?,
1248            resets_at: None,
1249            window_duration: chrono::Duration::days(7),
1250        })
1251    }
1252}
1253
1254// The dollar figures are finite by construction (they are integer cents and
1255// micro-cents divided by a constant), so the snapshot is total in the sense
1256// `Eq` asks for — the same reasoning as `OpenRouterSnapshot`.
1257impl Eq for DeepRelaySnapshot {}
1258
1259fn worst<'a>(
1260    left: &'a Option<DeepRelayMeter>,
1261    right: &'a Option<DeepRelayMeter>,
1262) -> Option<&'a DeepRelayMeter> {
1263    [left.as_ref(), right.as_ref()]
1264        .into_iter()
1265        .flatten()
1266        .max_by_key(|meter| meter.pct().unwrap_or(0))
1267}
1268
1269/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
1270/// `/api/v1/credits` and `/api/v1/key`.
1271#[derive(Debug, Clone, PartialEq)]
1272pub struct OpenRouterSnapshot {
1273    pub label: String,
1274    pub total_credits: f64,
1275    pub total_usage: f64,
1276    pub usage_daily: f64,
1277    pub usage_weekly: f64,
1278    pub usage_monthly: f64,
1279    pub is_free_tier: bool,
1280    pub limit: Option<f64>,
1281    pub limit_remaining: Option<f64>,
1282    pub recent_models: Vec<String>,
1283}
1284
1285impl Eq for OpenRouterSnapshot {}
1286
1287impl OpenRouterSnapshot {
1288    /// Spendable credit, **which can be negative**: OpenRouter lets an account
1289    /// run into debt, and clamping that to zero would report a healthy-looking
1290    /// `$0.00` to someone who has to top up before anything works again. The
1291    /// wire fields are each non-negative (see `openrouter::types`), so a
1292    /// negative result only ever means usage has overrun credits.
1293    pub fn balance(&self) -> f64 {
1294        self.total_credits - self.total_usage
1295    }
1296    /// Percentage of total_credits consumed (0..=100). Returns 0 when
1297    /// `total_credits` is 0 (free-tier-only accounts) — there is no
1298    /// denominator to be a percentage of. Severity does not come from this
1299    /// number alone: see [`crate::openrouter::vendor::severity`], which treats
1300    /// a negative [`Self::balance`] as critical regardless of the percentage.
1301    pub fn consumed_pct(&self) -> i32 {
1302        if self.total_credits <= 0.0 {
1303            return 0;
1304        }
1305        i32::from(crate::format::clamp_pct(
1306            (self.total_usage / self.total_credits) * 100.0,
1307        ))
1308    }
1309}
1310
1311/// OrcaRouter — prepaid credit card from the one-api compatible dashboard
1312/// billing endpoints (`/v1/dashboard/billing/usage` + `/subscription`), over an
1313/// API key. Usage arrives in **US cents** (`total_usage: 275` = $2.75); the
1314/// subscription's limit fields are USD and mean the *total* credit limit
1315/// (remaining + used), with `100000000` as the unlimited sentinel.
1316#[derive(Debug, Clone, PartialEq, Eq)]
1317pub struct OrcaRouterSnapshot {
1318    /// Cumulative spend, exact US cents (`total_usage`).
1319    pub spent_cents: i64,
1320    /// Total credit limit in exact US cents. `None` for unlimited keys (the
1321    /// `100000000` sentinel) or when the subscription response carried no
1322    /// limit field at all — either way the card is spend-only.
1323    pub limit_cents: Option<i64>,
1324    /// Key expiry (`access_until`, Unix seconds); `None` = no expiry (a wire
1325    /// `0` means the same thing).
1326    pub access_until: Option<DateTime<Utc>>,
1327}
1328
1329impl OrcaRouterSnapshot {
1330    pub fn spent_usd(&self) -> f64 {
1331        self.spent_cents as f64 / 100.0
1332    }
1333
1334    pub fn limit_usd(&self) -> Option<f64> {
1335        self.limit_cents.map(|c| c as f64 / 100.0)
1336    }
1337
1338    /// Remaining credit in exact cents. Can be negative (spend past the
1339    /// limit) — the sign belongs outside the symbol, like OpenRouter debt.
1340    pub fn remaining_cents(&self) -> Option<i64> {
1341        self.limit_cents.map(|limit| limit - self.spent_cents)
1342    }
1343
1344    pub fn remaining_usd(&self) -> Option<f64> {
1345        self.remaining_cents().map(|c| c as f64 / 100.0)
1346    }
1347
1348    /// Integer-percentage of the limit consumed, computed in cents so no
1349    /// float division is involved. `None` when there is no limit — an
1350    /// unlimited key has no percentage to be exact *about*.
1351    pub fn consumed_pct(&self) -> Option<i32> {
1352        self.limit_cents.filter(|l| *l > 0).map(|limit| {
1353            let pct = (self.spent_cents.saturating_mul(100)) / limit;
1354            pct.clamp(0, 100) as i32
1355        })
1356    }
1357}
1358
1359/// Alibaba Cloud Model Studio Token Plan — a 5-hour, a weekly and a monthly
1360/// ratio window, any of which the console account may not report. An absent
1361/// window is no-data (possibly unlimited), never 0%.
1362#[derive(Debug, Clone, PartialEq, Eq)]
1363pub struct ModelStudioSnapshot {
1364    /// 5-hour window. `None` when `per5HourPercentage` was absent.
1365    pub session: Option<UsageWindow>,
1366    /// Weekly window. `None` when `per1WeekPercentage` was absent.
1367    pub weekly: Option<UsageWindow>,
1368    /// Monthly window. `None` when `per1MonthPercentage` was absent. The
1369    /// reset is exact; the cycle length is plan-dependent, so no exact
1370    /// window duration is declared for it (same rule as Ollama's calendar
1371    /// month) and pacing is omitted wherever it would guess a denominator.
1372    pub monthly: Option<UsageWindow>,
1373}
1374
1375/// Muse Code subscription windows. Missing observations stay absent.
1376#[derive(Debug, Clone, PartialEq, Eq)]
1377pub struct MuseSnapshot {
1378    pub plan: String,
1379    pub session: Option<UsageWindow>,
1380    pub weekly: Option<UsageWindow>,
1381}
1382
1383/// Devin CLI quota snapshot. The CLI reports remaining percentages, while
1384/// renderers and report consumers use consumed percentages consistently.
1385#[derive(Debug, Clone, PartialEq, Eq)]
1386pub struct DevinSnapshot {
1387    pub daily: Option<UsageWindow>,
1388    pub weekly: Option<UsageWindow>,
1389    /// Exact value from the CLI's overageBalanceMicros field. Its USD meaning
1390    /// is empirically observed for the tested account, not a universal API
1391    /// contract; only renderers perform that display conversion.
1392    pub overage_balance_micros: Option<i64>,
1393}
1394
1395/// Factory `droid` CLI subscription quota. The limits endpoint reports a
1396/// `usedPercent` per window in two buckets — Standard and Droid Core — each
1397/// with a 5-hour, a weekly and a monthly window. A window the account has not
1398/// started still reports a percentage with `windowEnd: null`, so it is present
1399/// with no reset; an absent window stays absent.
1400#[derive(Debug, Clone, PartialEq, Eq)]
1401pub struct DroidSnapshot {
1402    /// Standard quota, 5-hour window.
1403    pub session: Option<UsageWindow>,
1404    /// Standard quota, weekly (7d) window.
1405    pub weekly: Option<UsageWindow>,
1406    /// Standard quota, monthly (30d) window.
1407    pub monthly: Option<UsageWindow>,
1408    /// Droid Core quota, 5-hour window.
1409    pub core_session: Option<UsageWindow>,
1410    /// Droid Core quota, weekly window.
1411    pub core_weekly: Option<UsageWindow>,
1412    /// Droid Core quota, monthly window.
1413    pub core_monthly: Option<UsageWindow>,
1414    /// `extraUsageBalanceCents`, verbatim; renderers convert cents to dollars.
1415    pub extra_balance_cents: Option<i64>,
1416}
1417
1418/// Worst-of severity class for the Waybar bar text color. Mirrors
1419/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
1420pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
1421    let mut max = snap.session.utilization_pct;
1422    if snap.weekly.utilization_pct > max {
1423        max = snap.weekly.utilization_pct;
1424    }
1425    if let Some(s) = &snap.sonnet
1426        && s.utilization_pct > max
1427    {
1428        max = s.utilization_pct;
1429    }
1430    for sw in &snap.scoped {
1431        if sw.window.utilization_pct > max {
1432            max = sw.window.utilization_pct;
1433        }
1434    }
1435    // Extra usage only promotes severity if a rate-limit window is at 100%.
1436    let any_at_cap = snap.session.utilization_pct >= 100
1437        || snap.weekly.utilization_pct >= 100
1438        || snap
1439            .sonnet
1440            .as_ref()
1441            .is_some_and(|s| s.utilization_pct >= 100)
1442        || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
1443    if any_at_cap && let Some(extra) = snap.extra.as_ref() {
1444        let p = extra.percent();
1445        if p > max {
1446            max = p;
1447        }
1448    }
1449    crate::pango::severity_for(max)
1450}
1451
1452#[cfg(test)]
1453mod tests {
1454    use super::*;
1455    use crate::pacing::PaceSeverity;
1456    use chrono::Duration;
1457
1458    fn w(pct: i32) -> UsageWindow {
1459        UsageWindow {
1460            utilization_pct: pct,
1461            resets_at: None,
1462            window_duration: Duration::hours(5),
1463        }
1464    }
1465
1466    fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
1467        AnthropicSnapshot {
1468            plan: "Max 5x".into(),
1469            session: w(s),
1470            weekly: w(w_),
1471            sonnet: sonnet.map(w),
1472            scoped: vec![],
1473            extra: extra.map(|(limit, spent)| ExtraUsage {
1474                limit: Some(Cents(limit)),
1475                spent: Cents(spent),
1476                currency: None,
1477                decimal_places: Some(2),
1478                status: ExtraUsageStatus::Active,
1479            }),
1480            reset_credits: Default::default(),
1481            cloud_credits: None,
1482            usage_credits: None,
1483        }
1484    }
1485
1486    #[test]
1487    fn fmt_minor_honors_currency_and_scale() {
1488        // No currency (older payloads) keeps the historical `$`.
1489        assert_eq!(fmt_minor(250, 2, None), "$2.50");
1490        // The #30 reporter's actual figures: BRL must not be claimed as `$`.
1491        assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
1492        assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
1493        // Zero-exponent currency: no decimal point, no /100.
1494        assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
1495        // Sign precedes the symbol, as `format::money` spells it.
1496        assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
1497        // Unknown code stays truthful as a suffix rather than guessing a symbol.
1498        assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
1499    }
1500
1501    #[test]
1502    fn extra_usage_formats_in_its_own_currency() {
1503        let e = ExtraUsage {
1504            limit: None,
1505            spent: Cents(14157),
1506            currency: Some("BRL".into()),
1507            decimal_places: Some(2),
1508            status: ExtraUsageStatus::Active,
1509        };
1510        assert_eq!(e.fmt_spent(), "R$141.57");
1511        assert_eq!(e.fmt_limit(), None);
1512
1513        let capped = ExtraUsage {
1514            limit: Some(Cents(5000)),
1515            spent: Cents(250),
1516            currency: None,
1517            decimal_places: Some(2),
1518            status: ExtraUsageStatus::Active,
1519        };
1520        assert_eq!(capped.fmt_spent(), "$2.50");
1521        assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
1522    }
1523
1524    #[test]
1525    fn extra_percent_with_zero_limit_is_zero() {
1526        assert_eq!(
1527            ExtraUsage {
1528                limit: Some(Cents(0)),
1529                spent: Cents(100),
1530                currency: None,
1531                decimal_places: Some(2),
1532                status: ExtraUsageStatus::Active,
1533            }
1534            .percent(),
1535            0
1536        );
1537    }
1538
1539    #[test]
1540    fn extra_percent_truncates() {
1541        // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
1542        assert_eq!(
1543            ExtraUsage {
1544                limit: Some(Cents(10000)),
1545                spent: Cents(3333),
1546                currency: None,
1547                decimal_places: Some(2),
1548                status: ExtraUsageStatus::Active,
1549            }
1550            .percent(),
1551            33
1552        );
1553    }
1554
1555    #[test]
1556    fn severity_picks_worst_of_three_windows() {
1557        let s = snap(40, 60, Some(80), None);
1558        assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
1559    }
1560
1561    #[test]
1562    fn severity_ignores_extra_when_no_cap_hit() {
1563        // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
1564        let s = snap(50, 60, None, Some((10000, 9500)));
1565        assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
1566    }
1567
1568    #[test]
1569    fn severity_promotes_extra_when_session_at_100() {
1570        let s = snap(100, 50, None, Some((10000, 9500)));
1571        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
1572    }
1573
1574    #[test]
1575    fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
1576        // session = 100, weekly = 50, extra = 100% → max should be 100.
1577        let s = snap(100, 50, None, Some((10000, 10000)));
1578        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
1579    }
1580
1581    fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
1582        s.scoped.push(ScopedWindow {
1583            label: "Fable".into(),
1584            window: w(pct),
1585        });
1586        s
1587    }
1588
1589    #[test]
1590    fn severity_includes_scoped_windows() {
1591        // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
1592        // week at 84 → the bar class must escalate to High.
1593        let s = with_scoped(snap(10, 55, None, None), 84);
1594        assert_eq!(anthropic_severity(&s), PaceSeverity::High);
1595    }
1596
1597    #[test]
1598    fn severity_promotes_extra_when_scoped_at_100() {
1599        // A scoped window at cap counts as a rate-limit cap hit, so extra
1600        // usage above the window max is promoted — same rule as session/weekly.
1601        let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
1602        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
1603    }
1604
1605    #[test]
1606    fn kimi_percent_is_exact_above_f64_precision() {
1607        let snap = KimiSnapshot {
1608            plan: None,
1609            weekly_limit: (1 << 53) + 1,
1610            weekly_used: 1 << 52,
1611            weekly_remaining: 0,
1612            weekly_reset_at: None,
1613            has_weekly: true,
1614            monthly_pct: None,
1615            monthly_reset_at: None,
1616            window_limit: u64::MAX,
1617            window_used: u64::MAX - 1,
1618            window_remaining: 0,
1619            window_reset_at: None,
1620        };
1621        assert_eq!(snap.weekly_pct(), 50);
1622        assert_eq!(snap.window_pct(), 100);
1623    }
1624
1625    #[test]
1626    fn kiro_pct_is_zero_without_a_positive_limit() {
1627        let snap = KiroSnapshot {
1628            plan: "FREE".into(),
1629            used: 5.0,
1630            limit: 0.0,
1631            reset_at: None,
1632        };
1633        assert_eq!(snap.pct(), 0);
1634    }
1635
1636    #[test]
1637    fn kiro_pct_rounds_the_credit_ratio() {
1638        let snap = KiroSnapshot {
1639            plan: "KIRO POWER".into(),
1640            used: 1.0,
1641            limit: 3.0,
1642            reset_at: None,
1643        };
1644        assert_eq!(snap.pct(), 33);
1645    }
1646}