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