1use chrono::{DateTime, Utc};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct UsageWindow {
23 pub utilization_pct: i32,
24 pub resets_at: Option<DateTime<Utc>>,
25 pub window_duration: chrono::Duration,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct Cents(pub i64);
32
33impl Cents {
34 pub fn fmt_dollars(self) -> String {
37 let (sign, abs) = if self.0 < 0 {
38 ("-", -self.0)
39 } else {
40 ("", self.0)
41 };
42 format!("{sign}${}.{:02}", abs / 100, abs % 100)
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct AnthropicSnapshot {
50 pub plan: String,
52 pub session: UsageWindow,
53 pub weekly: UsageWindow,
54 pub sonnet: Option<UsageWindow>,
57 pub scoped: Vec<ScopedWindow>,
62 pub extra: Option<ExtraUsage>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ScopedWindow {
70 pub label: String,
71 pub window: UsageWindow,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct ExtraUsage {
77 pub limit: Cents,
78 pub spent: Cents,
79}
80
81impl ExtraUsage {
82 pub fn percent(self) -> i32 {
85 if self.limit.0 <= 0 {
86 0
87 } else {
88 ((self.spent.0 * 100) / self.limit.0) as i32
89 }
90 }
91}
92
93#[derive(Debug, Clone, PartialEq)]
95pub struct DeepseekSnapshot {
96 pub is_available: bool,
97 pub balance: f64,
99 pub granted: f64,
101 pub topped_up: f64,
103 pub currency: String,
105}
106
107impl Eq for DeepseekSnapshot {}
108
109impl Default for DeepseekSnapshot {
110 fn default() -> Self {
111 Self {
112 is_available: false,
113 balance: 0.0,
114 granted: 0.0,
115 topped_up: 0.0,
116 currency: String::new(),
117 }
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum VendorSnapshot {
125 Anthropic(AnthropicSnapshot),
126 Openai(OpenAiSnapshot),
127 Zai(ZaiSnapshot),
128 Openrouter(OpenRouterSnapshot),
129 Deepseek(DeepseekSnapshot),
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct OpenAiSnapshot {
135 pub plan: String,
136 pub session: UsageWindow,
138 pub weekly: UsageWindow,
140 pub code_review: Option<UsageWindow>,
142 pub credits: Option<OpenAiCredits>,
144 pub source: OpenAiSource,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum OpenAiSource {
152 CodexOauth,
153 AdminKeyMtd,
154 Unavailable,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct OpenAiCredits {
159 pub balance: String,
162 pub has_credits: bool,
163 pub unlimited: bool,
164 pub approx_local_messages: Option<(i64, i64)>,
165 pub approx_cloud_messages: Option<(i64, i64)>,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct ZaiSnapshot {
172 pub plan: String,
173 pub session: Option<UsageWindow>,
174 pub weekly: Option<UsageWindow>,
175 pub mcp: Option<UsageWindow>,
176}
177
178#[derive(Debug, Clone, PartialEq)]
181pub struct OpenRouterSnapshot {
182 pub label: String,
183 pub total_credits: f64,
184 pub total_usage: f64,
185 pub usage_daily: f64,
186 pub usage_weekly: f64,
187 pub usage_monthly: f64,
188 pub is_free_tier: bool,
189 pub limit: Option<f64>,
190 pub limit_remaining: Option<f64>,
191}
192
193impl Eq for OpenRouterSnapshot {}
194
195impl OpenRouterSnapshot {
196 pub fn balance(&self) -> f64 {
197 (self.total_credits - self.total_usage).max(0.0)
198 }
199 pub fn consumed_pct(&self) -> i32 {
202 if self.total_credits <= 0.0 {
203 return 0;
204 }
205 ((self.total_usage / self.total_credits) * 100.0)
206 .round()
207 .clamp(0.0, 100.0) as i32
208 }
209}
210
211pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
214 let mut max = snap.session.utilization_pct;
215 if snap.weekly.utilization_pct > max {
216 max = snap.weekly.utilization_pct;
217 }
218 if let Some(s) = &snap.sonnet
219 && s.utilization_pct > max
220 {
221 max = s.utilization_pct;
222 }
223 for sw in &snap.scoped {
224 if sw.window.utilization_pct > max {
225 max = sw.window.utilization_pct;
226 }
227 }
228 let any_at_cap = snap.session.utilization_pct >= 100
230 || snap.weekly.utilization_pct >= 100
231 || snap
232 .sonnet
233 .as_ref()
234 .is_some_and(|s| s.utilization_pct >= 100)
235 || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
236 if any_at_cap && let Some(extra) = snap.extra {
237 let p = extra.percent();
238 if p > max {
239 max = p;
240 }
241 }
242 crate::pango::severity_for(max)
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use crate::pacing::PaceSeverity;
249 use chrono::Duration;
250
251 fn w(pct: i32) -> UsageWindow {
252 UsageWindow {
253 utilization_pct: pct,
254 resets_at: None,
255 window_duration: Duration::hours(5),
256 }
257 }
258
259 fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
260 AnthropicSnapshot {
261 plan: "Max 5x".into(),
262 session: w(s),
263 weekly: w(w_),
264 sonnet: sonnet.map(w),
265 scoped: vec![],
266 extra: extra.map(|(limit, spent)| ExtraUsage {
267 limit: Cents(limit),
268 spent: Cents(spent),
269 }),
270 }
271 }
272
273 #[test]
274 fn cents_format_positive() {
275 assert_eq!(Cents(0).fmt_dollars(), "$0.00");
276 assert_eq!(Cents(50).fmt_dollars(), "$0.50");
277 assert_eq!(Cents(250).fmt_dollars(), "$2.50");
278 assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
279 }
280
281 #[test]
282 fn cents_format_negative_uses_leading_sign() {
283 assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
285 assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
286 }
287
288 #[test]
289 fn extra_percent_with_zero_limit_is_zero() {
290 assert_eq!(
291 ExtraUsage {
292 limit: Cents(0),
293 spent: Cents(100)
294 }
295 .percent(),
296 0
297 );
298 }
299
300 #[test]
301 fn extra_percent_truncates() {
302 assert_eq!(
304 ExtraUsage {
305 limit: Cents(10000),
306 spent: Cents(3333)
307 }
308 .percent(),
309 33
310 );
311 }
312
313 #[test]
314 fn severity_picks_worst_of_three_windows() {
315 let s = snap(40, 60, Some(80), None);
316 assert_eq!(anthropic_severity(&s), PaceSeverity::High); }
318
319 #[test]
320 fn severity_ignores_extra_when_no_cap_hit() {
321 let s = snap(50, 60, None, Some((10000, 9500)));
323 assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); }
325
326 #[test]
327 fn severity_promotes_extra_when_session_at_100() {
328 let s = snap(100, 50, None, Some((10000, 9500)));
329 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); }
331
332 #[test]
333 fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
334 let s = snap(100, 50, None, Some((10000, 10000)));
336 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
337 }
338
339 fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
340 s.scoped.push(ScopedWindow {
341 label: "Fable".into(),
342 window: w(pct),
343 });
344 s
345 }
346
347 #[test]
348 fn severity_includes_scoped_windows() {
349 let s = with_scoped(snap(10, 55, None, None), 84);
352 assert_eq!(anthropic_severity(&s), PaceSeverity::High);
353 }
354
355 #[test]
356 fn severity_promotes_extra_when_scoped_at_100() {
357 let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
360 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
361 }
362}