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