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)]
61pub struct Cents(pub i64);
62
63impl Cents {
64 pub fn fmt_dollars(self) -> String {
67 let (sign, abs) = if self.0 < 0 {
68 ("-", -self.0)
69 } else {
70 ("", self.0)
71 };
72 format!("{sign}${}.{:02}", abs / 100, abs % 100)
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct AnthropicSnapshot {
80 pub plan: String,
82 pub session: UsageWindow,
83 pub weekly: UsageWindow,
84 pub sonnet: Option<UsageWindow>,
87 pub scoped: Vec<ScopedWindow>,
92 pub extra: Option<ExtraUsage>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ScopedWindow {
100 pub label: String,
101 pub window: UsageWindow,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct ExtraUsage {
107 pub limit: Cents,
108 pub spent: Cents,
109}
110
111impl ExtraUsage {
112 pub fn percent(self) -> i32 {
115 if self.limit.0 <= 0 {
116 0
117 } else {
118 ((self.spent.0 * 100) / self.limit.0) as i32
119 }
120 }
121}
122
123#[derive(Debug, Clone, PartialEq)]
125pub struct DeepseekSnapshot {
126 pub is_available: bool,
127 pub balance: f64,
129 pub granted: f64,
131 pub topped_up: f64,
133 pub currency: String,
135}
136
137impl Eq for DeepseekSnapshot {}
138
139impl Default for DeepseekSnapshot {
140 fn default() -> Self {
141 Self {
142 is_available: false,
143 balance: 0.0,
144 granted: 0.0,
145 topped_up: 0.0,
146 currency: String::new(),
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct KimiSnapshot {
154 pub plan: Option<String>,
155 pub weekly_limit: u64,
156 pub weekly_used: u64,
157 pub weekly_remaining: u64,
158 pub weekly_reset_at: Option<DateTime<Utc>>,
159 pub window_limit: u64,
160 pub window_used: u64,
161 pub window_remaining: u64,
162 pub window_reset_at: Option<DateTime<Utc>>,
163}
164
165impl KimiSnapshot {
166 fn pct(used: u64, limit: u64) -> i32 {
167 if limit == 0 {
168 0
169 } else {
170 let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
174 pct.min(100) as i32
175 }
176 }
177
178 pub fn weekly_pct(&self) -> i32 {
180 Self::pct(self.weekly_used, self.weekly_limit)
181 }
182
183 pub fn window_pct(&self) -> i32 {
185 Self::pct(self.window_used, self.window_limit)
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum VendorSnapshot {
193 Anthropic(AnthropicSnapshot),
194 Openai(OpenAiSnapshot),
195 Zai(ZaiSnapshot),
196 Openrouter(OpenRouterSnapshot),
197 Deepseek(DeepseekSnapshot),
198 Kimi(KimiSnapshot),
199 Kilo(KiloSnapshot),
200 Novita(NovitaSnapshot),
201 Moonshot(MoonshotSnapshot),
202 Grok(GrokSnapshot),
203 AnthropicApi(AnthropicApiSnapshot),
204}
205
206#[derive(Debug, Clone, PartialEq)]
210pub struct AnthropicApiSnapshot {
211 pub spent: f64,
212 pub limit: Option<f64>,
213}
214
215impl Eq for AnthropicApiSnapshot {}
216
217impl AnthropicApiSnapshot {
218 pub fn pct(&self) -> Option<i32> {
221 self.limit
222 .filter(|l| l.is_finite() && *l > 0.0)
223 .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
224 }
225}
226
227#[derive(Debug, Clone, PartialEq)]
230pub struct KiloSnapshot {
231 pub label: String,
232 pub balance: f64,
233}
234
235impl Eq for KiloSnapshot {}
236
237#[derive(Debug, Clone, PartialEq)]
240pub struct NovitaSnapshot {
241 pub available: f64,
243 pub cash: f64,
245 pub credit_limit: f64,
247 pub outstanding: f64,
249}
250
251impl Eq for NovitaSnapshot {}
252
253#[derive(Debug, Clone, PartialEq)]
257pub struct MoonshotSnapshot {
258 pub available: f64,
261 pub voucher: f64,
263 pub cash: f64,
265 pub currency: String,
267}
268
269impl Eq for MoonshotSnapshot {}
270
271#[derive(Debug, Clone, PartialEq)]
274pub struct GrokSnapshot {
275 pub balance: f64,
276}
277
278impl Eq for GrokSnapshot {}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct OpenAiSnapshot {
283 pub plan: String,
284 pub session: UsageWindow,
286 pub weekly: UsageWindow,
288 pub code_review: Option<UsageWindow>,
290 pub credits: Option<OpenAiCredits>,
292 pub source: OpenAiSource,
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum OpenAiSource {
300 CodexOauth,
301 AdminKeyMtd,
302 Unavailable,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct OpenAiCredits {
307 pub balance: String,
310 pub has_credits: bool,
311 pub unlimited: bool,
312 pub approx_local_messages: Option<(i64, i64)>,
313 pub approx_cloud_messages: Option<(i64, i64)>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct ZaiSnapshot {
320 pub plan: String,
321 pub session: Option<UsageWindow>,
322 pub weekly: Option<UsageWindow>,
323 pub mcp: Option<UsageWindow>,
324}
325
326#[derive(Debug, Clone, PartialEq)]
329pub struct OpenRouterSnapshot {
330 pub label: String,
331 pub total_credits: f64,
332 pub total_usage: f64,
333 pub usage_daily: f64,
334 pub usage_weekly: f64,
335 pub usage_monthly: f64,
336 pub is_free_tier: bool,
337 pub limit: Option<f64>,
338 pub limit_remaining: Option<f64>,
339}
340
341impl Eq for OpenRouterSnapshot {}
342
343impl OpenRouterSnapshot {
344 pub fn balance(&self) -> f64 {
345 (self.total_credits - self.total_usage).max(0.0)
346 }
347 pub fn consumed_pct(&self) -> i32 {
350 if self.total_credits <= 0.0 {
351 return 0;
352 }
353 ((self.total_usage / self.total_credits) * 100.0)
354 .round()
355 .clamp(0.0, 100.0) as i32
356 }
357}
358
359pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
362 let mut max = snap.session.utilization_pct;
363 if snap.weekly.utilization_pct > max {
364 max = snap.weekly.utilization_pct;
365 }
366 if let Some(s) = &snap.sonnet
367 && s.utilization_pct > max
368 {
369 max = s.utilization_pct;
370 }
371 for sw in &snap.scoped {
372 if sw.window.utilization_pct > max {
373 max = sw.window.utilization_pct;
374 }
375 }
376 let any_at_cap = snap.session.utilization_pct >= 100
378 || snap.weekly.utilization_pct >= 100
379 || snap
380 .sonnet
381 .as_ref()
382 .is_some_and(|s| s.utilization_pct >= 100)
383 || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
384 if any_at_cap && let Some(extra) = snap.extra {
385 let p = extra.percent();
386 if p > max {
387 max = p;
388 }
389 }
390 crate::pango::severity_for(max)
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use crate::pacing::PaceSeverity;
397 use chrono::Duration;
398
399 fn w(pct: i32) -> UsageWindow {
400 UsageWindow {
401 utilization_pct: pct,
402 resets_at: None,
403 window_duration: Duration::hours(5),
404 }
405 }
406
407 fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
408 AnthropicSnapshot {
409 plan: "Max 5x".into(),
410 session: w(s),
411 weekly: w(w_),
412 sonnet: sonnet.map(w),
413 scoped: vec![],
414 extra: extra.map(|(limit, spent)| ExtraUsage {
415 limit: Cents(limit),
416 spent: Cents(spent),
417 }),
418 }
419 }
420
421 #[test]
422 fn cents_format_positive() {
423 assert_eq!(Cents(0).fmt_dollars(), "$0.00");
424 assert_eq!(Cents(50).fmt_dollars(), "$0.50");
425 assert_eq!(Cents(250).fmt_dollars(), "$2.50");
426 assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
427 }
428
429 #[test]
430 fn cents_format_negative_uses_leading_sign() {
431 assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
433 assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
434 }
435
436 #[test]
437 fn extra_percent_with_zero_limit_is_zero() {
438 assert_eq!(
439 ExtraUsage {
440 limit: Cents(0),
441 spent: Cents(100)
442 }
443 .percent(),
444 0
445 );
446 }
447
448 #[test]
449 fn extra_percent_truncates() {
450 assert_eq!(
452 ExtraUsage {
453 limit: Cents(10000),
454 spent: Cents(3333)
455 }
456 .percent(),
457 33
458 );
459 }
460
461 #[test]
462 fn severity_picks_worst_of_three_windows() {
463 let s = snap(40, 60, Some(80), None);
464 assert_eq!(anthropic_severity(&s), PaceSeverity::High); }
466
467 #[test]
468 fn severity_ignores_extra_when_no_cap_hit() {
469 let s = snap(50, 60, None, Some((10000, 9500)));
471 assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); }
473
474 #[test]
475 fn severity_promotes_extra_when_session_at_100() {
476 let s = snap(100, 50, None, Some((10000, 9500)));
477 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); }
479
480 #[test]
481 fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
482 let s = snap(100, 50, None, Some((10000, 10000)));
484 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
485 }
486
487 fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
488 s.scoped.push(ScopedWindow {
489 label: "Fable".into(),
490 window: w(pct),
491 });
492 s
493 }
494
495 #[test]
496 fn severity_includes_scoped_windows() {
497 let s = with_scoped(snap(10, 55, None, None), 84);
500 assert_eq!(anthropic_severity(&s), PaceSeverity::High);
501 }
502
503 #[test]
504 fn severity_promotes_extra_when_scoped_at_100() {
505 let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
508 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
509 }
510
511 #[test]
512 fn kimi_percent_is_exact_above_f64_precision() {
513 let snap = KimiSnapshot {
514 plan: None,
515 weekly_limit: (1 << 53) + 1,
516 weekly_used: 1 << 52,
517 weekly_remaining: 0,
518 weekly_reset_at: None,
519 window_limit: u64::MAX,
520 window_used: u64::MAX - 1,
521 window_remaining: 0,
522 window_reset_at: None,
523 };
524 assert_eq!(snap.weekly_pct(), 50);
525 assert_eq!(snap.window_pct(), 100);
526 }
527}