1use chrono::{DateTime, Utc};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct UsageWindow {
24 pub utilization_pct: i32,
25 pub resets_at: Option<DateTime<Utc>>,
26 pub window_duration: chrono::Duration,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Cents(pub i64);
33
34impl Cents {
35 pub fn fmt_dollars(self) -> String {
38 let (sign, abs) = if self.0 < 0 {
39 ("-", -self.0)
40 } else {
41 ("", self.0)
42 };
43 format!("{sign}${}.{:02}", abs / 100, abs % 100)
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct AnthropicSnapshot {
51 pub plan: String,
53 pub session: UsageWindow,
54 pub weekly: UsageWindow,
55 pub sonnet: Option<UsageWindow>,
58 pub scoped: Vec<ScopedWindow>,
63 pub extra: Option<ExtraUsage>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ScopedWindow {
71 pub label: String,
72 pub window: UsageWindow,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct ExtraUsage {
78 pub limit: Cents,
79 pub spent: Cents,
80}
81
82impl ExtraUsage {
83 pub fn percent(self) -> i32 {
86 if self.limit.0 <= 0 {
87 0
88 } else {
89 ((self.spent.0 * 100) / self.limit.0) as i32
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq)]
96pub struct DeepseekSnapshot {
97 pub is_available: bool,
98 pub balance: f64,
100 pub granted: f64,
102 pub topped_up: f64,
104 pub currency: String,
106}
107
108impl Eq for DeepseekSnapshot {}
109
110impl Default for DeepseekSnapshot {
111 fn default() -> Self {
112 Self {
113 is_available: false,
114 balance: 0.0,
115 granted: 0.0,
116 topped_up: 0.0,
117 currency: String::new(),
118 }
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct KimiSnapshot {
125 pub plan: Option<String>,
126 pub weekly_limit: u64,
127 pub weekly_used: u64,
128 pub weekly_remaining: u64,
129 pub weekly_reset_at: Option<DateTime<Utc>>,
130 pub window_limit: u64,
131 pub window_used: u64,
132 pub window_remaining: u64,
133 pub window_reset_at: Option<DateTime<Utc>>,
134}
135
136impl KimiSnapshot {
137 fn pct(used: u64, limit: u64) -> i32 {
138 if limit == 0 {
139 0
140 } else {
141 let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
145 pct.min(100) as i32
146 }
147 }
148
149 pub fn weekly_pct(&self) -> i32 {
151 Self::pct(self.weekly_used, self.weekly_limit)
152 }
153
154 pub fn window_pct(&self) -> i32 {
156 Self::pct(self.window_used, self.window_limit)
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum VendorSnapshot {
164 Anthropic(AnthropicSnapshot),
165 Openai(OpenAiSnapshot),
166 Zai(ZaiSnapshot),
167 Openrouter(OpenRouterSnapshot),
168 Deepseek(DeepseekSnapshot),
169 Kimi(KimiSnapshot),
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct OpenAiSnapshot {
175 pub plan: String,
176 pub session: UsageWindow,
178 pub weekly: UsageWindow,
180 pub code_review: Option<UsageWindow>,
182 pub credits: Option<OpenAiCredits>,
184 pub source: OpenAiSource,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum OpenAiSource {
192 CodexOauth,
193 AdminKeyMtd,
194 Unavailable,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct OpenAiCredits {
199 pub balance: String,
202 pub has_credits: bool,
203 pub unlimited: bool,
204 pub approx_local_messages: Option<(i64, i64)>,
205 pub approx_cloud_messages: Option<(i64, i64)>,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct ZaiSnapshot {
212 pub plan: String,
213 pub session: Option<UsageWindow>,
214 pub weekly: Option<UsageWindow>,
215 pub mcp: Option<UsageWindow>,
216}
217
218#[derive(Debug, Clone, PartialEq)]
221pub struct OpenRouterSnapshot {
222 pub label: String,
223 pub total_credits: f64,
224 pub total_usage: f64,
225 pub usage_daily: f64,
226 pub usage_weekly: f64,
227 pub usage_monthly: f64,
228 pub is_free_tier: bool,
229 pub limit: Option<f64>,
230 pub limit_remaining: Option<f64>,
231}
232
233impl Eq for OpenRouterSnapshot {}
234
235impl OpenRouterSnapshot {
236 pub fn balance(&self) -> f64 {
237 (self.total_credits - self.total_usage).max(0.0)
238 }
239 pub fn consumed_pct(&self) -> i32 {
242 if self.total_credits <= 0.0 {
243 return 0;
244 }
245 ((self.total_usage / self.total_credits) * 100.0)
246 .round()
247 .clamp(0.0, 100.0) as i32
248 }
249}
250
251pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
254 let mut max = snap.session.utilization_pct;
255 if snap.weekly.utilization_pct > max {
256 max = snap.weekly.utilization_pct;
257 }
258 if let Some(s) = &snap.sonnet
259 && s.utilization_pct > max
260 {
261 max = s.utilization_pct;
262 }
263 for sw in &snap.scoped {
264 if sw.window.utilization_pct > max {
265 max = sw.window.utilization_pct;
266 }
267 }
268 let any_at_cap = snap.session.utilization_pct >= 100
270 || snap.weekly.utilization_pct >= 100
271 || snap
272 .sonnet
273 .as_ref()
274 .is_some_and(|s| s.utilization_pct >= 100)
275 || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
276 if any_at_cap && let Some(extra) = snap.extra {
277 let p = extra.percent();
278 if p > max {
279 max = p;
280 }
281 }
282 crate::pango::severity_for(max)
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::pacing::PaceSeverity;
289 use chrono::Duration;
290
291 fn w(pct: i32) -> UsageWindow {
292 UsageWindow {
293 utilization_pct: pct,
294 resets_at: None,
295 window_duration: Duration::hours(5),
296 }
297 }
298
299 fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
300 AnthropicSnapshot {
301 plan: "Max 5x".into(),
302 session: w(s),
303 weekly: w(w_),
304 sonnet: sonnet.map(w),
305 scoped: vec![],
306 extra: extra.map(|(limit, spent)| ExtraUsage {
307 limit: Cents(limit),
308 spent: Cents(spent),
309 }),
310 }
311 }
312
313 #[test]
314 fn cents_format_positive() {
315 assert_eq!(Cents(0).fmt_dollars(), "$0.00");
316 assert_eq!(Cents(50).fmt_dollars(), "$0.50");
317 assert_eq!(Cents(250).fmt_dollars(), "$2.50");
318 assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
319 }
320
321 #[test]
322 fn cents_format_negative_uses_leading_sign() {
323 assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
325 assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
326 }
327
328 #[test]
329 fn extra_percent_with_zero_limit_is_zero() {
330 assert_eq!(
331 ExtraUsage {
332 limit: Cents(0),
333 spent: Cents(100)
334 }
335 .percent(),
336 0
337 );
338 }
339
340 #[test]
341 fn extra_percent_truncates() {
342 assert_eq!(
344 ExtraUsage {
345 limit: Cents(10000),
346 spent: Cents(3333)
347 }
348 .percent(),
349 33
350 );
351 }
352
353 #[test]
354 fn severity_picks_worst_of_three_windows() {
355 let s = snap(40, 60, Some(80), None);
356 assert_eq!(anthropic_severity(&s), PaceSeverity::High); }
358
359 #[test]
360 fn severity_ignores_extra_when_no_cap_hit() {
361 let s = snap(50, 60, None, Some((10000, 9500)));
363 assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); }
365
366 #[test]
367 fn severity_promotes_extra_when_session_at_100() {
368 let s = snap(100, 50, None, Some((10000, 9500)));
369 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); }
371
372 #[test]
373 fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
374 let s = snap(100, 50, None, Some((10000, 10000)));
376 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
377 }
378
379 fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
380 s.scoped.push(ScopedWindow {
381 label: "Fable".into(),
382 window: w(pct),
383 });
384 s
385 }
386
387 #[test]
388 fn severity_includes_scoped_windows() {
389 let s = with_scoped(snap(10, 55, None, None), 84);
392 assert_eq!(anthropic_severity(&s), PaceSeverity::High);
393 }
394
395 #[test]
396 fn severity_promotes_extra_when_scoped_at_100() {
397 let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
400 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
401 }
402
403 #[test]
404 fn kimi_percent_is_exact_above_f64_precision() {
405 let snap = KimiSnapshot {
406 plan: None,
407 weekly_limit: (1 << 53) + 1,
408 weekly_used: 1 << 52,
409 weekly_remaining: 0,
410 weekly_reset_at: None,
411 window_limit: u64::MAX,
412 window_used: u64::MAX - 1,
413 window_remaining: 0,
414 window_reset_at: None,
415 };
416 assert_eq!(snap.weekly_pct(), 50);
417 assert_eq!(snap.window_pct(), 100);
418 }
419}