1use chrono::{DateTime, Utc};
16
17use crate::error::{AppError, Result};
18
19pub fn finite_amount(vendor: &str, field: &str, v: f64) -> Result<f64> {
23 if v.is_finite() {
24 Ok(v)
25 } else {
26 Err(AppError::Schema(format!(
27 "{vendor}: `{field}` is not a finite number"
28 )))
29 }
30}
31
32pub fn parse_amount(vendor: &str, field: &str, s: &str) -> Result<f64> {
36 let t = s.trim();
37 if t.is_empty() {
38 return Err(AppError::Schema(format!("{vendor}: `{field}` is empty")));
39 }
40 let v: f64 = t
41 .parse()
42 .map_err(|_| AppError::Schema(format!("{vendor}: `{field}` is not numeric (got {t:?})")))?;
43 finite_amount(vendor, field, v)
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct UsageWindow {
53 pub utilization_pct: i32,
54 pub resets_at: Option<DateTime<Utc>>,
55 pub window_duration: chrono::Duration,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct Cents(pub i64);
63
64impl Cents {
65 pub fn fmt_dollars(self) -> String {
68 let (sign, abs) = if self.0 < 0 {
69 ("-", -self.0)
70 } else {
71 ("", self.0)
72 };
73 format!("{sign}${}.{:02}", abs / 100, abs % 100)
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct AnthropicSnapshot {
81 pub plan: String,
83 pub session: UsageWindow,
84 pub weekly: UsageWindow,
85 pub sonnet: Option<UsageWindow>,
88 pub scoped: Vec<ScopedWindow>,
93 pub extra: Option<ExtraUsage>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ScopedWindow {
101 pub label: String,
102 pub window: UsageWindow,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ExtraUsage {
108 pub limit: Option<Cents>,
114 pub spent: Cents,
115 pub currency: Option<String>,
119 pub decimal_places: Option<u32>,
123}
124
125impl ExtraUsage {
126 pub fn percent(&self) -> i32 {
132 match self.limit {
133 Some(l) if l.0 > 0 => ((self.spent.0 * 100) / l.0) as i32,
134 _ => 0,
135 }
136 }
137
138 pub fn fmt_spent(&self) -> String {
139 self.fmt_amount(self.spent)
140 }
141
142 pub fn fmt_limit(&self) -> Option<String> {
143 self.limit.map(|l| self.fmt_amount(l))
144 }
145
146 fn fmt_amount(&self, amount: Cents) -> String {
147 match (self.decimal_places, self.currency.as_deref()) {
148 (Some(decimal_places), currency) => fmt_minor(amount.0, decimal_places, currency),
149 (None, None) => fmt_minor(amount.0, 2, None),
153 (None, Some(currency)) => fmt_minor_units(amount.0, currency),
157 }
158 }
159}
160
161fn fmt_minor_units(minor: i64, currency: &str) -> String {
162 let sign = if minor < 0 { "-" } else { "" };
163 format!("{sign}{} minor units {currency}", minor.unsigned_abs())
164}
165
166pub fn fmt_minor(minor: i64, decimal_places: u32, currency: Option<&str>) -> String {
172 let scale = 10_u64.pow(decimal_places);
173 let sign = if minor < 0 { "-" } else { "" };
176 let abs = minor.unsigned_abs();
177 let number = if decimal_places == 0 {
178 format!("{abs}")
179 } else {
180 format!(
181 "{}.{:0width$}",
182 abs / scale,
183 abs % scale,
184 width = decimal_places as usize
185 )
186 };
187 match currency {
188 None | Some("USD") => format!("{sign}${number}"),
189 Some("BRL") => format!("{sign}R${number}"),
190 Some("EUR") => format!("{sign}€{number}"),
191 Some("GBP") => format!("{sign}£{number}"),
192 Some("JPY") | Some("CNY") => format!("{sign}¥{number}"),
193 Some(other) => format!("{sign}{number} {other}"),
194 }
195}
196
197#[derive(Debug, Clone, PartialEq)]
199pub struct DeepseekSnapshot {
200 pub is_available: bool,
201 pub balance: f64,
203 pub granted: f64,
205 pub topped_up: f64,
207 pub currency: String,
209}
210
211impl Eq for DeepseekSnapshot {}
212
213impl Default for DeepseekSnapshot {
214 fn default() -> Self {
215 Self {
216 is_available: false,
217 balance: 0.0,
218 granted: 0.0,
219 topped_up: 0.0,
220 currency: String::new(),
221 }
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct KimiSnapshot {
228 pub plan: Option<String>,
229 pub weekly_limit: u64,
230 pub weekly_used: u64,
231 pub weekly_remaining: u64,
232 pub weekly_reset_at: Option<DateTime<Utc>>,
233 pub window_limit: u64,
234 pub window_used: u64,
235 pub window_remaining: u64,
236 pub window_reset_at: Option<DateTime<Utc>>,
237}
238
239impl KimiSnapshot {
240 fn pct(used: u64, limit: u64) -> i32 {
241 if limit == 0 {
242 0
243 } else {
244 let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
248 pct.min(100) as i32
249 }
250 }
251
252 pub fn weekly_pct(&self) -> i32 {
254 Self::pct(self.weekly_used, self.weekly_limit)
255 }
256
257 pub fn window_pct(&self) -> i32 {
259 Self::pct(self.window_used, self.window_limit)
260 }
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
266pub enum VendorSnapshot {
267 Anthropic(AnthropicSnapshot),
268 Openai(OpenAiSnapshot),
269 Zai(ZaiSnapshot),
270 Openrouter(OpenRouterSnapshot),
271 Deepseek(DeepseekSnapshot),
272 Kimi(KimiSnapshot),
273 Kilo(KiloSnapshot),
274 Novita(NovitaSnapshot),
275 Moonshot(MoonshotSnapshot),
276 Grok(GrokSnapshot),
277 AnthropicApi(AnthropicApiSnapshot),
278 Antigravity(AntigravitySnapshot),
279}
280
281#[derive(Debug, Clone, PartialEq)]
285pub struct AntigravitySnapshot {
286 pub plan: String,
287 pub account: String,
290 pub session: UsageWindow,
292 pub weekly: UsageWindow,
294 pub third_party_session: Option<UsageWindow>,
296 pub third_party_weekly: Option<UsageWindow>,
298}
299
300impl Eq for AntigravitySnapshot {}
301
302#[derive(Debug, Clone, PartialEq)]
306pub struct AnthropicApiSnapshot {
307 pub spent: f64,
308 pub limit: Option<f64>,
309}
310
311impl Eq for AnthropicApiSnapshot {}
312
313impl AnthropicApiSnapshot {
314 pub fn pct(&self) -> Option<i32> {
317 self.limit
318 .filter(|l| l.is_finite() && *l > 0.0)
319 .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
320 }
321}
322
323#[derive(Debug, Clone, PartialEq)]
326pub struct KiloSnapshot {
327 pub label: String,
328 pub balance: f64,
329}
330
331impl Eq for KiloSnapshot {}
332
333#[derive(Debug, Clone, PartialEq)]
336pub struct NovitaSnapshot {
337 pub available: f64,
339 pub cash: f64,
341 pub credit_limit: f64,
343 pub outstanding: f64,
345}
346
347impl Eq for NovitaSnapshot {}
348
349#[derive(Debug, Clone, PartialEq)]
353pub struct MoonshotSnapshot {
354 pub available: f64,
357 pub voucher: f64,
359 pub cash: f64,
361 pub currency: String,
363}
364
365impl Eq for MoonshotSnapshot {}
366
367#[derive(Debug, Clone, PartialEq)]
370pub struct GrokSnapshot {
371 pub balance: f64,
372}
373
374impl Eq for GrokSnapshot {}
375
376#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct OpenAiSnapshot {
379 pub plan: String,
380 pub session: UsageWindow,
382 pub weekly: UsageWindow,
384 pub code_review: Option<UsageWindow>,
386 pub credits: Option<OpenAiCredits>,
388 pub source: OpenAiSource,
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub enum OpenAiSource {
396 CodexOauth,
397 AdminKeyMtd,
398 Unavailable,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct OpenAiCredits {
403 pub balance: String,
406 pub has_credits: bool,
407 pub unlimited: bool,
408 pub approx_local_messages: Option<(i64, i64)>,
409 pub approx_cloud_messages: Option<(i64, i64)>,
410}
411
412#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct ZaiSnapshot {
416 pub plan: String,
417 pub session: Option<UsageWindow>,
418 pub weekly: Option<UsageWindow>,
419 pub mcp: Option<UsageWindow>,
420}
421
422#[derive(Debug, Clone, PartialEq)]
425pub struct OpenRouterSnapshot {
426 pub label: String,
427 pub total_credits: f64,
428 pub total_usage: f64,
429 pub usage_daily: f64,
430 pub usage_weekly: f64,
431 pub usage_monthly: f64,
432 pub is_free_tier: bool,
433 pub limit: Option<f64>,
434 pub limit_remaining: Option<f64>,
435}
436
437impl Eq for OpenRouterSnapshot {}
438
439impl OpenRouterSnapshot {
440 pub fn balance(&self) -> f64 {
441 (self.total_credits - self.total_usage).max(0.0)
442 }
443 pub fn consumed_pct(&self) -> i32 {
446 if self.total_credits <= 0.0 {
447 return 0;
448 }
449 ((self.total_usage / self.total_credits) * 100.0)
450 .round()
451 .clamp(0.0, 100.0) as i32
452 }
453}
454
455pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
458 let mut max = snap.session.utilization_pct;
459 if snap.weekly.utilization_pct > max {
460 max = snap.weekly.utilization_pct;
461 }
462 if let Some(s) = &snap.sonnet
463 && s.utilization_pct > max
464 {
465 max = s.utilization_pct;
466 }
467 for sw in &snap.scoped {
468 if sw.window.utilization_pct > max {
469 max = sw.window.utilization_pct;
470 }
471 }
472 let any_at_cap = snap.session.utilization_pct >= 100
474 || snap.weekly.utilization_pct >= 100
475 || snap
476 .sonnet
477 .as_ref()
478 .is_some_and(|s| s.utilization_pct >= 100)
479 || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
480 if any_at_cap && let Some(extra) = snap.extra.as_ref() {
481 let p = extra.percent();
482 if p > max {
483 max = p;
484 }
485 }
486 crate::pango::severity_for(max)
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use crate::pacing::PaceSeverity;
493 use chrono::Duration;
494
495 fn w(pct: i32) -> UsageWindow {
496 UsageWindow {
497 utilization_pct: pct,
498 resets_at: None,
499 window_duration: Duration::hours(5),
500 }
501 }
502
503 fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
504 AnthropicSnapshot {
505 plan: "Max 5x".into(),
506 session: w(s),
507 weekly: w(w_),
508 sonnet: sonnet.map(w),
509 scoped: vec![],
510 extra: extra.map(|(limit, spent)| ExtraUsage {
511 limit: Some(Cents(limit)),
512 spent: Cents(spent),
513 currency: None,
514 decimal_places: Some(2),
515 }),
516 }
517 }
518
519 #[test]
520 fn fmt_minor_honors_currency_and_scale() {
521 assert_eq!(fmt_minor(250, 2, None), "$2.50");
523 assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
525 assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
526 assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
528 assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
530 assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
532 }
533
534 #[test]
535 fn extra_usage_formats_in_its_own_currency() {
536 let e = ExtraUsage {
537 limit: None,
538 spent: Cents(14157),
539 currency: Some("BRL".into()),
540 decimal_places: Some(2),
541 };
542 assert_eq!(e.fmt_spent(), "R$141.57");
543 assert_eq!(e.fmt_limit(), None);
544
545 let capped = ExtraUsage {
546 limit: Some(Cents(5000)),
547 spent: Cents(250),
548 currency: None,
549 decimal_places: Some(2),
550 };
551 assert_eq!(capped.fmt_spent(), "$2.50");
552 assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
553 }
554
555 #[test]
556 fn cents_format_positive() {
557 assert_eq!(Cents(0).fmt_dollars(), "$0.00");
558 assert_eq!(Cents(50).fmt_dollars(), "$0.50");
559 assert_eq!(Cents(250).fmt_dollars(), "$2.50");
560 assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
561 }
562
563 #[test]
564 fn cents_format_negative_uses_leading_sign() {
565 assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
567 assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
568 }
569
570 #[test]
571 fn extra_percent_with_zero_limit_is_zero() {
572 assert_eq!(
573 ExtraUsage {
574 limit: Some(Cents(0)),
575 spent: Cents(100),
576 currency: None,
577 decimal_places: Some(2),
578 }
579 .percent(),
580 0
581 );
582 }
583
584 #[test]
585 fn extra_percent_truncates() {
586 assert_eq!(
588 ExtraUsage {
589 limit: Some(Cents(10000)),
590 spent: Cents(3333),
591 currency: None,
592 decimal_places: Some(2),
593 }
594 .percent(),
595 33
596 );
597 }
598
599 #[test]
600 fn severity_picks_worst_of_three_windows() {
601 let s = snap(40, 60, Some(80), None);
602 assert_eq!(anthropic_severity(&s), PaceSeverity::High); }
604
605 #[test]
606 fn severity_ignores_extra_when_no_cap_hit() {
607 let s = snap(50, 60, None, Some((10000, 9500)));
609 assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); }
611
612 #[test]
613 fn severity_promotes_extra_when_session_at_100() {
614 let s = snap(100, 50, None, Some((10000, 9500)));
615 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); }
617
618 #[test]
619 fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
620 let s = snap(100, 50, None, Some((10000, 10000)));
622 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
623 }
624
625 fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
626 s.scoped.push(ScopedWindow {
627 label: "Fable".into(),
628 window: w(pct),
629 });
630 s
631 }
632
633 #[test]
634 fn severity_includes_scoped_windows() {
635 let s = with_scoped(snap(10, 55, None, None), 84);
638 assert_eq!(anthropic_severity(&s), PaceSeverity::High);
639 }
640
641 #[test]
642 fn severity_promotes_extra_when_scoped_at_100() {
643 let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
646 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
647 }
648
649 #[test]
650 fn kimi_percent_is_exact_above_f64_precision() {
651 let snap = KimiSnapshot {
652 plan: None,
653 weekly_limit: (1 << 53) + 1,
654 weekly_used: 1 << 52,
655 weekly_remaining: 0,
656 weekly_reset_at: None,
657 window_limit: u64::MAX,
658 window_used: u64::MAX - 1,
659 window_remaining: 0,
660 window_reset_at: None,
661 };
662 assert_eq!(snap.weekly_pct(), 50);
663 assert_eq!(snap.window_pct(), 100);
664 }
665}