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
65impl Cents {
66 /// Format as `[-]$D.CC`. Negative values render `-$D.CC` (not `$-D.CC`),
67 /// matching claudebar's `_fmt_dollars` (claudebar:532-537).
68 pub fn fmt_dollars(self) -> String {
69 let (sign, abs) = if self.0 < 0 {
70 ("-", -self.0)
71 } else {
72 ("", self.0)
73 };
74 format!("{sign}${}.{:02}", abs / 100, abs % 100)
75 }
76}
77
78/// Anthropic-specific snapshot — three rolling windows plus optional
79/// pay-as-you-go credit balance.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct AnthropicSnapshot {
82 /// "Claude Pro", "Claude Max 5x", "Claude Max 20x", etc.
83 pub plan: String,
84 pub session: UsageWindow,
85 pub weekly: UsageWindow,
86 /// Some vendors of Claude (Pro, some Max tiers) don't have a separate
87 /// Sonnet bucket — in which case this is None.
88 pub sonnet: Option<UsageWindow>,
89 /// Model-scoped weekly windows from the newer `limits[]` array
90 /// (`kind == "weekly_scoped"`), e.g. the Fable weekly cap. Labels come
91 /// from the API (`scope.model.display_name`), so new models show up
92 /// without a code change. Empty when the account has none.
93 pub scoped: Vec<ScopedWindow>,
94 /// `None` when `extra_usage.is_enabled` is false or the block is absent.
95 pub extra: Option<ExtraUsage>,
96}
97
98/// A usage window scoped to a specific model, labeled by the API
99/// (e.g. "Fable"). Weekly (7d) duration.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ScopedWindow {
102 pub label: String,
103 pub window: UsageWindow,
104}
105
106/// "Extra usage" pay-as-you-go block (claudebar's `extra_usage`).
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct ExtraUsage {
109 /// `None` when the payload carries no usable `monthly_limit` — an
110 /// explicit null (observed for plans without a spending cap, e.g. Claude
111 /// Pro, #30) or an absent field. Either way the spend is real and stays
112 /// visible; only the limit is unreported, and the renderers say exactly
113 /// that rather than inferring a plan tier from it.
114 pub limit: Option<Cents>,
115 pub spent: Cents,
116 /// ISO code from the block (`"BRL"`, `"USD"`, …). `None` on older payloads
117 /// that predate the field — formatted as `$` for back-compat, which was
118 /// the only behaviour before the field existed.
119 pub currency: Option<String>,
120 /// Minor-unit digits from the block's `decimal_places` (BRL/USD = 2,
121 /// JPY/KRW = 0). `None` means the wire did not report the scale. We keep
122 /// that absence instead of guessing from an incomplete currency table.
123 pub decimal_places: Option<u32>,
124}
125
126impl ExtraUsage {
127 /// Integer percentage of the monthly limit consumed (0..=100, saturating
128 /// at 0 when limit is non-positive — matches claudebar:540-542).
129 ///
130 /// With no cap there is no denominator, so no meaningful percentage
131 /// exists; 0 keeps the bar and severity calm rather than inventing one.
132 pub fn percent(&self) -> i32 {
133 match self.limit {
134 Some(l) if l.0 > 0 => ((self.spent.0 * 100) / l.0) as i32,
135 _ => 0,
136 }
137 }
138
139 pub fn fmt_spent(&self) -> String {
140 self.fmt_amount(self.spent)
141 }
142
143 pub fn fmt_limit(&self) -> Option<String> {
144 self.limit.map(|l| self.fmt_amount(l))
145 }
146
147 fn fmt_amount(&self, amount: Cents) -> String {
148 match (self.decimal_places, self.currency.as_deref()) {
149 (Some(decimal_places), currency) => fmt_minor(amount.0, decimal_places, currency),
150 // Legacy payloads predate both fields and were always cents/USD.
151 // Preserve that established behaviour only when neither field can
152 // tell us otherwise.
153 (None, None) => fmt_minor(amount.0, 2, None),
154 // A currency code alone does not determine its ISO minor-unit
155 // exponent. Keep the amount truthful instead of silently dividing
156 // zero-, three-, or four-decimal currencies by the wrong scale.
157 (None, Some(currency)) => fmt_minor_units(amount.0, currency),
158 }
159 }
160}
161
162fn fmt_minor_units(minor: i64, currency: &str) -> String {
163 let sign = if minor < 0 { "-" } else { "" };
164 format!("{sign}{} minor units {currency}", minor.unsigned_abs())
165}
166
167/// Format an amount in minor units with its own currency and scale.
168///
169/// The scale is this function's own — `money` cannot express a zero- or
170/// three-decimal currency — but the *symbol* comes from
171/// [`crate::format::with_currency`], so the two cannot disagree about what a
172/// given code looks like.
173pub fn fmt_minor(minor: i64, decimal_places: u32, currency: Option<&str>) -> String {
174 let scale = 10_u64.pow(decimal_places);
175 // `unsigned_abs`, not negation: `-i64::MIN` overflows. Unreachable from
176 // the wire (the parse gate rejects negatives) but this is a pub fn.
177 let sign = if minor < 0 { "-" } else { "" };
178 let abs = minor.unsigned_abs();
179 let number = if decimal_places == 0 {
180 format!("{abs}")
181 } else {
182 format!(
183 "{}.{:0width$}",
184 abs / scale,
185 abs % scale,
186 width = decimal_places as usize
187 )
188 };
189 crate::format::with_currency(sign, &number, currency)
190}
191
192/// DeepSeek — credit balance from `/user/balance`.
193#[derive(Debug, Clone, PartialEq)]
194pub struct DeepseekSnapshot {
195 pub is_available: bool,
196 /// Current balance (prefer USD, fallback to CNY).
197 pub balance: f64,
198 /// Free-granted credits component.
199 pub granted: f64,
200 /// Topped-up (purchased) credits component.
201 pub topped_up: f64,
202 /// The currency of the above amounts (currently "USD" or "CNY").
203 pub currency: String,
204}
205
206impl Eq for DeepseekSnapshot {}
207
208impl Default for DeepseekSnapshot {
209 fn default() -> Self {
210 Self {
211 is_available: false,
212 balance: 0.0,
213 granted: 0.0,
214 topped_up: 0.0,
215 currency: String::new(),
216 }
217 }
218}
219
220/// Cursor — the two included-usage pools the dashboard shows, from the
221/// undocumented `cursor.com/api/usage-summary` endpoint (the same one the
222/// dashboard's own frontend calls), authenticated with the session token the
223/// Cursor IDE wrote to its local `state.vscdb`.
224///
225/// Since Cursor's mid-2026 pricing, a plan's included compute is split into two
226/// quota pools, each shown as a percentage: **Cursor Models** (Auto + Composer,
227/// `autoPercentUsed`) and **Other Models** (named / third-party, `apiPercentUsed`).
228/// Overflow past either pool falls to on-demand spend. Percentages are integers
229/// (rounded from the wire floats) to match the dashboard and every other
230/// vendor's integer-percent convention; they can exceed 100 when a pool is over
231/// its included allowance.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct CursorSnapshot {
234 /// Membership label, title-cased from `membershipType` (e.g. "Ultra").
235 pub plan: String,
236 /// "Cursor Models" pool — Auto + Composer (`autoPercentUsed`, rounded).
237 pub auto_pct: i32,
238 /// "Other Models" pool — named / third-party (`apiPercentUsed`, rounded).
239 pub api_pct: i32,
240 /// Overall included usage (`totalPercentUsed`, rounded) — the dashboard's
241 /// "you've used N% of your included total usage" headline.
242 pub total_pct: i32,
243 /// `true` when the plan reports `isUnlimited` — the pools don't cap and the
244 /// percentages are not meaningful.
245 pub unlimited: bool,
246 /// Whether on-demand (overage) spend is turned on (`onDemand.enabled`).
247 pub on_demand_enabled: bool,
248 /// On-demand spend in cents (`onDemand.used`), when Cursor reports it.
249 pub on_demand_used_cents: Option<i64>,
250 /// Configured on-demand spending limit in cents (`onDemand.limit`).
251 pub on_demand_limit_cents: Option<i64>,
252 /// End of the current billing cycle (`billingCycleEnd`) — when the pools
253 /// reset.
254 pub reset_at: Option<DateTime<Utc>>,
255 /// Start of the current billing cycle (`billingCycleStart`), when the API
256 /// sends it. With `reset_at` it gives the exact window length the pace
257 /// projection needs; absent, no window length is reported at all.
258 pub cycle_start: Option<DateTime<Utc>>,
259}
260
261impl CursorSnapshot {
262 /// Length of the current billing cycle, but only when the API stated both
263 /// ends and they are ordered. `None` otherwise — older responses and every
264 /// snapshot cached before `billingCycleStart` existed omit the start, and a
265 /// guessed month would reach a frontend as an exact window and be paced as
266 /// one. `window_secs` is absent instead; the reset time still shows.
267 pub fn cycle_window(&self) -> Option<chrono::Duration> {
268 match (self.cycle_start, self.reset_at) {
269 (Some(start), Some(end)) if end > start => Some(end - start),
270 _ => None,
271 }
272 }
273
274 /// The binding pool — whichever is closest to (or furthest past) its cap.
275 /// Drives the bar color and the single generic `session_pct` alias.
276 pub fn worst_pct(&self) -> i32 {
277 self.auto_pct.max(self.api_pct)
278 }
279}
280
281/// Kiro CLI (AWS CodeWhisperer / Q Developer backend) — a single credit pool
282/// from `AmazonCodeWhispererService.GetUsageLimits`, the same call kiro-cli's
283/// own `/usage` slash command makes. Authenticated with the AWS SSO OIDC
284/// bearer token kiro-cli already cached locally, refreshed with the paired
285/// refresh token when it's close to expiry — see `kiro::db` and `kiro::oauth`.
286#[derive(Debug, Clone, PartialEq)]
287pub struct KiroSnapshot {
288 /// Subscription tier label (`subscriptionInfo.subscriptionTitle`, e.g.
289 /// "KIRO POWER").
290 pub plan: String,
291 /// Credits consumed this cycle (`currentUsageWithPrecision`).
292 pub used: f64,
293 /// Credits included in the plan (`usageLimitWithPrecision`).
294 pub limit: f64,
295 /// When the credit pool resets (`nextDateReset`).
296 pub reset_at: Option<DateTime<Utc>>,
297}
298
299impl Eq for KiroSnapshot {}
300
301impl KiroSnapshot {
302 /// Percentage of the credit pool consumed, rounded. `0` when `limit` is
303 /// not positive — defensive; the API has not been observed to send that.
304 pub fn pct(&self) -> i32 {
305 if self.limit <= 0.0 {
306 return 0;
307 }
308 ((self.used / self.limit) * 100.0)
309 .round()
310 .clamp(0.0, 9999.0) as i32
311 }
312}
313
314/// Kimi Code — weekly subscription quota plus a 5h rolling rate-limit window.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct KimiSnapshot {
317 pub plan: Option<String>,
318 pub weekly_limit: u64,
319 pub weekly_used: u64,
320 pub weekly_remaining: u64,
321 pub weekly_reset_at: Option<DateTime<Utc>>,
322 pub window_limit: u64,
323 pub window_used: u64,
324 pub window_remaining: u64,
325 pub window_reset_at: Option<DateTime<Utc>>,
326}
327
328impl KimiSnapshot {
329 fn pct(used: u64, limit: u64) -> i32 {
330 if limit == 0 {
331 0
332 } else {
333 // Keep all quota values exact: f64 loses integer precision above
334 // 2^53. This is the integer equivalent of round(used / limit *
335 // 100), with saturation for inconsistent upstream counters.
336 let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
337 pct.min(100) as i32
338 }
339 }
340
341 /// Percentage of the weekly subscription quota consumed (0..=100).
342 pub fn weekly_pct(&self) -> i32 {
343 Self::pct(self.weekly_used, self.weekly_limit)
344 }
345
346 /// Percentage of the rolling rate-limit window consumed (0..=100).
347 pub fn window_pct(&self) -> i32 {
348 Self::pct(self.window_used, self.window_limit)
349 }
350}
351
352/// Discriminated union of vendor-specific snapshots. The widget and TUI match
353/// on this to pick a renderer.
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub enum VendorSnapshot {
356 Anthropic(AnthropicSnapshot),
357 Openai(OpenAiSnapshot),
358 Copilot(crate::copilot::types::Snapshot),
359 Zai(ZaiSnapshot),
360 Openrouter(OpenRouterSnapshot),
361 Deepseek(DeepseekSnapshot),
362 Kimi(KimiSnapshot),
363 Kilo(KiloSnapshot),
364 Novita(NovitaSnapshot),
365 Moonshot(MoonshotSnapshot),
366 Grok(GrokSnapshot),
367 SuperGrok(SuperGrokSnapshot),
368 AnthropicApi(AnthropicApiSnapshot),
369 Antigravity(AntigravitySnapshot),
370 Cursor(CursorSnapshot),
371 Minimax(MinimaxSnapshot),
372 Kiro(KiroSnapshot),
373 NousResearch(crate::nous::types::AccountSnapshot),
374 OpenCodeGo(crate::opencode_go::types::Usage),
375 CommandCode(crate::commandcode::types::Snapshot),
376 Ollama(OllamaSnapshot),
377 /// A `[[custom]]` provider. Which one is not in the snapshot: the caller
378 /// that fetched it holds the `CustomProviderConfig`, and the cache
379 /// directory is keyed by its `id`.
380 Custom(crate::custom::types::CustomSnapshot),
381}
382
383/// Google Antigravity 2.0 / CLI snapshot. The API groups models into Gemini
384/// and third-party (Claude/GPT) buckets, and each group may carry a 5-hour and
385/// a weekly window — up to four, and not every product or plan offers all of
386/// them. Antigravity CLI 1.1.22 returns weekly buckets only, so every window is
387/// optional and a snapshot is valid when at least one arrived.
388#[derive(Debug, Clone, PartialEq)]
389pub struct AntigravitySnapshot {
390 pub plan: String,
391 /// Fingerprint of the signed-in account. Never displayed — it exists so a
392 /// cache written for one Google account is not served for another.
393 pub account: String,
394 /// Where the figures came from: a running local product, or the Cloud
395 /// Code API reached with the saved Google session when no local server
396 /// can answer — none is running, or `agy` withholds its CSRF token.
397 pub source: AntigravitySource,
398 /// Gemini group, 5-hour window.
399 pub session: Option<UsageWindow>,
400 /// Gemini group, weekly window.
401 pub weekly: Option<UsageWindow>,
402 /// Claude/GPT group, 5-hour window.
403 pub third_party_session: Option<UsageWindow>,
404 /// Claude/GPT group, weekly window.
405 pub third_party_weekly: Option<UsageWindow>,
406}
407
408impl Eq for AntigravitySnapshot {}
409
410/// Which path produced an [`AntigravitySnapshot`]. The local language server
411/// is the primary source; the remote API is the fallback for when no product
412/// is running, and the panel says so because the two can disagree briefly.
413#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
414pub enum AntigravitySource {
415 #[default]
416 Local,
417 Remote,
418}
419
420impl AntigravitySource {
421 pub fn as_str(self) -> &'static str {
422 match self {
423 AntigravitySource::Local => "local",
424 AntigravitySource::Remote => "remote",
425 }
426 }
427
428 /// Inverse of [`as_str`](Self::as_str); anything unrecognised is `None` so
429 /// a cache reader can fall back to the default rather than guess.
430 pub fn parse(s: &str) -> Option<Self> {
431 match s {
432 "local" => Some(AntigravitySource::Local),
433 "remote" => Some(AntigravitySource::Remote),
434 _ => None,
435 }
436 }
437}
438
439/// MiniMax Token Plan — `/v1/token_plan/remains` returns one row per model
440/// bucket (`general` for text/coding, `video`), and each row carries its own
441/// rolling interval window plus a weekly window.
442///
443/// Two things the payload dictates rather than convention: the interval length
444/// is **not fixed** (`general` rolls every 5h, `video` every 24h), so the
445/// duration is derived from the row's own start/end rather than assumed; and
446/// the API reports the percentage **remaining**, which is inverted on the way
447/// in so these windows carry consumed-% like every other vendor's.
448#[derive(Debug, Clone, PartialEq, Eq)]
449pub struct MinimaxSnapshot {
450 pub plan: String,
451 /// `general` bucket — rolling interval window (5h on the observed plans).
452 pub session: UsageWindow,
453 /// `general` bucket — weekly window.
454 pub weekly: UsageWindow,
455 /// `video` bucket, `None` on plans that carry no video quota.
456 pub video_session: Option<UsageWindow>,
457 pub video_weekly: Option<UsageWindow>,
458}
459
460/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
461/// monthly `limit` is supplied from config (the API exposes neither the limit
462/// nor the remaining prepaid credit balance).
463#[derive(Debug, Clone, PartialEq)]
464pub struct AnthropicApiSnapshot {
465 pub spent: f64,
466 pub limit: Option<f64>,
467}
468
469impl Eq for AnthropicApiSnapshot {}
470
471impl AnthropicApiSnapshot {
472 /// Spend as an integer percentage of the configured limit; `None` when no
473 /// positive limit is set.
474 pub fn pct(&self) -> Option<i32> {
475 self.limit
476 .filter(|l| l.is_finite() && *l > 0.0)
477 .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
478 }
479}
480
481/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
482/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
483#[derive(Debug, Clone, PartialEq)]
484pub struct KiloSnapshot {
485 pub label: String,
486 pub balance: f64,
487}
488
489impl Eq for KiloSnapshot {}
490
491/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
492/// all amounts already converted from the API's 1/10000-USD integers to USD.
493#[derive(Debug, Clone, PartialEq)]
494pub struct NovitaSnapshot {
495 /// Spendable credit balance (`availableBalance`).
496 pub available: f64,
497 /// Remaining top-up (`cashBalance`).
498 pub cash: f64,
499 /// Credit limit — max you can owe (`creditLimit`).
500 pub credit_limit: f64,
501 /// Amount currently owed (`outstandingInvoices`).
502 pub outstanding: f64,
503}
504
505impl Eq for NovitaSnapshot {}
506
507/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
508/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
509/// field in the response, so it's carried here from the region config.
510#[derive(Debug, Clone, PartialEq)]
511pub struct MoonshotSnapshot {
512 /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
513 /// the inference API.
514 pub available: f64,
515 /// Voucher credit (`voucher_balance`).
516 pub voucher: f64,
517 /// Cash balance (`cash_balance`); can be negative (debt).
518 pub cash: f64,
519 /// "USD" or "CNY", implied by the host.
520 pub currency: String,
521}
522
523impl Eq for MoonshotSnapshot {}
524
525/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
526/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
527#[derive(Debug, Clone, PartialEq)]
528pub struct GrokSnapshot {
529 pub balance: f64,
530}
531
532impl Eq for GrokSnapshot {}
533
534/// SuperGrok subscription usage from Grok Build's billing endpoint (ACP as
535/// fallback) plus banked remaining-resets. Distinct from [`GrokSnapshot`]
536/// (Management API prepaid balance).
537#[derive(Debug, Clone, PartialEq)]
538pub struct SuperGrokSnapshot {
539 /// Subscription tier label when the billing response supplies one
540 /// (e.g. "SuperGrok", "SuperGrok Heavy"); otherwise `"SuperGrok"`.
541 pub plan: String,
542 /// Opaque digest of Grok auth/config state. Never displayed — cache
543 /// isolation only.
544 pub account: String,
545 /// Current included-credit usage percent. The field name is retained as a
546 /// compatibility alias for format/render code; [`Self::period`] says
547 /// whether the server's actual window is weekly or monthly.
548 pub weekly_pct: i32,
549 pub period: SuperGrokPeriod,
550 /// When the current usage period ends.
551 pub reset_at: Option<DateTime<Utc>>,
552 /// Remaining prepaid (purchased) API credit in USD, when present.
553 pub prepaid_balance: Option<f64>,
554 pub reset_credits: ResetCredits,
555}
556
557impl Eq for SuperGrokSnapshot {}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
560pub enum SuperGrokPeriod {
561 Weekly,
562 Monthly,
563 Unknown,
564}
565
566impl SuperGrokPeriod {
567 pub fn label(self) -> &'static str {
568 match self {
569 Self::Weekly => "Weekly",
570 Self::Monthly => "Monthly",
571 Self::Unknown => "Current period",
572 }
573 }
574
575 pub fn short(self) -> &'static str {
576 match self {
577 Self::Weekly => "wk",
578 Self::Monthly => "mo",
579 Self::Unknown => "period",
580 }
581 }
582}
583
584/// OpenAI Codex OAuth — exposes whichever rolling windows the API reports.
585#[derive(Debug, Clone, PartialEq, Eq)]
586pub struct OpenAiSnapshot {
587 pub plan: String,
588 /// 5h window, identified by its duration rather than its wire position.
589 pub session: Option<UsageWindow>,
590 /// 7d window, identified by its duration rather than its wire position.
591 pub weekly: Option<UsageWindow>,
592 /// Optional 7d code-review bucket.
593 pub code_review: Option<UsageWindow>,
594 /// Named limits beside the main one, each with its own windows. Empty for
595 /// an account that has none.
596 pub additional_limits: Vec<OpenAiNamedLimit>,
597 /// Models the account currently cannot dispatch to, with the time they
598 /// return when the API states one. Only unavailable models are kept: a
599 /// list of everything that *is* working is noise, and the reason this
600 /// exists is to explain a refusal no percentage accounts for.
601 pub unavailable_models: Vec<OpenAiUnavailableModel>,
602 /// Optional credit balance + approximate message-count ranges.
603 pub credits: Option<OpenAiCredits>,
604 pub reset_credits: ResetCredits,
605 /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
606 /// the placeholder set and the "OpenAI does not expose this for Plus"
607 /// tooltip when the OAuth path isn't available.
608 pub source: OpenAiSource,
609}
610
611/// A named limit that sits beside Codex's main window — a reserved pool or a
612/// model-specific allowance. It can be exhausted while the headline window is
613/// nearly untouched, which is the case it exists to make visible.
614#[derive(Debug, Clone, PartialEq, Eq)]
615pub struct OpenAiNamedLimit {
616 /// The API's own name for it, shown as given.
617 pub name: String,
618 pub session: Option<UsageWindow>,
619 pub weekly: Option<UsageWindow>,
620}
621
622/// A model the account cannot currently dispatch to.
623#[derive(Debug, Clone, PartialEq, Eq)]
624pub struct OpenAiUnavailableModel {
625 pub model: String,
626 /// When the API says it returns. `None` means it did not say.
627 pub available_at: Option<DateTime<Utc>>,
628}
629
630/// Banked, user-redeemable quota resets — Codex's "rate limit reset credits"
631/// and SuperGrok's "remaining resets" are the same idea under two names: a
632/// count you have earned, each with its own expiry, redeemed by hand rather
633/// than arriving on the window's own schedule. Distinct from a
634/// [`UsageWindow::resets_at`], which needs no action and cannot be banked.
635///
636/// The redemption identifier each provider returns alongside these
637/// (`credits[].id`, `tokens[].token_id`) is deliberately *not* carried here:
638/// it is the handle that spends the credit, and nothing that renders a status
639/// bar needs it.
640#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
641pub struct ResetCredits {
642 pub available: u32,
643 /// One row per credit the provider described. May be shorter than
644 /// `available` — Codex's usage endpoint gives the count without the
645 /// per-credit detail, and the detail call is allowed to fail on its own.
646 #[serde(default)]
647 pub credits: Vec<ResetCredit>,
648}
649
650#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
651pub struct ResetCredit {
652 /// Provider label when one exists ("Full reset (Weekly + 5 hr)"). SuperGrok
653 /// tokens have no title.
654 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub title: Option<String>,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub expires_at: Option<DateTime<Utc>>,
658}
659
660impl ResetCredits {
661 pub fn is_empty(&self) -> bool {
662 self.available == 0
663 }
664
665 pub fn next_expiry(&self) -> Option<DateTime<Utc>> {
666 self.credits
667 .iter()
668 .filter_map(|credit| credit.expires_at)
669 .min()
670 }
671}
672
673#[derive(Debug, Clone, Copy, PartialEq, Eq)]
674pub enum OpenAiSource {
675 CodexOauth,
676 AdminKeyMtd,
677 Unavailable,
678}
679
680#[derive(Debug, Clone, PartialEq, Eq)]
681pub struct OpenAiCredits {
682 /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
683 /// a string because OpenAI returns it that way.
684 pub balance: String,
685 pub has_credits: bool,
686 pub unlimited: bool,
687 pub approx_local_messages: Option<(i64, i64)>,
688 pub approx_cloud_messages: Option<(i64, i64)>,
689}
690
691/// Z.AI / BigModel — list of buckets with discriminated types. We project the
692/// two we care about into named fields (5h tokens, weekly tokens, MCP).
693#[derive(Debug, Clone, PartialEq, Eq)]
694pub struct ZaiSnapshot {
695 pub plan: String,
696 pub session: Option<UsageWindow>,
697 pub weekly: Option<UsageWindow>,
698 pub mcp: Option<UsageWindow>,
699}
700
701/// Ollama Cloud — the session and weekly usage windows served by
702/// `ollama.com/api/usage`, plus a per-model breakdown. The response also
703/// carries an `activity.cost` string for the current period; we keep it raw
704/// (it is already dollar-formatted upstream) and let the renderer place it.
705#[derive(Debug, Clone, PartialEq, Eq)]
706pub struct OllamaSnapshot {
707 /// Display label, taken from the `[ollama] plan` config field. The API
708 /// itself does not report a plan name.
709 pub plan: String,
710 /// 5h rolling window (`limits.session`). `None` when the account has not
711 /// touched the cloud tier yet (the window is omitted from the response,
712 /// not reported as zero).
713 pub session: Option<UsageWindow>,
714 /// 7d rolling window (`limits.weekly`).
715 pub weekly: Option<UsageWindow>,
716 /// Per-model request counts inside the session window, in the order the
717 /// API returned them. Renderers sort and truncate this for the tooltip.
718 pub session_models: Vec<OllamaModelUsage>,
719 /// Per-model request counts inside the weekly window.
720 pub weekly_models: Vec<OllamaModelUsage>,
721 /// `activity.cost` as a pre-formatted dollar string (`"0.00000"`,
722 /// `"1.23456"`). Already a string on the wire — the renderer decides
723 /// whether to keep it verbatim or reformat.
724 pub activity_cost: Option<String>,
725 /// `activity.period.type` (`"last_4_weeks"` and friends). A short
726 /// human-readable label the renderer can show next to the cost.
727 pub activity_period: Option<String>,
728}
729
730/// One row of `OllamaSnapshot::{session,weekly}_models`. The API carries the
731/// per-model request count; the percentage of the window that this single
732/// model represents is not reported, so the renderer derives it locally.
733#[derive(Debug, Clone, PartialEq, Eq)]
734pub struct OllamaModelUsage {
735 pub name: String,
736 pub request_count: u64,
737}
738
739/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
740/// `/api/v1/credits` and `/api/v1/key`.
741#[derive(Debug, Clone, PartialEq)]
742pub struct OpenRouterSnapshot {
743 pub label: String,
744 pub total_credits: f64,
745 pub total_usage: f64,
746 pub usage_daily: f64,
747 pub usage_weekly: f64,
748 pub usage_monthly: f64,
749 pub is_free_tier: bool,
750 pub limit: Option<f64>,
751 pub limit_remaining: Option<f64>,
752}
753
754impl Eq for OpenRouterSnapshot {}
755
756impl OpenRouterSnapshot {
757 /// Spendable credit, **which can be negative**: OpenRouter lets an account
758 /// run into debt, and clamping that to zero would report a healthy-looking
759 /// `$0.00` to someone who has to top up before anything works again. The
760 /// wire fields are each non-negative (see `openrouter::types`), so a
761 /// negative result only ever means usage has overrun credits.
762 pub fn balance(&self) -> f64 {
763 self.total_credits - self.total_usage
764 }
765 /// Percentage of total_credits consumed (0..=100). Returns 0 when
766 /// `total_credits` is 0 (free-tier-only accounts) — there is no
767 /// denominator to be a percentage of. Severity does not come from this
768 /// number alone: see [`crate::openrouter::vendor::severity`], which treats
769 /// a negative [`Self::balance`] as critical regardless of the percentage.
770 pub fn consumed_pct(&self) -> i32 {
771 if self.total_credits <= 0.0 {
772 return 0;
773 }
774 ((self.total_usage / self.total_credits) * 100.0)
775 .round()
776 .clamp(0.0, 100.0) as i32
777 }
778}
779
780/// Worst-of severity class for the Waybar bar text color. Mirrors
781/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
782pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
783 let mut max = snap.session.utilization_pct;
784 if snap.weekly.utilization_pct > max {
785 max = snap.weekly.utilization_pct;
786 }
787 if let Some(s) = &snap.sonnet
788 && s.utilization_pct > max
789 {
790 max = s.utilization_pct;
791 }
792 for sw in &snap.scoped {
793 if sw.window.utilization_pct > max {
794 max = sw.window.utilization_pct;
795 }
796 }
797 // Extra usage only promotes severity if a rate-limit window is at 100%.
798 let any_at_cap = snap.session.utilization_pct >= 100
799 || snap.weekly.utilization_pct >= 100
800 || snap
801 .sonnet
802 .as_ref()
803 .is_some_and(|s| s.utilization_pct >= 100)
804 || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
805 if any_at_cap && let Some(extra) = snap.extra.as_ref() {
806 let p = extra.percent();
807 if p > max {
808 max = p;
809 }
810 }
811 crate::pango::severity_for(max)
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817 use crate::pacing::PaceSeverity;
818 use chrono::Duration;
819
820 fn w(pct: i32) -> UsageWindow {
821 UsageWindow {
822 utilization_pct: pct,
823 resets_at: None,
824 window_duration: Duration::hours(5),
825 }
826 }
827
828 fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
829 AnthropicSnapshot {
830 plan: "Max 5x".into(),
831 session: w(s),
832 weekly: w(w_),
833 sonnet: sonnet.map(w),
834 scoped: vec![],
835 extra: extra.map(|(limit, spent)| ExtraUsage {
836 limit: Some(Cents(limit)),
837 spent: Cents(spent),
838 currency: None,
839 decimal_places: Some(2),
840 }),
841 }
842 }
843
844 #[test]
845 fn fmt_minor_honors_currency_and_scale() {
846 // No currency (older payloads) keeps the historical `$`.
847 assert_eq!(fmt_minor(250, 2, None), "$2.50");
848 // The #30 reporter's actual figures: BRL must not be claimed as `$`.
849 assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
850 assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
851 // Zero-exponent currency: no decimal point, no /100.
852 assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
853 // Sign precedes the symbol, matching `fmt_dollars`.
854 assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
855 // Unknown code stays truthful as a suffix rather than guessing a symbol.
856 assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
857 }
858
859 #[test]
860 fn extra_usage_formats_in_its_own_currency() {
861 let e = ExtraUsage {
862 limit: None,
863 spent: Cents(14157),
864 currency: Some("BRL".into()),
865 decimal_places: Some(2),
866 };
867 assert_eq!(e.fmt_spent(), "R$141.57");
868 assert_eq!(e.fmt_limit(), None);
869
870 let capped = ExtraUsage {
871 limit: Some(Cents(5000)),
872 spent: Cents(250),
873 currency: None,
874 decimal_places: Some(2),
875 };
876 assert_eq!(capped.fmt_spent(), "$2.50");
877 assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
878 }
879
880 #[test]
881 fn cents_format_positive() {
882 assert_eq!(Cents(0).fmt_dollars(), "$0.00");
883 assert_eq!(Cents(50).fmt_dollars(), "$0.50");
884 assert_eq!(Cents(250).fmt_dollars(), "$2.50");
885 assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
886 }
887
888 #[test]
889 fn cents_format_negative_uses_leading_sign() {
890 // claudebar bug-fix: never "$-1.-50" — sign goes before the dollar sign.
891 assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
892 assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
893 }
894
895 #[test]
896 fn extra_percent_with_zero_limit_is_zero() {
897 assert_eq!(
898 ExtraUsage {
899 limit: Some(Cents(0)),
900 spent: Cents(100),
901 currency: None,
902 decimal_places: Some(2),
903 }
904 .percent(),
905 0
906 );
907 }
908
909 #[test]
910 fn extra_percent_truncates() {
911 // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
912 assert_eq!(
913 ExtraUsage {
914 limit: Some(Cents(10000)),
915 spent: Cents(3333),
916 currency: None,
917 decimal_places: Some(2),
918 }
919 .percent(),
920 33
921 );
922 }
923
924 #[test]
925 fn severity_picks_worst_of_three_windows() {
926 let s = snap(40, 60, Some(80), None);
927 assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
928 }
929
930 #[test]
931 fn severity_ignores_extra_when_no_cap_hit() {
932 // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
933 let s = snap(50, 60, None, Some((10000, 9500)));
934 assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
935 }
936
937 #[test]
938 fn severity_promotes_extra_when_session_at_100() {
939 let s = snap(100, 50, None, Some((10000, 9500)));
940 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
941 }
942
943 #[test]
944 fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
945 // session = 100, weekly = 50, extra = 100% → max should be 100.
946 let s = snap(100, 50, None, Some((10000, 10000)));
947 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
948 }
949
950 fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
951 s.scoped.push(ScopedWindow {
952 label: "Fable".into(),
953 window: w(pct),
954 });
955 s
956 }
957
958 #[test]
959 fn severity_includes_scoped_windows() {
960 // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
961 // week at 84 → the bar class must escalate to High.
962 let s = with_scoped(snap(10, 55, None, None), 84);
963 assert_eq!(anthropic_severity(&s), PaceSeverity::High);
964 }
965
966 #[test]
967 fn severity_promotes_extra_when_scoped_at_100() {
968 // A scoped window at cap counts as a rate-limit cap hit, so extra
969 // usage above the window max is promoted — same rule as session/weekly.
970 let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
971 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
972 }
973
974 #[test]
975 fn kimi_percent_is_exact_above_f64_precision() {
976 let snap = KimiSnapshot {
977 plan: None,
978 weekly_limit: (1 << 53) + 1,
979 weekly_used: 1 << 52,
980 weekly_remaining: 0,
981 weekly_reset_at: None,
982 window_limit: u64::MAX,
983 window_used: u64::MAX - 1,
984 window_remaining: 0,
985 window_reset_at: None,
986 };
987 assert_eq!(snap.weekly_pct(), 50);
988 assert_eq!(snap.window_pct(), 100);
989 }
990
991 #[test]
992 fn kiro_pct_is_zero_without_a_positive_limit() {
993 let snap = KiroSnapshot {
994 plan: "FREE".into(),
995 used: 5.0,
996 limit: 0.0,
997 reset_at: None,
998 };
999 assert_eq!(snap.pct(), 0);
1000 }
1001
1002 #[test]
1003 fn kiro_pct_rounds_the_credit_ratio() {
1004 let snap = KiroSnapshot {
1005 plan: "KIRO POWER".into(),
1006 used: 1.0,
1007 limit: 3.0,
1008 reset_at: None,
1009 };
1010 assert_eq!(snap.pct(), 33);
1011 }
1012}