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