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};
16
17use crate::error::{AppError, Result};
18
19/// Reject a non-finite monetary value. A NaN or infinity reaching a balance
20/// field means the payload was not what we think it is; displaying it as money
21/// (or caching it as authoritative) is worse than failing loudly.
22pub fn finite_amount(vendor: &str, field: &str, v: f64) -> Result<f64> {
23 if v.is_finite() {
24 Ok(v)
25 } else {
26 Err(AppError::Schema(format!(
27 "{vendor}: `{field}` is not a finite number"
28 )))
29 }
30}
31
32/// Parse a monetary field that the wire encodes as a string. A malformed or
33/// empty value is a schema error, **not** a zero balance — silently reporting
34/// $0.00 for an error envelope is the failure mode this guards against.
35pub fn parse_amount(vendor: &str, field: &str, s: &str) -> Result<f64> {
36 let t = s.trim();
37 if t.is_empty() {
38 return Err(AppError::Schema(format!("{vendor}: `{field}` is empty")));
39 }
40 let v: f64 = t
41 .parse()
42 .map_err(|_| AppError::Schema(format!("{vendor}: `{field}` is not numeric (got {t:?})")))?;
43 finite_amount(vendor, field, v)
44}
45
46/// A single usage window — generic enough that every vendor with a notion of
47/// "% used vs. when does it reset" can express itself with it.
48///
49/// `utilization_pct` is `0..=100` (integer percent, matching claudebar's units).
50/// `resets_at` is `None` when the vendor doesn't report a reset time.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct UsageWindow {
53 pub utilization_pct: i32,
54 pub resets_at: Option<DateTime<Utc>>,
55 /// Window length (used for pacing math).
56 pub window_duration: chrono::Duration,
57}
58
59/// Money in minor currency units (historically always cents; see
60/// `ExtraUsage::decimal_places` for the actual scale) to dodge float roundoff.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct Cents(pub i64);
63
64impl Cents {
65 /// Format as `[-]$D.CC`. Negative values render `-$D.CC` (not `$-D.CC`),
66 /// matching claudebar's `_fmt_dollars` (claudebar:532-537).
67 pub fn fmt_dollars(self) -> String {
68 let (sign, abs) = if self.0 < 0 {
69 ("-", -self.0)
70 } else {
71 ("", self.0)
72 };
73 format!("{sign}${}.{:02}", abs / 100, abs % 100)
74 }
75}
76
77/// Anthropic-specific snapshot — three rolling windows plus optional
78/// pay-as-you-go credit balance.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct AnthropicSnapshot {
81 /// "Claude Pro", "Claude Max 5x", "Claude Max 20x", etc.
82 pub plan: String,
83 pub session: UsageWindow,
84 pub weekly: UsageWindow,
85 /// Some vendors of Claude (Pro, some Max tiers) don't have a separate
86 /// Sonnet bucket — in which case this is None.
87 pub sonnet: Option<UsageWindow>,
88 /// Model-scoped weekly windows from the newer `limits[]` array
89 /// (`kind == "weekly_scoped"`), e.g. the Fable weekly cap. Labels come
90 /// from the API (`scope.model.display_name`), so new models show up
91 /// without a code change. Empty when the account has none.
92 pub scoped: Vec<ScopedWindow>,
93 /// `None` when `extra_usage.is_enabled` is false or the block is absent.
94 pub extra: Option<ExtraUsage>,
95}
96
97/// A usage window scoped to a specific model, labeled by the API
98/// (e.g. "Fable"). Weekly (7d) duration.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ScopedWindow {
101 pub label: String,
102 pub window: UsageWindow,
103}
104
105/// "Extra usage" pay-as-you-go block (claudebar's `extra_usage`).
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ExtraUsage {
108 /// `None` when the payload carries no usable `monthly_limit` — an
109 /// explicit null (observed for plans without a spending cap, e.g. Claude
110 /// Pro, #30) or an absent field. Either way the spend is real and stays
111 /// visible; only the limit is unreported, and the renderers say exactly
112 /// that rather than inferring a plan tier from it.
113 pub limit: Option<Cents>,
114 pub spent: Cents,
115 /// ISO code from the block (`"BRL"`, `"USD"`, …). `None` on older payloads
116 /// that predate the field — formatted as `$` for back-compat, which was
117 /// the only behaviour before the field existed.
118 pub currency: Option<String>,
119 /// Minor-unit digits from the block's `decimal_places` (BRL/USD = 2,
120 /// JPY/KRW = 0). `None` means the wire did not report the scale. We keep
121 /// that absence instead of guessing from an incomplete currency table.
122 pub decimal_places: Option<u32>,
123}
124
125impl ExtraUsage {
126 /// Integer percentage of the monthly limit consumed (0..=100, saturating
127 /// at 0 when limit is non-positive — matches claudebar:540-542).
128 ///
129 /// With no cap there is no denominator, so no meaningful percentage
130 /// exists; 0 keeps the bar and severity calm rather than inventing one.
131 pub fn percent(&self) -> i32 {
132 match self.limit {
133 Some(l) if l.0 > 0 => ((self.spent.0 * 100) / l.0) as i32,
134 _ => 0,
135 }
136 }
137
138 pub fn fmt_spent(&self) -> String {
139 self.fmt_amount(self.spent)
140 }
141
142 pub fn fmt_limit(&self) -> Option<String> {
143 self.limit.map(|l| self.fmt_amount(l))
144 }
145
146 fn fmt_amount(&self, amount: Cents) -> String {
147 match (self.decimal_places, self.currency.as_deref()) {
148 (Some(decimal_places), currency) => fmt_minor(amount.0, decimal_places, currency),
149 // Legacy payloads predate both fields and were always cents/USD.
150 // Preserve that established behaviour only when neither field can
151 // tell us otherwise.
152 (None, None) => fmt_minor(amount.0, 2, None),
153 // A currency code alone does not determine its ISO minor-unit
154 // exponent. Keep the amount truthful instead of silently dividing
155 // zero-, three-, or four-decimal currencies by the wrong scale.
156 (None, Some(currency)) => fmt_minor_units(amount.0, currency),
157 }
158 }
159}
160
161fn fmt_minor_units(minor: i64, currency: &str) -> String {
162 let sign = if minor < 0 { "-" } else { "" };
163 format!("{sign}{} minor units {currency}", minor.unsigned_abs())
164}
165
166/// Format an amount in minor units with its own currency and scale.
167///
168/// The scale is this function's own — `money` cannot express a zero- or
169/// three-decimal currency — but the *symbol* comes from
170/// [`crate::format::with_currency`], so the two cannot disagree about what a
171/// given code looks like.
172pub fn fmt_minor(minor: i64, decimal_places: u32, currency: Option<&str>) -> String {
173 let scale = 10_u64.pow(decimal_places);
174 // `unsigned_abs`, not negation: `-i64::MIN` overflows. Unreachable from
175 // the wire (the parse gate rejects negatives) but this is a pub fn.
176 let sign = if minor < 0 { "-" } else { "" };
177 let abs = minor.unsigned_abs();
178 let number = if decimal_places == 0 {
179 format!("{abs}")
180 } else {
181 format!(
182 "{}.{:0width$}",
183 abs / scale,
184 abs % scale,
185 width = decimal_places as usize
186 )
187 };
188 crate::format::with_currency(sign, &number, currency)
189}
190
191/// DeepSeek — credit balance from `/user/balance`.
192#[derive(Debug, Clone, PartialEq)]
193pub struct DeepseekSnapshot {
194 pub is_available: bool,
195 /// Current balance (prefer USD, fallback to CNY).
196 pub balance: f64,
197 /// Free-granted credits component.
198 pub granted: f64,
199 /// Topped-up (purchased) credits component.
200 pub topped_up: f64,
201 /// The currency of the above amounts (currently "USD" or "CNY").
202 pub currency: String,
203}
204
205impl Eq for DeepseekSnapshot {}
206
207impl Default for DeepseekSnapshot {
208 fn default() -> Self {
209 Self {
210 is_available: false,
211 balance: 0.0,
212 granted: 0.0,
213 topped_up: 0.0,
214 currency: String::new(),
215 }
216 }
217}
218
219/// Cursor — the two included-usage pools the dashboard shows, from the
220/// undocumented `cursor.com/api/usage-summary` endpoint (the same one the
221/// dashboard's own frontend calls), authenticated with the session token the
222/// Cursor IDE wrote to its local `state.vscdb`.
223///
224/// Since Cursor's mid-2026 pricing, a plan's included compute is split into two
225/// quota pools, each shown as a percentage: **Cursor Models** (Auto + Composer,
226/// `autoPercentUsed`) and **Other Models** (named / third-party, `apiPercentUsed`).
227/// Overflow past either pool falls to on-demand spend. Percentages are integers
228/// (rounded from the wire floats) to match the dashboard and every other
229/// vendor's integer-percent convention; they can exceed 100 when a pool is over
230/// its included allowance.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct CursorSnapshot {
233 /// Membership label, title-cased from `membershipType` (e.g. "Ultra").
234 pub plan: String,
235 /// "Cursor Models" pool — Auto + Composer (`autoPercentUsed`, rounded).
236 pub auto_pct: i32,
237 /// "Other Models" pool — named / third-party (`apiPercentUsed`, rounded).
238 pub api_pct: i32,
239 /// Overall included usage (`totalPercentUsed`, rounded) — the dashboard's
240 /// "you've used N% of your included total usage" headline.
241 pub total_pct: i32,
242 /// `true` when the plan reports `isUnlimited` — the pools don't cap and the
243 /// percentages are not meaningful.
244 pub unlimited: bool,
245 /// Whether on-demand (overage) spend is turned on (`onDemand.enabled`).
246 pub on_demand_enabled: bool,
247 /// End of the current billing cycle (`billingCycleEnd`) — when the pools
248 /// reset.
249 pub reset_at: Option<DateTime<Utc>>,
250}
251
252impl CursorSnapshot {
253 /// The binding pool — whichever is closest to (or furthest past) its cap.
254 /// Drives the bar color and the single generic `session_pct` alias.
255 pub fn worst_pct(&self) -> i32 {
256 self.auto_pct.max(self.api_pct)
257 }
258}
259
260/// Kiro CLI (AWS CodeWhisperer / Q Developer backend) — a single credit pool
261/// from `AmazonCodeWhispererService.GetUsageLimits`, the same call kiro-cli's
262/// own `/usage` slash command makes. Authenticated with the AWS SSO OIDC
263/// bearer token kiro-cli already cached locally, refreshed with the paired
264/// refresh token when it's close to expiry — see `kiro::db` and `kiro::oauth`.
265#[derive(Debug, Clone, PartialEq)]
266pub struct KiroSnapshot {
267 /// Subscription tier label (`subscriptionInfo.subscriptionTitle`, e.g.
268 /// "KIRO POWER").
269 pub plan: String,
270 /// Credits consumed this cycle (`currentUsageWithPrecision`).
271 pub used: f64,
272 /// Credits included in the plan (`usageLimitWithPrecision`).
273 pub limit: f64,
274 /// When the credit pool resets (`nextDateReset`).
275 pub reset_at: Option<DateTime<Utc>>,
276}
277
278impl Eq for KiroSnapshot {}
279
280impl KiroSnapshot {
281 /// Percentage of the credit pool consumed, rounded. `0` when `limit` is
282 /// not positive — defensive; the API has not been observed to send that.
283 pub fn pct(&self) -> i32 {
284 if self.limit <= 0.0 {
285 return 0;
286 }
287 ((self.used / self.limit) * 100.0)
288 .round()
289 .clamp(0.0, 9999.0) as i32
290 }
291}
292
293/// Kimi Code — weekly subscription quota plus a 5h rolling rate-limit window.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct KimiSnapshot {
296 pub plan: Option<String>,
297 pub weekly_limit: u64,
298 pub weekly_used: u64,
299 pub weekly_remaining: u64,
300 pub weekly_reset_at: Option<DateTime<Utc>>,
301 pub window_limit: u64,
302 pub window_used: u64,
303 pub window_remaining: u64,
304 pub window_reset_at: Option<DateTime<Utc>>,
305}
306
307impl KimiSnapshot {
308 fn pct(used: u64, limit: u64) -> i32 {
309 if limit == 0 {
310 0
311 } else {
312 // Keep all quota values exact: f64 loses integer precision above
313 // 2^53. This is the integer equivalent of round(used / limit *
314 // 100), with saturation for inconsistent upstream counters.
315 let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
316 pct.min(100) as i32
317 }
318 }
319
320 /// Percentage of the weekly subscription quota consumed (0..=100).
321 pub fn weekly_pct(&self) -> i32 {
322 Self::pct(self.weekly_used, self.weekly_limit)
323 }
324
325 /// Percentage of the rolling rate-limit window consumed (0..=100).
326 pub fn window_pct(&self) -> i32 {
327 Self::pct(self.window_used, self.window_limit)
328 }
329}
330
331/// Discriminated union of vendor-specific snapshots. The widget and TUI match
332/// on this to pick a renderer.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub enum VendorSnapshot {
335 Anthropic(AnthropicSnapshot),
336 Openai(OpenAiSnapshot),
337 Zai(ZaiSnapshot),
338 Openrouter(OpenRouterSnapshot),
339 Deepseek(DeepseekSnapshot),
340 Kimi(KimiSnapshot),
341 Kilo(KiloSnapshot),
342 Novita(NovitaSnapshot),
343 Moonshot(MoonshotSnapshot),
344 Grok(GrokSnapshot),
345 SuperGrok(SuperGrokSnapshot),
346 AnthropicApi(AnthropicApiSnapshot),
347 Antigravity(AntigravitySnapshot),
348 Cursor(CursorSnapshot),
349 Minimax(MinimaxSnapshot),
350 Kiro(KiroSnapshot),
351 NousResearch(crate::nous::types::AccountSnapshot),
352 OpenCodeGo(crate::opencode_go::types::Usage),
353 CommandCode(crate::commandcode::types::Snapshot),
354}
355
356/// Google Antigravity 2.0 / CLI snapshot. The API groups models into Gemini
357/// and third-party (Claude/GPT) buckets, and each group carries its own 5-hour
358/// and weekly window — four independent windows in total.
359#[derive(Debug, Clone, PartialEq)]
360pub struct AntigravitySnapshot {
361 pub plan: String,
362 /// Fingerprint of the signed-in account. Never displayed — it exists so a
363 /// cache written for one Google account is not served for another.
364 pub account: String,
365 /// Gemini group, 5-hour window.
366 pub session: UsageWindow,
367 /// Gemini group, weekly window.
368 pub weekly: UsageWindow,
369 /// Claude/GPT group, 5-hour window.
370 pub third_party_session: Option<UsageWindow>,
371 /// Claude/GPT group, weekly window.
372 pub third_party_weekly: Option<UsageWindow>,
373}
374
375impl Eq for AntigravitySnapshot {}
376
377/// MiniMax Token Plan — `/v1/token_plan/remains` returns one row per model
378/// bucket (`general` for text/coding, `video`), and each row carries its own
379/// rolling interval window plus a weekly window.
380///
381/// Two things the payload dictates rather than convention: the interval length
382/// is **not fixed** (`general` rolls every 5h, `video` every 24h), so the
383/// duration is derived from the row's own start/end rather than assumed; and
384/// the API reports the percentage **remaining**, which is inverted on the way
385/// in so these windows carry consumed-% like every other vendor's.
386#[derive(Debug, Clone, PartialEq, Eq)]
387pub struct MinimaxSnapshot {
388 pub plan: String,
389 /// `general` bucket — rolling interval window (5h on the observed plans).
390 pub session: UsageWindow,
391 /// `general` bucket — weekly window.
392 pub weekly: UsageWindow,
393 /// `video` bucket, `None` on plans that carry no video quota.
394 pub video_session: Option<UsageWindow>,
395 pub video_weekly: Option<UsageWindow>,
396}
397
398/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
399/// monthly `limit` is supplied from config (the API exposes neither the limit
400/// nor the remaining prepaid credit balance).
401#[derive(Debug, Clone, PartialEq)]
402pub struct AnthropicApiSnapshot {
403 pub spent: f64,
404 pub limit: Option<f64>,
405}
406
407impl Eq for AnthropicApiSnapshot {}
408
409impl AnthropicApiSnapshot {
410 /// Spend as an integer percentage of the configured limit; `None` when no
411 /// positive limit is set.
412 pub fn pct(&self) -> Option<i32> {
413 self.limit
414 .filter(|l| l.is_finite() && *l > 0.0)
415 .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
416 }
417}
418
419/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
420/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
421#[derive(Debug, Clone, PartialEq)]
422pub struct KiloSnapshot {
423 pub label: String,
424 pub balance: f64,
425}
426
427impl Eq for KiloSnapshot {}
428
429/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
430/// all amounts already converted from the API's 1/10000-USD integers to USD.
431#[derive(Debug, Clone, PartialEq)]
432pub struct NovitaSnapshot {
433 /// Spendable credit balance (`availableBalance`).
434 pub available: f64,
435 /// Remaining top-up (`cashBalance`).
436 pub cash: f64,
437 /// Credit limit — max you can owe (`creditLimit`).
438 pub credit_limit: f64,
439 /// Amount currently owed (`outstandingInvoices`).
440 pub outstanding: f64,
441}
442
443impl Eq for NovitaSnapshot {}
444
445/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
446/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
447/// field in the response, so it's carried here from the region config.
448#[derive(Debug, Clone, PartialEq)]
449pub struct MoonshotSnapshot {
450 /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
451 /// the inference API.
452 pub available: f64,
453 /// Voucher credit (`voucher_balance`).
454 pub voucher: f64,
455 /// Cash balance (`cash_balance`); can be negative (debt).
456 pub cash: f64,
457 /// "USD" or "CNY", implied by the host.
458 pub currency: String,
459}
460
461impl Eq for MoonshotSnapshot {}
462
463/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
464/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
465#[derive(Debug, Clone, PartialEq)]
466pub struct GrokSnapshot {
467 pub balance: f64,
468}
469
470impl Eq for GrokSnapshot {}
471
472/// SuperGrok subscription usage returned by the official Grok Build CLI's
473/// credential-owning `x.ai/billing` ACP extension. Distinct from
474/// [`GrokSnapshot`] (Management API prepaid balance).
475#[derive(Debug, Clone, PartialEq)]
476pub struct SuperGrokSnapshot {
477 /// Subscription tier label when the billing response supplies one
478 /// (e.g. "SuperGrok", "SuperGrok Heavy"); otherwise `"SuperGrok"`.
479 pub plan: String,
480 /// Opaque digest of Grok auth/config state. Never displayed — cache
481 /// isolation only.
482 pub account: String,
483 /// Current included-credit usage percent. The field name is retained as a
484 /// compatibility alias for format/render code; [`Self::period`] says
485 /// whether the server's actual window is weekly or monthly.
486 pub weekly_pct: i32,
487 pub period: SuperGrokPeriod,
488 /// When the current usage period ends.
489 pub reset_at: Option<DateTime<Utc>>,
490 /// Remaining prepaid (purchased) API credit in USD, when present.
491 pub prepaid_balance: Option<f64>,
492}
493
494impl Eq for SuperGrokSnapshot {}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq)]
497pub enum SuperGrokPeriod {
498 Weekly,
499 Monthly,
500 Unknown,
501}
502
503impl SuperGrokPeriod {
504 pub fn label(self) -> &'static str {
505 match self {
506 Self::Weekly => "Weekly",
507 Self::Monthly => "Monthly",
508 Self::Unknown => "Current period",
509 }
510 }
511
512 pub fn short(self) -> &'static str {
513 match self {
514 Self::Weekly => "wk",
515 Self::Monthly => "mo",
516 Self::Unknown => "period",
517 }
518 }
519}
520
521/// OpenAI Codex OAuth — exposes whichever rolling windows the API reports.
522#[derive(Debug, Clone, PartialEq, Eq)]
523pub struct OpenAiSnapshot {
524 pub plan: String,
525 /// 5h window, identified by its duration rather than its wire position.
526 pub session: Option<UsageWindow>,
527 /// 7d window, identified by its duration rather than its wire position.
528 pub weekly: Option<UsageWindow>,
529 /// Optional 7d code-review bucket.
530 pub code_review: Option<UsageWindow>,
531 /// Optional credit balance + approximate message-count ranges.
532 pub credits: Option<OpenAiCredits>,
533 /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
534 /// the placeholder set and the "OpenAI does not expose this for Plus"
535 /// tooltip when the OAuth path isn't available.
536 pub source: OpenAiSource,
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
540pub enum OpenAiSource {
541 CodexOauth,
542 AdminKeyMtd,
543 Unavailable,
544}
545
546#[derive(Debug, Clone, PartialEq, Eq)]
547pub struct OpenAiCredits {
548 /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
549 /// a string because OpenAI returns it that way.
550 pub balance: String,
551 pub has_credits: bool,
552 pub unlimited: bool,
553 pub approx_local_messages: Option<(i64, i64)>,
554 pub approx_cloud_messages: Option<(i64, i64)>,
555}
556
557/// Z.AI / BigModel — list of buckets with discriminated types. We project the
558/// two we care about into named fields (5h tokens, weekly tokens, MCP).
559#[derive(Debug, Clone, PartialEq, Eq)]
560pub struct ZaiSnapshot {
561 pub plan: String,
562 pub session: Option<UsageWindow>,
563 pub weekly: Option<UsageWindow>,
564 pub mcp: Option<UsageWindow>,
565}
566
567/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
568/// `/api/v1/credits` and `/api/v1/key`.
569#[derive(Debug, Clone, PartialEq)]
570pub struct OpenRouterSnapshot {
571 pub label: String,
572 pub total_credits: f64,
573 pub total_usage: f64,
574 pub usage_daily: f64,
575 pub usage_weekly: f64,
576 pub usage_monthly: f64,
577 pub is_free_tier: bool,
578 pub limit: Option<f64>,
579 pub limit_remaining: Option<f64>,
580}
581
582impl Eq for OpenRouterSnapshot {}
583
584impl OpenRouterSnapshot {
585 /// Spendable credit, **which can be negative**: OpenRouter lets an account
586 /// run into debt, and clamping that to zero would report a healthy-looking
587 /// `$0.00` to someone who has to top up before anything works again. The
588 /// wire fields are each non-negative (see `openrouter::types`), so a
589 /// negative result only ever means usage has overrun credits.
590 pub fn balance(&self) -> f64 {
591 self.total_credits - self.total_usage
592 }
593 /// Percentage of total_credits consumed (0..=100). Returns 0 when
594 /// `total_credits` is 0 (free-tier-only accounts) — there is no
595 /// denominator to be a percentage of. Severity does not come from this
596 /// number alone: see [`crate::openrouter::vendor::severity`], which treats
597 /// a negative [`Self::balance`] as critical regardless of the percentage.
598 pub fn consumed_pct(&self) -> i32 {
599 if self.total_credits <= 0.0 {
600 return 0;
601 }
602 ((self.total_usage / self.total_credits) * 100.0)
603 .round()
604 .clamp(0.0, 100.0) as i32
605 }
606}
607
608/// Worst-of severity class for the Waybar bar text color. Mirrors
609/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
610pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
611 let mut max = snap.session.utilization_pct;
612 if snap.weekly.utilization_pct > max {
613 max = snap.weekly.utilization_pct;
614 }
615 if let Some(s) = &snap.sonnet
616 && s.utilization_pct > max
617 {
618 max = s.utilization_pct;
619 }
620 for sw in &snap.scoped {
621 if sw.window.utilization_pct > max {
622 max = sw.window.utilization_pct;
623 }
624 }
625 // Extra usage only promotes severity if a rate-limit window is at 100%.
626 let any_at_cap = snap.session.utilization_pct >= 100
627 || snap.weekly.utilization_pct >= 100
628 || snap
629 .sonnet
630 .as_ref()
631 .is_some_and(|s| s.utilization_pct >= 100)
632 || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
633 if any_at_cap && let Some(extra) = snap.extra.as_ref() {
634 let p = extra.percent();
635 if p > max {
636 max = p;
637 }
638 }
639 crate::pango::severity_for(max)
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use crate::pacing::PaceSeverity;
646 use chrono::Duration;
647
648 fn w(pct: i32) -> UsageWindow {
649 UsageWindow {
650 utilization_pct: pct,
651 resets_at: None,
652 window_duration: Duration::hours(5),
653 }
654 }
655
656 fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
657 AnthropicSnapshot {
658 plan: "Max 5x".into(),
659 session: w(s),
660 weekly: w(w_),
661 sonnet: sonnet.map(w),
662 scoped: vec![],
663 extra: extra.map(|(limit, spent)| ExtraUsage {
664 limit: Some(Cents(limit)),
665 spent: Cents(spent),
666 currency: None,
667 decimal_places: Some(2),
668 }),
669 }
670 }
671
672 #[test]
673 fn fmt_minor_honors_currency_and_scale() {
674 // No currency (older payloads) keeps the historical `$`.
675 assert_eq!(fmt_minor(250, 2, None), "$2.50");
676 // The #30 reporter's actual figures: BRL must not be claimed as `$`.
677 assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
678 assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
679 // Zero-exponent currency: no decimal point, no /100.
680 assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
681 // Sign precedes the symbol, matching `fmt_dollars`.
682 assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
683 // Unknown code stays truthful as a suffix rather than guessing a symbol.
684 assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
685 }
686
687 #[test]
688 fn extra_usage_formats_in_its_own_currency() {
689 let e = ExtraUsage {
690 limit: None,
691 spent: Cents(14157),
692 currency: Some("BRL".into()),
693 decimal_places: Some(2),
694 };
695 assert_eq!(e.fmt_spent(), "R$141.57");
696 assert_eq!(e.fmt_limit(), None);
697
698 let capped = ExtraUsage {
699 limit: Some(Cents(5000)),
700 spent: Cents(250),
701 currency: None,
702 decimal_places: Some(2),
703 };
704 assert_eq!(capped.fmt_spent(), "$2.50");
705 assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
706 }
707
708 #[test]
709 fn cents_format_positive() {
710 assert_eq!(Cents(0).fmt_dollars(), "$0.00");
711 assert_eq!(Cents(50).fmt_dollars(), "$0.50");
712 assert_eq!(Cents(250).fmt_dollars(), "$2.50");
713 assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
714 }
715
716 #[test]
717 fn cents_format_negative_uses_leading_sign() {
718 // claudebar bug-fix: never "$-1.-50" — sign goes before the dollar sign.
719 assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
720 assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
721 }
722
723 #[test]
724 fn extra_percent_with_zero_limit_is_zero() {
725 assert_eq!(
726 ExtraUsage {
727 limit: Some(Cents(0)),
728 spent: Cents(100),
729 currency: None,
730 decimal_places: Some(2),
731 }
732 .percent(),
733 0
734 );
735 }
736
737 #[test]
738 fn extra_percent_truncates() {
739 // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
740 assert_eq!(
741 ExtraUsage {
742 limit: Some(Cents(10000)),
743 spent: Cents(3333),
744 currency: None,
745 decimal_places: Some(2),
746 }
747 .percent(),
748 33
749 );
750 }
751
752 #[test]
753 fn severity_picks_worst_of_three_windows() {
754 let s = snap(40, 60, Some(80), None);
755 assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
756 }
757
758 #[test]
759 fn severity_ignores_extra_when_no_cap_hit() {
760 // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
761 let s = snap(50, 60, None, Some((10000, 9500)));
762 assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
763 }
764
765 #[test]
766 fn severity_promotes_extra_when_session_at_100() {
767 let s = snap(100, 50, None, Some((10000, 9500)));
768 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
769 }
770
771 #[test]
772 fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
773 // session = 100, weekly = 50, extra = 100% → max should be 100.
774 let s = snap(100, 50, None, Some((10000, 10000)));
775 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
776 }
777
778 fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
779 s.scoped.push(ScopedWindow {
780 label: "Fable".into(),
781 window: w(pct),
782 });
783 s
784 }
785
786 #[test]
787 fn severity_includes_scoped_windows() {
788 // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
789 // week at 84 → the bar class must escalate to High.
790 let s = with_scoped(snap(10, 55, None, None), 84);
791 assert_eq!(anthropic_severity(&s), PaceSeverity::High);
792 }
793
794 #[test]
795 fn severity_promotes_extra_when_scoped_at_100() {
796 // A scoped window at cap counts as a rate-limit cap hit, so extra
797 // usage above the window max is promoted — same rule as session/weekly.
798 let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
799 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
800 }
801
802 #[test]
803 fn kimi_percent_is_exact_above_f64_precision() {
804 let snap = KimiSnapshot {
805 plan: None,
806 weekly_limit: (1 << 53) + 1,
807 weekly_used: 1 << 52,
808 weekly_remaining: 0,
809 weekly_reset_at: None,
810 window_limit: u64::MAX,
811 window_used: u64::MAX - 1,
812 window_remaining: 0,
813 window_reset_at: None,
814 };
815 assert_eq!(snap.weekly_pct(), 50);
816 assert_eq!(snap.window_pct(), 100);
817 }
818
819 #[test]
820 fn kiro_pct_is_zero_without_a_positive_limit() {
821 let snap = KiroSnapshot {
822 plan: "FREE".into(),
823 used: 5.0,
824 limit: 0.0,
825 reset_at: None,
826 };
827 assert_eq!(snap.pct(), 0);
828 }
829
830 #[test]
831 fn kiro_pct_rounds_the_credit_ratio() {
832 let snap = KiroSnapshot {
833 plan: "KIRO POWER".into(),
834 used: 1.0,
835 limit: 3.0,
836 reset_at: None,
837 };
838 assert_eq!(snap.pct(), 33);
839 }
840}