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