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, Eq)]
83pub enum VendorSnapshot {
84 Anthropic(AnthropicSnapshot),
85 Openai(OpenAiSnapshot),
86 Zai(ZaiSnapshot),
87 Openrouter(OpenRouterSnapshot),
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct OpenAiSnapshot {
93 pub plan: String,
94 pub session: UsageWindow,
96 pub weekly: UsageWindow,
98 pub code_review: Option<UsageWindow>,
100 pub credits: Option<OpenAiCredits>,
102 pub source: OpenAiSource,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum OpenAiSource {
110 CodexOauth,
111 AdminKeyMtd,
112 Unavailable,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct OpenAiCredits {
117 pub balance: String,
120 pub has_credits: bool,
121 pub unlimited: bool,
122 pub approx_local_messages: Option<(i64, i64)>,
123 pub approx_cloud_messages: Option<(i64, i64)>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct ZaiSnapshot {
130 pub plan: String,
131 pub session: Option<UsageWindow>,
132 pub weekly: Option<UsageWindow>,
133 pub mcp: Option<UsageWindow>,
134}
135
136#[derive(Debug, Clone, PartialEq)]
139pub struct OpenRouterSnapshot {
140 pub label: String,
141 pub total_credits: f64,
142 pub total_usage: f64,
143 pub usage_daily: f64,
144 pub usage_weekly: f64,
145 pub usage_monthly: f64,
146 pub is_free_tier: bool,
147 pub limit: Option<f64>,
148 pub limit_remaining: Option<f64>,
149}
150
151impl Eq for OpenRouterSnapshot {}
152
153impl OpenRouterSnapshot {
154 pub fn balance(&self) -> f64 {
155 (self.total_credits - self.total_usage).max(0.0)
156 }
157 pub fn consumed_pct(&self) -> i32 {
160 if self.total_credits <= 0.0 {
161 return 0;
162 }
163 ((self.total_usage / self.total_credits) * 100.0)
164 .round()
165 .clamp(0.0, 100.0) as i32
166 }
167}
168
169pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
172 let mut max = snap.session.utilization_pct;
173 if snap.weekly.utilization_pct > max {
174 max = snap.weekly.utilization_pct;
175 }
176 if let Some(s) = &snap.sonnet {
177 if s.utilization_pct > max {
178 max = s.utilization_pct;
179 }
180 }
181 let any_at_cap = snap.session.utilization_pct >= 100
183 || snap.weekly.utilization_pct >= 100
184 || snap
185 .sonnet
186 .as_ref()
187 .is_some_and(|s| s.utilization_pct >= 100);
188 if any_at_cap {
189 if let Some(extra) = snap.extra {
190 let p = extra.percent();
191 if p > max {
192 max = p;
193 }
194 }
195 }
196 crate::pango::severity_for(max)
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::pacing::PaceSeverity;
203 use chrono::Duration;
204
205 fn w(pct: i32) -> UsageWindow {
206 UsageWindow {
207 utilization_pct: pct,
208 resets_at: None,
209 window_duration: Duration::hours(5),
210 }
211 }
212
213 fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
214 AnthropicSnapshot {
215 plan: "Max 5x".into(),
216 session: w(s),
217 weekly: w(w_),
218 sonnet: sonnet.map(w),
219 extra: extra.map(|(limit, spent)| ExtraUsage {
220 limit: Cents(limit),
221 spent: Cents(spent),
222 }),
223 }
224 }
225
226 #[test]
227 fn cents_format_positive() {
228 assert_eq!(Cents(0).fmt_dollars(), "$0.00");
229 assert_eq!(Cents(50).fmt_dollars(), "$0.50");
230 assert_eq!(Cents(250).fmt_dollars(), "$2.50");
231 assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
232 }
233
234 #[test]
235 fn cents_format_negative_uses_leading_sign() {
236 assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
238 assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
239 }
240
241 #[test]
242 fn extra_percent_with_zero_limit_is_zero() {
243 assert_eq!(
244 ExtraUsage {
245 limit: Cents(0),
246 spent: Cents(100)
247 }
248 .percent(),
249 0
250 );
251 }
252
253 #[test]
254 fn extra_percent_truncates() {
255 assert_eq!(
257 ExtraUsage {
258 limit: Cents(10000),
259 spent: Cents(3333)
260 }
261 .percent(),
262 33
263 );
264 }
265
266 #[test]
267 fn severity_picks_worst_of_three_windows() {
268 let s = snap(40, 60, Some(80), None);
269 assert_eq!(anthropic_severity(&s), PaceSeverity::High); }
271
272 #[test]
273 fn severity_ignores_extra_when_no_cap_hit() {
274 let s = snap(50, 60, None, Some((10000, 9500)));
276 assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); }
278
279 #[test]
280 fn severity_promotes_extra_when_session_at_100() {
281 let s = snap(100, 50, None, Some((10000, 9500)));
282 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); }
284
285 #[test]
286 fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
287 let s = snap(100, 50, None, Some((10000, 10000)));
289 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
290 }
291}