1use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6
7use mj_core::config::HarnessKind;
8
9pub const API_LABEL: &str = "API";
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct QuotaWindow {
15 pub label: String,
16 pub remaining_percent: Option<u8>,
17 pub used: Option<i64>,
18 pub limit: Option<i64>,
19 pub resets: Option<String>,
20 #[serde(default)]
21 pub resets_at_epoch_seconds: Option<i64>,
22}
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
27pub struct QuotaSnapshot {
28 pub reports: BTreeMap<String, ProfileQuota>,
30 pub probing: BTreeSet<String>,
32 pub cycles: u64,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct ProfileQuota {
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub banked_resets: Option<u64>,
44 pub profile_id: String,
45 pub harness: HarnessKind,
46 pub windows: Vec<QuotaWindow>,
47 pub extra: Option<String>,
48 pub error: Option<String>,
49 pub refreshed_at_epoch_seconds: u64,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub rate_limited_until_epoch_seconds: Option<u64>,
56}
57
58impl ProfileQuota {
59 pub fn rate_limit_label(&self, now: u64) -> Option<String> {
61 let until = self
62 .rate_limited_until_epoch_seconds
63 .filter(|until| *until > now)?;
64 Some(format!(
65 "rate limited · retry in {} min",
66 (until - now).div_ceil(60)
67 ))
68 }
69
70 pub fn weekly_window(&self) -> Option<&QuotaWindow> {
71 self.windows
72 .iter()
73 .find(|window| is_weekly_quota_window(&window.label))
74 }
75
76 pub fn five_hour_window(&self) -> Option<&QuotaWindow> {
77 self.windows
78 .iter()
79 .find(|window| is_short_quota_window(&window.label))
80 }
81
82 pub fn is_usage_priced(&self) -> bool {
86 self.error.is_none() && self.windows.is_empty() && self.extra.as_deref() == Some(API_LABEL)
87 }
88
89 pub fn five_hour_projects_exhaustion(&self) -> bool {
90 self.five_hour_window().is_some_and(|window| {
91 projects_exhaustion_before_reset(window, self.refreshed_at_epoch_seconds)
92 })
93 }
94
95 pub fn compact(&self) -> String {
96 if let Some(error) = &self.error {
97 return quota_error_label(error);
98 }
99 let mut seen_resets = std::collections::BTreeSet::new();
100 let mut parts = self
101 .windows
102 .iter()
103 .filter(|window| {
104 !is_short_quota_window(&window.label)
105 || projects_exhaustion_before_reset(window, self.refreshed_at_epoch_seconds)
106 })
107 .map(|window| {
108 let usage = match (window.remaining_percent, window.used, window.limit) {
109 (Some(remaining), _, _) => format!("{remaining}% left"),
110 (_, Some(used), Some(limit)) => format!("{used}/{limit}"),
111 _ => "available".to_string(),
112 };
113 match window
114 .resets
115 .as_ref()
116 .filter(|reset| seen_resets.insert((*reset).clone()))
117 {
118 Some(reset) => format!("{} {usage}, resets {reset}", window.label),
119 None => format!("{} {usage}", window.label),
120 }
121 })
122 .collect::<Vec<_>>();
123 if let Some(extra) = &self.extra {
124 parts.push(extra.clone());
125 }
126 if parts.is_empty() {
127 "no quota windows reported".to_string()
128 } else {
129 parts.join(" · ")
130 }
131 }
132
133 pub fn error_label(&self) -> Option<String> {
134 self.error.as_deref().map(quota_error_label)
135 }
136}
137
138fn quota_error_label(error: &str) -> String {
139 if error == "login expired" {
142 error.to_string()
143 } else if error.starts_with("rate limited") {
144 "rate limited".to_string()
147 } else {
148 "unavailable".to_string()
149 }
150}
151
152fn is_weekly_quota_window(label: &str) -> bool {
155 matches!(
156 label.to_ascii_lowercase().as_str(),
157 "week" | "weekly" | "7d" | "month" | "monthly"
158 )
159}
160
161fn is_short_quota_window(label: &str) -> bool {
162 matches!(
163 label.to_ascii_lowercase().as_str(),
164 "5h" | "5-hour" | "5 hour"
165 )
166}
167
168#[must_use]
170pub fn projects_exhaustion(window: &QuotaWindow, now: u64) -> bool {
171 projects_exhaustion_before_reset(window, now)
172}
173
174fn projects_exhaustion_before_reset(window: &QuotaWindow, now: u64) -> bool {
175 const FIVE_HOURS_SECONDS: i64 = 5 * 60 * 60;
176 let Some(reset) = window.resets_at_epoch_seconds else {
177 return false;
178 };
179 let Ok(now) = i64::try_from(now) else {
180 return false;
181 };
182 let remaining_time = reset - now;
183 let elapsed = FIVE_HOURS_SECONDS - remaining_time;
184 if remaining_time <= 0 || elapsed <= 0 || elapsed >= FIVE_HOURS_SECONDS {
185 return false;
186 }
187 if let (Some(used), Some(limit)) = (window.used, window.limit)
188 && limit > 0
189 {
190 return i128::from(used.clamp(0, limit)) * i128::from(FIVE_HOURS_SECONDS)
191 > i128::from(limit) * i128::from(elapsed);
192 }
193 window
194 .remaining_percent
195 .is_some_and(|remaining| i64::from(100 - remaining) * FIVE_HOURS_SECONDS > 100 * elapsed)
196}
197
198#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum ResetCountdownStyle {
202 #[default]
203 Long,
204 FiveHour,
205}
206
207impl QuotaWindow {
208 pub fn reset_countdown_style(&self) -> ResetCountdownStyle {
209 if is_short_quota_window(&self.label) {
210 ResetCountdownStyle::FiveHour
211 } else {
212 ResetCountdownStyle::Long
213 }
214 }
215
216 pub fn reset_display(&self, now: u64, banked_resets: Option<u64>) -> String {
217 format_quota_reset(
218 now,
219 self.resets_at_epoch_seconds,
220 self.resets.as_deref(),
221 self.reset_countdown_style(),
222 banked_resets,
223 )
224 }
225}
226
227impl ProfileQuota {
228 pub fn banked_resets_for_window(&self, window: &QuotaWindow) -> Option<u64> {
230 self.banked_resets.filter(|_| {
231 self.weekly_window()
232 .is_some_and(|weekly| weekly.label == window.label)
233 })
234 }
235}
236
237pub fn format_quota_reset(
240 now: u64,
241 reset: Option<i64>,
242 fallback: Option<&str>,
243 style: ResetCountdownStyle,
244 banked_resets: Option<u64>,
245) -> String {
246 let mut display = match reset {
247 Some(reset) => match style {
248 ResetCountdownStyle::Long => quota_reset_countdown(now, reset),
249 ResetCountdownStyle::FiveHour => five_hour_quota_reset_countdown(now, reset),
250 },
251 None => fallback.unwrap_or_default().to_owned(),
252 };
253 if let Some(count) = banked_resets.filter(|count| *count > 0) {
254 if !display.is_empty() {
255 display.push(' ');
256 }
257 display.push_str(&format!("[{count}]"));
258 }
259 display
260}
261
262pub fn quota_reset_countdown(now: u64, reset_at_epoch_seconds: i64) -> String {
263 let Ok(reset) = u64::try_from(reset_at_epoch_seconds) else {
264 return "now".into();
265 };
266 let remaining = reset.saturating_sub(now);
267 if remaining == 0 {
268 return "now".into();
269 }
270
271 const MINUTE: u64 = 60;
272 const HOUR: u64 = 60 * MINUTE;
273 const DAY: u64 = 24 * HOUR;
274 if remaining >= DAY {
275 let days = remaining / DAY;
276 let hours = remaining % DAY / HOUR;
277 format!("{days}d {hours}h")
278 } else if remaining >= HOUR {
279 let hours = remaining / HOUR;
280 let minutes = remaining % HOUR / MINUTE;
281 if hours < 10 && minutes > 0 {
283 format!("{hours}h {minutes}m")
284 } else {
285 format!("{hours}h")
286 }
287 } else if remaining >= MINUTE {
288 format!("{}m", remaining / MINUTE)
289 } else {
290 "<1m".into()
291 }
292}
293
294pub fn five_hour_quota_reset_countdown(now: u64, reset_at_epoch_seconds: i64) -> String {
295 let Ok(reset) = u64::try_from(reset_at_epoch_seconds) else {
296 return "now".into();
297 };
298 let remaining = reset.saturating_sub(now);
299 if remaining == 0 {
300 "now".into()
301 } else if remaining < 60 {
302 "<1m".into()
303 } else if remaining < 60 * 60 {
304 format!("{}m", remaining / 60)
305 } else {
306 let hours = remaining / (60 * 60);
307 let minutes = remaining % (60 * 60) / 60;
308 format!("{hours}h {minutes}m")
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn reset_display_matches_shared_browser_cases() {
318 #[derive(Deserialize)]
319 struct Case {
320 name: String,
321 now: u64,
322 reset: Option<i64>,
323 fallback: Option<String>,
324 style: ResetCountdownStyle,
325 banked_resets: Option<u64>,
326 expected: String,
327 }
328 let cases: Vec<Case> =
329 serde_json::from_str(include_str!("quota/reset_display_cases.json")).unwrap();
330 for case in cases {
331 assert_eq!(
332 format_quota_reset(
333 case.now,
334 case.reset,
335 case.fallback.as_deref(),
336 case.style,
337 case.banked_resets
338 ),
339 case.expected,
340 "{}",
341 case.name
342 );
343 }
344 }
345
346 #[test]
347 fn a_rate_limit_hold_reads_as_minutes_until_the_retry_and_ends_with_the_hold() {
348 let mut quota: ProfileQuota = serde_json::from_value(serde_json::json!({
349 "profile_id": "claude", "harness": "claude", "windows": [],
350 "extra": null, "error": null, "refreshed_at_epoch_seconds": 0,
351 "rate_limited_until_epoch_seconds": 1000 + 15 * 60
352 }))
353 .unwrap();
354 assert_eq!(
355 quota.rate_limit_label(1000).as_deref(),
356 Some("rate limited · retry in 15 min")
357 );
358 assert_eq!(
359 quota.rate_limit_label(1000 + 15 * 60 - 1).as_deref(),
360 Some("rate limited · retry in 1 min")
361 );
362 assert_eq!(quota.rate_limit_label(1000 + 15 * 60), None);
363 quota.rate_limited_until_epoch_seconds = None;
364 assert!(
365 serde_json::to_value("a)
366 .unwrap()
367 .get("rate_limited_until_epoch_seconds")
368 .is_none()
369 );
370 }
371
372 #[test]
373 fn old_quota_reports_decode_without_a_banked_balance() {
374 let quota: ProfileQuota = serde_json::from_value(serde_json::json!({
375 "profile_id": "claude", "harness": "claude", "windows": [],
376 "extra": null, "error": null, "refreshed_at_epoch_seconds": 0
377 }))
378 .unwrap();
379 assert_eq!(quota.banked_resets, None);
380 assert!(
381 serde_json::to_value("a)
382 .unwrap()
383 .get("banked_resets")
384 .is_none()
385 );
386 }
387}