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