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}