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 extra: Option<ExtraUsage>,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct ExtraUsage {
64 pub limit: Cents,
65 pub spent: Cents,
66}
67
68impl ExtraUsage {
69 pub fn percent(self) -> i32 {
72 if self.limit.0 <= 0 {
73 0
74 } else {
75 ((self.spent.0 * 100) / self.limit.0) as i32
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq)]
82pub struct DeepseekSnapshot {
83 pub is_available: bool,
84 pub balance: f64,
86 pub granted: f64,
88 pub topped_up: f64,
90 pub currency: String,
92}
93
94impl Eq for DeepseekSnapshot {}
95
96impl Default for DeepseekSnapshot {
97 fn default() -> Self {
98 Self {
99 is_available: false,
100 balance: 0.0,
101 granted: 0.0,
102 topped_up: 0.0,
103 currency: String::new(),
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum VendorSnapshot {
112 Anthropic(AnthropicSnapshot),
113 Openai(OpenAiSnapshot),
114 Zai(ZaiSnapshot),
115 Openrouter(OpenRouterSnapshot),
116 Deepseek(DeepseekSnapshot),
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct OpenAiSnapshot {
122 pub plan: String,
123 pub session: UsageWindow,
125 pub weekly: UsageWindow,
127 pub code_review: Option<UsageWindow>,
129 pub credits: Option<OpenAiCredits>,
131 pub source: OpenAiSource,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum OpenAiSource {
139 CodexOauth,
140 AdminKeyMtd,
141 Unavailable,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct OpenAiCredits {
146 pub balance: String,
149 pub has_credits: bool,
150 pub unlimited: bool,
151 pub approx_local_messages: Option<(i64, i64)>,
152 pub approx_cloud_messages: Option<(i64, i64)>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct ZaiSnapshot {
159 pub plan: String,
160 pub session: Option<UsageWindow>,
161 pub weekly: Option<UsageWindow>,
162 pub mcp: Option<UsageWindow>,
163}
164
165#[derive(Debug, Clone, PartialEq)]
168pub struct OpenRouterSnapshot {
169 pub label: String,
170 pub total_credits: f64,
171 pub total_usage: f64,
172 pub usage_daily: f64,
173 pub usage_weekly: f64,
174 pub usage_monthly: f64,
175 pub is_free_tier: bool,
176 pub limit: Option<f64>,
177 pub limit_remaining: Option<f64>,
178}
179
180impl Eq for OpenRouterSnapshot {}
181
182impl OpenRouterSnapshot {
183 pub fn balance(&self) -> f64 {
184 (self.total_credits - self.total_usage).max(0.0)
185 }
186 pub fn consumed_pct(&self) -> i32 {
189 if self.total_credits <= 0.0 {
190 return 0;
191 }
192 ((self.total_usage / self.total_credits) * 100.0)
193 .round()
194 .clamp(0.0, 100.0) as i32
195 }
196}
197
198pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
201 let mut max = snap.session.utilization_pct;
202 if snap.weekly.utilization_pct > max {
203 max = snap.weekly.utilization_pct;
204 }
205 if let Some(s) = &snap.sonnet
206 && s.utilization_pct > max
207 {
208 max = s.utilization_pct;
209 }
210 let any_at_cap = snap.session.utilization_pct >= 100
212 || snap.weekly.utilization_pct >= 100
213 || snap
214 .sonnet
215 .as_ref()
216 .is_some_and(|s| s.utilization_pct >= 100);
217 if any_at_cap && let Some(extra) = snap.extra {
218 let p = extra.percent();
219 if p > max {
220 max = p;
221 }
222 }
223 crate::pango::severity_for(max)
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::pacing::PaceSeverity;
230 use chrono::Duration;
231
232 fn w(pct: i32) -> UsageWindow {
233 UsageWindow {
234 utilization_pct: pct,
235 resets_at: None,
236 window_duration: Duration::hours(5),
237 }
238 }
239
240 fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
241 AnthropicSnapshot {
242 plan: "Max 5x".into(),
243 session: w(s),
244 weekly: w(w_),
245 sonnet: sonnet.map(w),
246 extra: extra.map(|(limit, spent)| ExtraUsage {
247 limit: Cents(limit),
248 spent: Cents(spent),
249 }),
250 }
251 }
252
253 #[test]
254 fn cents_format_positive() {
255 assert_eq!(Cents(0).fmt_dollars(), "$0.00");
256 assert_eq!(Cents(50).fmt_dollars(), "$0.50");
257 assert_eq!(Cents(250).fmt_dollars(), "$2.50");
258 assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
259 }
260
261 #[test]
262 fn cents_format_negative_uses_leading_sign() {
263 assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
265 assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
266 }
267
268 #[test]
269 fn extra_percent_with_zero_limit_is_zero() {
270 assert_eq!(
271 ExtraUsage {
272 limit: Cents(0),
273 spent: Cents(100)
274 }
275 .percent(),
276 0
277 );
278 }
279
280 #[test]
281 fn extra_percent_truncates() {
282 assert_eq!(
284 ExtraUsage {
285 limit: Cents(10000),
286 spent: Cents(3333)
287 }
288 .percent(),
289 33
290 );
291 }
292
293 #[test]
294 fn severity_picks_worst_of_three_windows() {
295 let s = snap(40, 60, Some(80), None);
296 assert_eq!(anthropic_severity(&s), PaceSeverity::High); }
298
299 #[test]
300 fn severity_ignores_extra_when_no_cap_hit() {
301 let s = snap(50, 60, None, Some((10000, 9500)));
303 assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); }
305
306 #[test]
307 fn severity_promotes_extra_when_session_at_100() {
308 let s = snap(100, 50, None, Some((10000, 9500)));
309 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); }
311
312 #[test]
313 fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
314 let s = snap(100, 50, None, Some((10000, 10000)));
316 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
317 }
318}