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