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
65impl Cents {
66 /// Format as `[-]$D.CC`. Negative values render `-$D.CC` (not `$-D.CC`),
67 /// matching claudebar's `_fmt_dollars` (claudebar:532-537).
68 pub fn fmt_dollars(self) -> String {
69 let (sign, abs) = if self.0 < 0 {
70 ("-", -self.0)
71 } else {
72 ("", self.0)
73 };
74 format!("{sign}${}.{:02}", abs / 100, abs % 100)
75 }
76}
77
78/// Anthropic-specific snapshot — three rolling windows plus optional
79/// pay-as-you-go credit balance.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct AnthropicSnapshot {
82 /// "Claude Pro", "Claude Max 5x", "Claude Max 20x", etc.
83 pub plan: String,
84 pub session: UsageWindow,
85 pub weekly: UsageWindow,
86 /// Some vendors of Claude (Pro, some Max tiers) don't have a separate
87 /// Sonnet bucket — in which case this is None.
88 pub sonnet: Option<UsageWindow>,
89 /// Model-scoped weekly windows from the newer `limits[]` array
90 /// (`kind == "weekly_scoped"`), e.g. the Fable weekly cap. Labels come
91 /// from the API (`scope.model.display_name`), so new models show up
92 /// without a code change. Empty when the account has none.
93 pub scoped: Vec<ScopedWindow>,
94 /// `None` when `extra_usage.is_enabled` is false or the block is absent.
95 pub extra: Option<ExtraUsage>,
96}
97
98/// A usage window scoped to a specific model, labeled by the API
99/// (e.g. "Fable"). Weekly (7d) duration.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ScopedWindow {
102 pub label: String,
103 pub window: UsageWindow,
104}
105
106/// "Extra usage" pay-as-you-go block (claudebar's `extra_usage`).
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct ExtraUsage {
109 /// `None` when the payload carries no usable `monthly_limit` — an
110 /// explicit null (observed for plans without a spending cap, e.g. Claude
111 /// Pro, #30) or an absent field. Either way the spend is real and stays
112 /// visible; only the limit is unreported, and the renderers say exactly
113 /// that rather than inferring a plan tier from it.
114 pub limit: Option<Cents>,
115 pub spent: Cents,
116 /// ISO code from the block (`"BRL"`, `"USD"`, …). `None` on older payloads
117 /// that predate the field — formatted as `$` for back-compat, which was
118 /// the only behaviour before the field existed.
119 pub currency: Option<String>,
120 /// Minor-unit digits from the block's `decimal_places` (BRL/USD = 2,
121 /// JPY/KRW = 0). `None` means the wire did not report the scale. We keep
122 /// that absence instead of guessing from an incomplete currency table.
123 pub decimal_places: Option<u32>,
124}
125
126impl ExtraUsage {
127 /// Integer percentage of the monthly limit consumed (0..=100, saturating
128 /// at 0 when limit is non-positive — matches claudebar:540-542).
129 ///
130 /// With no cap there is no denominator, so no meaningful percentage
131 /// exists; 0 keeps the bar and severity calm rather than inventing one.
132 pub fn percent(&self) -> i32 {
133 match self.limit {
134 Some(l) if l.0 > 0 => ((self.spent.0 * 100) / l.0) as i32,
135 _ => 0,
136 }
137 }
138
139 pub fn fmt_spent(&self) -> String {
140 self.fmt_amount(self.spent)
141 }
142
143 pub fn fmt_limit(&self) -> Option<String> {
144 self.limit.map(|l| self.fmt_amount(l))
145 }
146
147 fn fmt_amount(&self, amount: Cents) -> String {
148 match (self.decimal_places, self.currency.as_deref()) {
149 (Some(decimal_places), currency) => fmt_minor(amount.0, decimal_places, currency),
150 // Legacy payloads predate both fields and were always cents/USD.
151 // Preserve that established behaviour only when neither field can
152 // tell us otherwise.
153 (None, None) => fmt_minor(amount.0, 2, None),
154 // A currency code alone does not determine its ISO minor-unit
155 // exponent. Keep the amount truthful instead of silently dividing
156 // zero-, three-, or four-decimal currencies by the wrong scale.
157 (None, Some(currency)) => fmt_minor_units(amount.0, currency),
158 }
159 }
160}
161
162fn fmt_minor_units(minor: i64, currency: &str) -> String {
163 let sign = if minor < 0 { "-" } else { "" };
164 format!("{sign}{} minor units {currency}", minor.unsigned_abs())
165}
166
167/// Format an amount in minor units with its own currency and scale.
168///
169/// The scale is this function's own — `money` cannot express a zero- or
170/// three-decimal currency — but the *symbol* comes from
171/// [`crate::format::with_currency`], so the two cannot disagree about what a
172/// given code looks like.
173pub fn fmt_minor(minor: i64, decimal_places: u32, currency: Option<&str>) -> String {
174 let scale = 10_u64.pow(decimal_places);
175 // `unsigned_abs`, not negation: `-i64::MIN` overflows. Unreachable from
176 // the wire (the parse gate rejects negatives) but this is a pub fn.
177 let sign = if minor < 0 { "-" } else { "" };
178 let abs = minor.unsigned_abs();
179 let number = if decimal_places == 0 {
180 format!("{abs}")
181 } else {
182 format!(
183 "{}.{:0width$}",
184 abs / scale,
185 abs % scale,
186 width = decimal_places as usize
187 )
188 };
189 crate::format::with_currency(sign, &number, currency)
190}
191
192/// DeepSeek — credit balance from `/user/balance`.
193#[derive(Debug, Clone, PartialEq)]
194pub struct DeepseekSnapshot {
195 pub is_available: bool,
196 /// Current balance (prefer USD, fallback to CNY).
197 pub balance: f64,
198 /// Free-granted credits component.
199 pub granted: f64,
200 /// Topped-up (purchased) credits component.
201 pub topped_up: f64,
202 /// The currency of the above amounts (currently "USD" or "CNY").
203 pub currency: String,
204}
205
206impl Eq for DeepseekSnapshot {}
207
208impl Default for DeepseekSnapshot {
209 fn default() -> Self {
210 Self {
211 is_available: false,
212 balance: 0.0,
213 granted: 0.0,
214 topped_up: 0.0,
215 currency: String::new(),
216 }
217 }
218}
219
220/// Cursor — the two included-usage pools the dashboard shows, from the
221/// undocumented `cursor.com/api/usage-summary` endpoint (the same one the
222/// dashboard's own frontend calls), authenticated with the session token the
223/// Cursor IDE wrote to its local `state.vscdb`.
224///
225/// Since Cursor's mid-2026 pricing, a plan's included compute is split into two
226/// quota pools, each shown as a percentage: **Cursor Models** (Auto + Composer,
227/// `autoPercentUsed`) and **Other Models** (named / third-party, `apiPercentUsed`).
228/// Overflow past either pool falls to on-demand spend. Percentages are integers
229/// (rounded from the wire floats) to match the dashboard and every other
230/// vendor's integer-percent convention; they can exceed 100 when a pool is over
231/// its included allowance.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct CursorSnapshot {
234 /// Membership label, title-cased from `membershipType` (e.g. "Ultra").
235 pub plan: String,
236 /// "Cursor Models" pool — Auto + Composer (`autoPercentUsed`, rounded).
237 pub auto_pct: i32,
238 /// "Other Models" pool — named / third-party (`apiPercentUsed`, rounded).
239 pub api_pct: i32,
240 /// Overall included usage (`totalPercentUsed`, rounded) — the dashboard's
241 /// "you've used N% of your included total usage" headline.
242 pub total_pct: i32,
243 /// `true` when the plan reports `isUnlimited` — the pools don't cap and the
244 /// percentages are not meaningful.
245 pub unlimited: bool,
246 /// Whether on-demand (overage) spend is turned on (`onDemand.enabled`).
247 pub on_demand_enabled: bool,
248 /// On-demand spend in cents (`onDemand.used`), when Cursor reports it.
249 pub on_demand_used_cents: Option<i64>,
250 /// Configured on-demand spending limit in cents (`onDemand.limit`).
251 pub on_demand_limit_cents: Option<i64>,
252 /// End of the current billing cycle (`billingCycleEnd`) — when the pools
253 /// reset.
254 pub reset_at: Option<DateTime<Utc>>,
255 /// Start of the current billing cycle (`billingCycleStart`), when the API
256 /// sends it. With `reset_at` it gives the exact window length the pace
257 /// projection needs; absent, no window length is reported at all.
258 pub cycle_start: Option<DateTime<Utc>>,
259}
260
261impl CursorSnapshot {
262 /// Length of the current billing cycle, but only when the API stated both
263 /// ends and they are ordered. `None` otherwise — older responses and every
264 /// snapshot cached before `billingCycleStart` existed omit the start, and a
265 /// guessed month would reach a frontend as an exact window and be paced as
266 /// one. `window_secs` is absent instead; the reset time still shows.
267 pub fn cycle_window(&self) -> Option<chrono::Duration> {
268 match (self.cycle_start, self.reset_at) {
269 (Some(start), Some(end)) if end > start => Some(end - start),
270 _ => None,
271 }
272 }
273
274 /// The binding pool — whichever is closest to (or furthest past) its cap.
275 /// Drives the bar color and the single generic `session_pct` alias.
276 pub fn worst_pct(&self) -> i32 {
277 self.auto_pct.max(self.api_pct)
278 }
279}
280
281/// Kiro CLI (AWS CodeWhisperer / Q Developer backend) — a single credit pool
282/// from `AmazonCodeWhispererService.GetUsageLimits`, the same call kiro-cli's
283/// own `/usage` slash command makes. Authenticated with the AWS SSO OIDC
284/// bearer token kiro-cli already cached locally, refreshed with the paired
285/// refresh token when it's close to expiry — see `kiro::db` and `kiro::oauth`.
286#[derive(Debug, Clone, PartialEq)]
287pub struct KiroSnapshot {
288 /// Subscription tier label (`subscriptionInfo.subscriptionTitle`, e.g.
289 /// "KIRO POWER").
290 pub plan: String,
291 /// Credits consumed this cycle (`currentUsageWithPrecision`).
292 pub used: f64,
293 /// Credits included in the plan (`usageLimitWithPrecision`).
294 pub limit: f64,
295 /// When the credit pool resets (`nextDateReset`).
296 pub reset_at: Option<DateTime<Utc>>,
297}
298
299impl Eq for KiroSnapshot {}
300
301impl KiroSnapshot {
302 /// Percentage of the credit pool consumed, rounded. `0` when `limit` is
303 /// not positive — defensive; the API has not been observed to send that.
304 pub fn pct(&self) -> i32 {
305 if self.limit <= 0.0 {
306 return 0;
307 }
308 ((self.used / self.limit) * 100.0)
309 .round()
310 .clamp(0.0, 9999.0) as i32
311 }
312}
313
314/// Kimi Code — weekly subscription quota plus a 5h rolling rate-limit window.
315///
316/// Accounts on the newer `/coding/v1/usages` response shape have no weekly
317/// counters at all: the top-level `usage` block is replaced by a `usages` map
318/// of ratios, of which only `limit_month_total` — the combined monthly pool —
319/// is read. On such accounts `has_weekly` is false, the weekly counters stay
320/// zero (never fabricated), and the monthly pool arrives as
321/// `monthly_pct`/`monthly_reset_at`.
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct KimiSnapshot {
324 pub plan: Option<String>,
325 pub weekly_limit: u64,
326 pub weekly_used: u64,
327 pub weekly_remaining: u64,
328 pub weekly_reset_at: Option<DateTime<Utc>>,
329 /// `false` on the newer `usages`-map shape, which exposes no weekly
330 /// bucket; renderers must drop the weekly row rather than draw zeros.
331 pub has_weekly: bool,
332 /// Combined monthly pool usage (0..=100) on the newer shape. The
333 /// `limit_month_code` entry is the Code slice *inside* that pool, never
334 /// its own allowance, so it is not carried here.
335 pub monthly_pct: Option<i32>,
336 pub monthly_reset_at: Option<DateTime<Utc>>,
337 pub window_limit: u64,
338 pub window_used: u64,
339 pub window_remaining: u64,
340 pub window_reset_at: Option<DateTime<Utc>>,
341}
342
343impl KimiSnapshot {
344 fn pct(used: u64, limit: u64) -> i32 {
345 if limit == 0 {
346 0
347 } else {
348 // Keep all quota values exact: f64 loses integer precision above
349 // 2^53. This is the integer equivalent of round(used / limit *
350 // 100), with saturation for inconsistent upstream counters.
351 let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
352 pct.min(100) as i32
353 }
354 }
355
356 /// Percentage of the weekly subscription quota consumed (0..=100).
357 pub fn weekly_pct(&self) -> i32 {
358 Self::pct(self.weekly_used, self.weekly_limit)
359 }
360
361 /// Percentage of the rolling rate-limit window consumed (0..=100).
362 pub fn window_pct(&self) -> i32 {
363 Self::pct(self.window_used, self.window_limit)
364 }
365
366 /// Worst percentage across the windows this snapshot actually has: the
367 /// rolling window, the weekly quota when present, and the monthly pool
368 /// when present.
369 pub fn worst_pct(&self) -> i32 {
370 let mut worst = self.window_pct();
371 if self.has_weekly {
372 worst = worst.max(self.weekly_pct());
373 }
374 if let Some(monthly) = self.monthly_pct {
375 worst = worst.max(monthly);
376 }
377 worst
378 }
379}
380
381/// Discriminated union of vendor-specific snapshots. The widget and TUI match
382/// on this to pick a renderer.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub enum VendorSnapshot {
385 Anthropic(AnthropicSnapshot),
386 Openai(OpenAiSnapshot),
387 Copilot(crate::copilot::types::Snapshot),
388 Zai(ZaiSnapshot),
389 Openrouter(OpenRouterSnapshot),
390 Deepseek(DeepseekSnapshot),
391 Kimi(KimiSnapshot),
392 Kilo(KiloSnapshot),
393 Novita(NovitaSnapshot),
394 Moonshot(MoonshotSnapshot),
395 Grok(GrokSnapshot),
396 SuperGrok(SuperGrokSnapshot),
397 Grokbot(GrokbotSnapshot),
398 AnthropicApi(AnthropicApiSnapshot),
399 Antigravity(AntigravitySnapshot),
400 Cursor(CursorSnapshot),
401 Minimax(MinimaxSnapshot),
402 Kiro(KiroSnapshot),
403 NousResearch(crate::nous::types::AccountSnapshot),
404 OpenCodeGo(crate::opencode_go::types::Usage),
405 CommandCode(crate::commandcode::types::Snapshot),
406 Ollama(OllamaSnapshot),
407 /// A `[[custom]]` provider. Which one is not in the snapshot: the caller
408 /// that fetched it holds the `CustomProviderConfig`, and the cache
409 /// directory is keyed by its `id`.
410 Custom(crate::custom::types::CustomSnapshot),
411}
412
413/// Google Antigravity 2.0 / CLI snapshot. The API groups models into Gemini
414/// and third-party (Claude/GPT) buckets, and each group may carry a 5-hour and
415/// a weekly window — up to four, and not every product or plan offers all of
416/// them. Antigravity CLI 1.1.22 returns weekly buckets only, so every window is
417/// optional and a snapshot is valid when at least one arrived.
418#[derive(Debug, Clone, PartialEq)]
419pub struct AntigravitySnapshot {
420 pub plan: String,
421 /// Fingerprint of the signed-in account. Never displayed — it exists so a
422 /// cache written for one Google account is not served for another.
423 pub account: String,
424 /// Where the figures came from: a running local product, or the Cloud
425 /// Code API reached with the saved Google session when no local server
426 /// can answer — none is running, or `agy` withholds its CSRF token.
427 pub source: AntigravitySource,
428 /// Gemini group, 5-hour window.
429 pub session: Option<UsageWindow>,
430 /// Gemini group, weekly window.
431 pub weekly: Option<UsageWindow>,
432 /// Claude/GPT group, 5-hour window.
433 pub third_party_session: Option<UsageWindow>,
434 /// Claude/GPT group, weekly window.
435 pub third_party_weekly: Option<UsageWindow>,
436}
437
438impl Eq for AntigravitySnapshot {}
439
440/// Which path produced an [`AntigravitySnapshot`]. The local language server
441/// is the primary source; the remote API is the fallback for when no product
442/// is running, and the panel says so because the two can disagree briefly.
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
444pub enum AntigravitySource {
445 #[default]
446 Local,
447 Remote,
448}
449
450impl AntigravitySource {
451 pub fn as_str(self) -> &'static str {
452 match self {
453 AntigravitySource::Local => "local",
454 AntigravitySource::Remote => "remote",
455 }
456 }
457
458 /// Inverse of [`as_str`](Self::as_str); anything unrecognised is `None` so
459 /// a cache reader can fall back to the default rather than guess.
460 pub fn parse(s: &str) -> Option<Self> {
461 match s {
462 "local" => Some(AntigravitySource::Local),
463 "remote" => Some(AntigravitySource::Remote),
464 _ => None,
465 }
466 }
467}
468
469/// MiniMax Token Plan — `/v1/token_plan/remains` returns one row per model
470/// bucket (`general` for text/coding, `video`), and each row carries its own
471/// rolling interval window plus a weekly window.
472///
473/// Two things the payload dictates rather than convention: the interval length
474/// is **not fixed** (`general` rolls every 5h, `video` every 24h), so the
475/// duration is derived from the row's own start/end rather than assumed; and
476/// the API reports the percentage **remaining**, which is inverted on the way
477/// in so these windows carry consumed-% like every other vendor's.
478#[derive(Debug, Clone, PartialEq, Eq)]
479pub struct MinimaxSnapshot {
480 pub plan: String,
481 /// `general` bucket — rolling interval window (5h on the observed plans).
482 pub session: UsageWindow,
483 /// `general` bucket — weekly window.
484 pub weekly: UsageWindow,
485 /// `video` bucket, `None` on plans that carry no video quota.
486 pub video_session: Option<UsageWindow>,
487 pub video_weekly: Option<UsageWindow>,
488}
489
490/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
491/// monthly `limit` is supplied from config (the API exposes neither the limit
492/// nor the remaining prepaid credit balance).
493#[derive(Debug, Clone, PartialEq)]
494pub struct AnthropicApiSnapshot {
495 pub spent: f64,
496 pub limit: Option<f64>,
497}
498
499impl Eq for AnthropicApiSnapshot {}
500
501impl AnthropicApiSnapshot {
502 /// Spend as an integer percentage of the configured limit; `None` when no
503 /// positive limit is set.
504 pub fn pct(&self) -> Option<i32> {
505 self.limit
506 .filter(|l| l.is_finite() && *l > 0.0)
507 .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
508 }
509}
510
511/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
512/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
513#[derive(Debug, Clone, PartialEq)]
514pub struct KiloSnapshot {
515 pub label: String,
516 pub balance: f64,
517}
518
519impl Eq for KiloSnapshot {}
520
521/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
522/// all amounts already converted from the API's 1/10000-USD integers to USD.
523#[derive(Debug, Clone, PartialEq)]
524pub struct NovitaSnapshot {
525 /// Spendable credit balance (`availableBalance`).
526 pub available: f64,
527 /// Remaining top-up (`cashBalance`).
528 pub cash: f64,
529 /// Credit limit — max you can owe (`creditLimit`).
530 pub credit_limit: f64,
531 /// Amount currently owed (`outstandingInvoices`).
532 pub outstanding: f64,
533}
534
535impl Eq for NovitaSnapshot {}
536
537/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
538/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
539/// field in the response, so it's carried here from the region config.
540#[derive(Debug, Clone, PartialEq)]
541pub struct MoonshotSnapshot {
542 /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
543 /// the inference API.
544 pub available: f64,
545 /// Voucher credit (`voucher_balance`).
546 pub voucher: f64,
547 /// Cash balance (`cash_balance`); can be negative (debt).
548 pub cash: f64,
549 /// "USD" or "CNY", implied by the host.
550 pub currency: String,
551}
552
553impl Eq for MoonshotSnapshot {}
554
555/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
556/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
557#[derive(Debug, Clone, PartialEq)]
558pub struct GrokSnapshot {
559 pub balance: f64,
560}
561
562impl Eq for GrokSnapshot {}
563
564/// SuperGrok subscription usage from Grok Build's billing endpoint (ACP as
565/// fallback) plus banked remaining-resets. Distinct from [`GrokSnapshot`]
566/// (Management API prepaid balance).
567#[derive(Debug, Clone, PartialEq)]
568pub struct SuperGrokSnapshot {
569 /// Subscription tier label when the billing response supplies one
570 /// (e.g. "SuperGrok", "SuperGrok Heavy"); otherwise `"SuperGrok"`.
571 pub plan: String,
572 /// Opaque digest of Grok auth/config state. Never displayed — cache
573 /// isolation only.
574 pub account: String,
575 /// Current included-credit usage percent. The field name is retained as a
576 /// compatibility alias for format/render code; [`Self::period`] says
577 /// whether the server's actual window is weekly or monthly.
578 pub weekly_pct: i32,
579 pub period: SuperGrokPeriod,
580 /// When the current usage period ends.
581 pub reset_at: Option<DateTime<Utc>>,
582 /// Remaining prepaid (purchased) API credit in USD, when present.
583 pub prepaid_balance: Option<f64>,
584 pub reset_credits: ResetCredits,
585 /// Per-product slices of the same included-credit pool (`GrokBuild`,
586 /// `GrokChat`, `GrokImagine`, …). Empty when the billing document omits
587 /// `productUsage`.
588 pub products: Vec<SuperGrokProduct>,
589}
590
591/// One SuperGrok product's share of the current included-credit window.
592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
593pub struct SuperGrokProduct {
594 pub label: String,
595 pub percent: i32,
596}
597
598impl Eq for SuperGrokSnapshot {}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
601pub enum SuperGrokPeriod {
602 Weekly,
603 Monthly,
604 Unknown,
605}
606
607impl SuperGrokPeriod {
608 pub fn label(self) -> &'static str {
609 match self {
610 Self::Weekly => "Weekly",
611 Self::Monthly => "Monthly",
612 Self::Unknown => "Current period",
613 }
614 }
615
616 pub fn short(self) -> &'static str {
617 match self {
618 Self::Weekly => "wk",
619 Self::Monthly => "mo",
620 Self::Unknown => "period",
621 }
622 }
623}
624
625/// Grok Bot desktop app — the weekly included-usage pool from
626/// `aiserver.v1.DashboardService/GetSandUsageStatus` (Connect-RPC), read with
627/// the app's own OAuth session. Distinct from [`GrokSnapshot`] (Management
628/// API prepaid dollars) and [`SuperGrokSnapshot`] (Grok Build subscription).
629#[derive(Debug, Clone, PartialEq, Eq)]
630pub struct GrokbotSnapshot {
631 /// `grokPlanLabel`, falling back to `cursorPlanName`, then "Grok Bot".
632 pub plan: String,
633 /// `hasNonZeroIncludedLimit`. When false the account carries no included
634 /// allowance at all — a distinct "no included allowance" state, never a
635 /// fabricated 0% meter.
636 pub has_included_allowance: bool,
637 /// `usagePercent` of the included pool (0..=100). Meaningful only when
638 /// `has_included_allowance` is set.
639 pub weekly_pct: i32,
640 /// `hasAvailableUsage` — the account can still serve requests, which at
641 /// 100% of the included pool means on-demand is picking up the rest.
642 pub has_available_usage: bool,
643 /// `onDemandSettings.enabled` — pay-as-you-go past the included pool.
644 pub on_demand_enabled: bool,
645 /// `currentPeriodStart`.
646 pub period_start: Option<DateTime<Utc>>,
647 /// `nextResetTimestampUtc`.
648 pub reset_at: Option<DateTime<Utc>>,
649 /// `reset_at − period_start` when both are reported (7 days on the
650 /// captured account) — computed, never assumed.
651 pub window: Option<chrono::Duration>,
652}
653
654impl GrokbotSnapshot {
655 /// At 100% of the included pool, `hasAvailableUsage` can still be true
656 /// because on-demand keeps serving — say so, but only when the account
657 /// actually has on-demand switched on.
658 pub fn on_demand_note(&self) -> Option<&'static str> {
659 (self.has_included_allowance
660 && self.weekly_pct >= 100
661 && self.has_available_usage
662 && self.on_demand_enabled)
663 .then_some("included pool exhausted — on-demand may still be serving usage")
664 }
665}
666
667/// OpenAI Codex OAuth — exposes whichever rolling windows the API reports.
668#[derive(Debug, Clone, PartialEq, Eq)]
669pub struct OpenAiSnapshot {
670 pub plan: String,
671 /// 5h window, identified by its duration rather than its wire position.
672 pub session: Option<UsageWindow>,
673 /// 7d window, identified by its duration rather than its wire position.
674 pub weekly: Option<UsageWindow>,
675 /// Optional 7d code-review bucket.
676 pub code_review: Option<UsageWindow>,
677 /// Named limits beside the main one, each with its own windows. Empty for
678 /// an account that has none.
679 pub additional_limits: Vec<OpenAiNamedLimit>,
680 /// Models the account currently cannot dispatch to, with the time they
681 /// return when the API states one. Only unavailable models are kept: a
682 /// list of everything that *is* working is noise, and the reason this
683 /// exists is to explain a refusal no percentage accounts for.
684 pub unavailable_models: Vec<OpenAiUnavailableModel>,
685 /// Optional credit balance + approximate message-count ranges.
686 pub credits: Option<OpenAiCredits>,
687 pub reset_credits: ResetCredits,
688 /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
689 /// the placeholder set and the "OpenAI does not expose this for Plus"
690 /// tooltip when the OAuth path isn't available.
691 pub source: OpenAiSource,
692}
693
694/// A named limit that sits beside Codex's main window — a reserved pool or a
695/// model-specific allowance. It can be exhausted while the headline window is
696/// nearly untouched, which is the case it exists to make visible.
697#[derive(Debug, Clone, PartialEq, Eq)]
698pub struct OpenAiNamedLimit {
699 /// The API's own name for it, shown as given.
700 pub name: String,
701 pub session: Option<UsageWindow>,
702 pub weekly: Option<UsageWindow>,
703}
704
705/// A model the account cannot currently dispatch to.
706#[derive(Debug, Clone, PartialEq, Eq)]
707pub struct OpenAiUnavailableModel {
708 pub model: String,
709 /// When the API says it returns. `None` means it did not say.
710 pub available_at: Option<DateTime<Utc>>,
711}
712
713/// Banked, user-redeemable quota resets — Codex's "rate limit reset credits"
714/// and SuperGrok's "remaining resets" are the same idea under two names: a
715/// count you have earned, each with its own expiry, redeemed by hand rather
716/// than arriving on the window's own schedule. Distinct from a
717/// [`UsageWindow::resets_at`], which needs no action and cannot be banked.
718///
719/// The redemption identifier each provider returns alongside these
720/// (`credits[].id`, `tokens[].token_id`) is deliberately *not* carried here:
721/// it is the handle that spends the credit, and nothing that renders a status
722/// bar needs it.
723#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
724pub struct ResetCredits {
725 pub available: u32,
726 /// One row per credit the provider described. May be shorter than
727 /// `available` — Codex's usage endpoint gives the count without the
728 /// per-credit detail, and the detail call is allowed to fail on its own.
729 #[serde(default)]
730 pub credits: Vec<ResetCredit>,
731}
732
733#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
734pub struct ResetCredit {
735 /// Provider label when one exists ("Full reset (Weekly + 5 hr)"). SuperGrok
736 /// tokens have no title.
737 #[serde(default, skip_serializing_if = "Option::is_none")]
738 pub title: Option<String>,
739 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub expires_at: Option<DateTime<Utc>>,
741}
742
743impl ResetCredits {
744 pub fn is_empty(&self) -> bool {
745 self.available == 0
746 }
747
748 pub fn next_expiry(&self) -> Option<DateTime<Utc>> {
749 self.credits
750 .iter()
751 .filter_map(|credit| credit.expires_at)
752 .min()
753 }
754}
755
756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757pub enum OpenAiSource {
758 CodexOauth,
759 AdminKeyMtd,
760 Unavailable,
761}
762
763#[derive(Debug, Clone, PartialEq, Eq)]
764pub struct OpenAiCredits {
765 /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
766 /// a string because OpenAI returns it that way.
767 pub balance: String,
768 pub has_credits: bool,
769 pub unlimited: bool,
770 pub approx_local_messages: Option<(i64, i64)>,
771 pub approx_cloud_messages: Option<(i64, i64)>,
772}
773
774/// Z.AI / BigModel — list of buckets with discriminated types. We project the
775/// two we care about into named fields (5h tokens, weekly tokens, MCP).
776#[derive(Debug, Clone, PartialEq, Eq)]
777pub struct ZaiSnapshot {
778 pub plan: String,
779 pub session: Option<UsageWindow>,
780 pub weekly: Option<UsageWindow>,
781 pub mcp: Option<UsageWindow>,
782}
783
784/// Ollama Cloud — the session and weekly usage windows served by
785/// `ollama.com/api/usage`, plus a per-model breakdown. The response also
786/// carries an `activity.cost` string for the current period; we keep it raw
787/// (it is already dollar-formatted upstream) and let the renderer place it.
788#[derive(Debug, Clone, PartialEq, Eq)]
789pub struct OllamaSnapshot {
790 /// Display label, taken from the `[ollama] plan` config field. The API
791 /// itself does not report a plan name.
792 pub plan: String,
793 /// 5h rolling window (`limits.session`). `None` when the account has not
794 /// touched the cloud tier yet (the window is omitted from the response,
795 /// not reported as zero).
796 pub session: Option<UsageWindow>,
797 /// 7d rolling window (`limits.weekly`).
798 pub weekly: Option<UsageWindow>,
799 /// Calendar-month window (`limits.monthly`). Some Pro accounts report
800 /// this in place of `session`/`weekly` instead of alongside them.
801 pub monthly: Option<UsageWindow>,
802 /// Per-model request counts inside the session window, in the order the
803 /// API returned them. Renderers sort and truncate this for the tooltip.
804 pub session_models: Vec<OllamaModelUsage>,
805 /// Per-model request counts inside the weekly window.
806 pub weekly_models: Vec<OllamaModelUsage>,
807 /// Per-model request counts inside the monthly window.
808 pub monthly_models: Vec<OllamaModelUsage>,
809 /// `activity.cost` as a pre-formatted dollar string (`"0.00000"`,
810 /// `"1.23456"`). Already a string on the wire — the renderer decides
811 /// whether to keep it verbatim or reformat.
812 pub activity_cost: Option<String>,
813 /// `activity.period.type` (`"last_4_weeks"` and friends). A short
814 /// human-readable label the renderer can show next to the cost.
815 pub activity_period: Option<String>,
816}
817
818/// One row of `OllamaSnapshot::{session,weekly}_models`. The API carries the
819/// per-model request count; the percentage of the window that this single
820/// model represents is not reported, so the renderer derives it locally.
821#[derive(Debug, Clone, PartialEq, Eq)]
822pub struct OllamaModelUsage {
823 pub name: String,
824 pub request_count: u64,
825}
826
827/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
828/// `/api/v1/credits` and `/api/v1/key`.
829#[derive(Debug, Clone, PartialEq)]
830pub struct OpenRouterSnapshot {
831 pub label: String,
832 pub total_credits: f64,
833 pub total_usage: f64,
834 pub usage_daily: f64,
835 pub usage_weekly: f64,
836 pub usage_monthly: f64,
837 pub is_free_tier: bool,
838 pub limit: Option<f64>,
839 pub limit_remaining: Option<f64>,
840}
841
842impl Eq for OpenRouterSnapshot {}
843
844impl OpenRouterSnapshot {
845 /// Spendable credit, **which can be negative**: OpenRouter lets an account
846 /// run into debt, and clamping that to zero would report a healthy-looking
847 /// `$0.00` to someone who has to top up before anything works again. The
848 /// wire fields are each non-negative (see `openrouter::types`), so a
849 /// negative result only ever means usage has overrun credits.
850 pub fn balance(&self) -> f64 {
851 self.total_credits - self.total_usage
852 }
853 /// Percentage of total_credits consumed (0..=100). Returns 0 when
854 /// `total_credits` is 0 (free-tier-only accounts) — there is no
855 /// denominator to be a percentage of. Severity does not come from this
856 /// number alone: see [`crate::openrouter::vendor::severity`], which treats
857 /// a negative [`Self::balance`] as critical regardless of the percentage.
858 pub fn consumed_pct(&self) -> i32 {
859 if self.total_credits <= 0.0 {
860 return 0;
861 }
862 ((self.total_usage / self.total_credits) * 100.0)
863 .round()
864 .clamp(0.0, 100.0) as i32
865 }
866}
867
868/// Worst-of severity class for the Waybar bar text color. Mirrors
869/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
870pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
871 let mut max = snap.session.utilization_pct;
872 if snap.weekly.utilization_pct > max {
873 max = snap.weekly.utilization_pct;
874 }
875 if let Some(s) = &snap.sonnet
876 && s.utilization_pct > max
877 {
878 max = s.utilization_pct;
879 }
880 for sw in &snap.scoped {
881 if sw.window.utilization_pct > max {
882 max = sw.window.utilization_pct;
883 }
884 }
885 // Extra usage only promotes severity if a rate-limit window is at 100%.
886 let any_at_cap = snap.session.utilization_pct >= 100
887 || snap.weekly.utilization_pct >= 100
888 || snap
889 .sonnet
890 .as_ref()
891 .is_some_and(|s| s.utilization_pct >= 100)
892 || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
893 if any_at_cap && let Some(extra) = snap.extra.as_ref() {
894 let p = extra.percent();
895 if p > max {
896 max = p;
897 }
898 }
899 crate::pango::severity_for(max)
900}
901
902#[cfg(test)]
903mod tests {
904 use super::*;
905 use crate::pacing::PaceSeverity;
906 use chrono::Duration;
907
908 fn w(pct: i32) -> UsageWindow {
909 UsageWindow {
910 utilization_pct: pct,
911 resets_at: None,
912 window_duration: Duration::hours(5),
913 }
914 }
915
916 fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
917 AnthropicSnapshot {
918 plan: "Max 5x".into(),
919 session: w(s),
920 weekly: w(w_),
921 sonnet: sonnet.map(w),
922 scoped: vec![],
923 extra: extra.map(|(limit, spent)| ExtraUsage {
924 limit: Some(Cents(limit)),
925 spent: Cents(spent),
926 currency: None,
927 decimal_places: Some(2),
928 }),
929 }
930 }
931
932 #[test]
933 fn fmt_minor_honors_currency_and_scale() {
934 // No currency (older payloads) keeps the historical `$`.
935 assert_eq!(fmt_minor(250, 2, None), "$2.50");
936 // The #30 reporter's actual figures: BRL must not be claimed as `$`.
937 assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
938 assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
939 // Zero-exponent currency: no decimal point, no /100.
940 assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
941 // Sign precedes the symbol, matching `fmt_dollars`.
942 assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
943 // Unknown code stays truthful as a suffix rather than guessing a symbol.
944 assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
945 }
946
947 #[test]
948 fn extra_usage_formats_in_its_own_currency() {
949 let e = ExtraUsage {
950 limit: None,
951 spent: Cents(14157),
952 currency: Some("BRL".into()),
953 decimal_places: Some(2),
954 };
955 assert_eq!(e.fmt_spent(), "R$141.57");
956 assert_eq!(e.fmt_limit(), None);
957
958 let capped = ExtraUsage {
959 limit: Some(Cents(5000)),
960 spent: Cents(250),
961 currency: None,
962 decimal_places: Some(2),
963 };
964 assert_eq!(capped.fmt_spent(), "$2.50");
965 assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
966 }
967
968 #[test]
969 fn cents_format_positive() {
970 assert_eq!(Cents(0).fmt_dollars(), "$0.00");
971 assert_eq!(Cents(50).fmt_dollars(), "$0.50");
972 assert_eq!(Cents(250).fmt_dollars(), "$2.50");
973 assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
974 }
975
976 #[test]
977 fn cents_format_negative_uses_leading_sign() {
978 // claudebar bug-fix: never "$-1.-50" — sign goes before the dollar sign.
979 assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
980 assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
981 }
982
983 #[test]
984 fn extra_percent_with_zero_limit_is_zero() {
985 assert_eq!(
986 ExtraUsage {
987 limit: Some(Cents(0)),
988 spent: Cents(100),
989 currency: None,
990 decimal_places: Some(2),
991 }
992 .percent(),
993 0
994 );
995 }
996
997 #[test]
998 fn extra_percent_truncates() {
999 // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
1000 assert_eq!(
1001 ExtraUsage {
1002 limit: Some(Cents(10000)),
1003 spent: Cents(3333),
1004 currency: None,
1005 decimal_places: Some(2),
1006 }
1007 .percent(),
1008 33
1009 );
1010 }
1011
1012 #[test]
1013 fn severity_picks_worst_of_three_windows() {
1014 let s = snap(40, 60, Some(80), None);
1015 assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
1016 }
1017
1018 #[test]
1019 fn severity_ignores_extra_when_no_cap_hit() {
1020 // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
1021 let s = snap(50, 60, None, Some((10000, 9500)));
1022 assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
1023 }
1024
1025 #[test]
1026 fn severity_promotes_extra_when_session_at_100() {
1027 let s = snap(100, 50, None, Some((10000, 9500)));
1028 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
1029 }
1030
1031 #[test]
1032 fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
1033 // session = 100, weekly = 50, extra = 100% → max should be 100.
1034 let s = snap(100, 50, None, Some((10000, 10000)));
1035 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
1036 }
1037
1038 fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
1039 s.scoped.push(ScopedWindow {
1040 label: "Fable".into(),
1041 window: w(pct),
1042 });
1043 s
1044 }
1045
1046 #[test]
1047 fn severity_includes_scoped_windows() {
1048 // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
1049 // week at 84 → the bar class must escalate to High.
1050 let s = with_scoped(snap(10, 55, None, None), 84);
1051 assert_eq!(anthropic_severity(&s), PaceSeverity::High);
1052 }
1053
1054 #[test]
1055 fn severity_promotes_extra_when_scoped_at_100() {
1056 // A scoped window at cap counts as a rate-limit cap hit, so extra
1057 // usage above the window max is promoted — same rule as session/weekly.
1058 let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
1059 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
1060 }
1061
1062 #[test]
1063 fn kimi_percent_is_exact_above_f64_precision() {
1064 let snap = KimiSnapshot {
1065 plan: None,
1066 weekly_limit: (1 << 53) + 1,
1067 weekly_used: 1 << 52,
1068 weekly_remaining: 0,
1069 weekly_reset_at: None,
1070 has_weekly: true,
1071 monthly_pct: None,
1072 monthly_reset_at: None,
1073 window_limit: u64::MAX,
1074 window_used: u64::MAX - 1,
1075 window_remaining: 0,
1076 window_reset_at: None,
1077 };
1078 assert_eq!(snap.weekly_pct(), 50);
1079 assert_eq!(snap.window_pct(), 100);
1080 }
1081
1082 #[test]
1083 fn kiro_pct_is_zero_without_a_positive_limit() {
1084 let snap = KiroSnapshot {
1085 plan: "FREE".into(),
1086 used: 5.0,
1087 limit: 0.0,
1088 reset_at: None,
1089 };
1090 assert_eq!(snap.pct(), 0);
1091 }
1092
1093 #[test]
1094 fn kiro_pct_rounds_the_credit_ratio() {
1095 let snap = KiroSnapshot {
1096 plan: "KIRO POWER".into(),
1097 used: 1.0,
1098 limit: 3.0,
1099 reset_at: None,
1100 };
1101 assert_eq!(snap.pct(), 33);
1102 }
1103}