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 if s.utilization_pct > max {
207 max = s.utilization_pct;
208 }
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 {
218 if let Some(extra) = snap.extra {
219 let p = extra.percent();
220 if p > max {
221 max = p;
222 }
223 }
224 }
225 crate::pango::severity_for(max)
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::pacing::PaceSeverity;
232 use chrono::Duration;
233
234 fn w(pct: i32) -> UsageWindow {
235 UsageWindow {
236 utilization_pct: pct,
237 resets_at: None,
238 window_duration: Duration::hours(5),
239 }
240 }
241
242 fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
243 AnthropicSnapshot {
244 plan: "Max 5x".into(),
245 session: w(s),
246 weekly: w(w_),
247 sonnet: sonnet.map(w),
248 extra: extra.map(|(limit, spent)| ExtraUsage {
249 limit: Cents(limit),
250 spent: Cents(spent),
251 }),
252 }
253 }
254
255 #[test]
256 fn cents_format_positive() {
257 assert_eq!(Cents(0).fmt_dollars(), "$0.00");
258 assert_eq!(Cents(50).fmt_dollars(), "$0.50");
259 assert_eq!(Cents(250).fmt_dollars(), "$2.50");
260 assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
261 }
262
263 #[test]
264 fn cents_format_negative_uses_leading_sign() {
265 assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
267 assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
268 }
269
270 #[test]
271 fn extra_percent_with_zero_limit_is_zero() {
272 assert_eq!(
273 ExtraUsage {
274 limit: Cents(0),
275 spent: Cents(100)
276 }
277 .percent(),
278 0
279 );
280 }
281
282 #[test]
283 fn extra_percent_truncates() {
284 assert_eq!(
286 ExtraUsage {
287 limit: Cents(10000),
288 spent: Cents(3333)
289 }
290 .percent(),
291 33
292 );
293 }
294
295 #[test]
296 fn severity_picks_worst_of_three_windows() {
297 let s = snap(40, 60, Some(80), None);
298 assert_eq!(anthropic_severity(&s), PaceSeverity::High); }
300
301 #[test]
302 fn severity_ignores_extra_when_no_cap_hit() {
303 let s = snap(50, 60, None, Some((10000, 9500)));
305 assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); }
307
308 #[test]
309 fn severity_promotes_extra_when_session_at_100() {
310 let s = snap(100, 50, None, Some((10000, 9500)));
311 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); }
313
314 #[test]
315 fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
316 let s = snap(100, 50, None, Some((10000, 10000)));
318 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
319 }
320}