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