Skip to main content

mj_client/
quota.rs

1//! Quota data and display helpers shared by Mjolnir's control surfaces.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6
7use mj_core::config::HarnessKind;
8
9/// Label used when a harness is billed by API usage rather than a subscription.
10pub const API_LABEL: &str = "API";
11
12/// A quota window reported by a harness.
13#[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/// What the daemon publishes about quota. The daemon is the only process that
25/// asks a provider for quota; every surface reads this instead.
26#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
27pub struct QuotaSnapshot {
28    /// The latest report for each enabled profile.
29    pub reports: BTreeMap<String, ProfileQuota>,
30    /// Profiles the daemon is asking a provider about right now.
31    pub probing: BTreeSet<String>,
32    /// Probe cycles the daemon has finished. A surface that asked for a
33    /// refresh remembers the value it saw and knows the refresh is over when
34    /// this grows.
35    pub cycles: u64,
36}
37
38/// The quota report shown for one harness profile.
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct ProfileQuota {
41    /// Unused provider-granted resets, absent when the provider cannot report them.
42    #[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    /// The provider answered 429 (too many requests): the daemon will not
51    /// probe this profile again before this time. The windows, when there are
52    /// any, are the last good reading, and `refreshed_at_epoch_seconds` is
53    /// when it was taken.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub rate_limited_until_epoch_seconds: Option<u64>,
56}
57
58impl ProfileQuota {
59    /// `rate limited · retry in N min` while the provider's hold lasts.
60    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    /// Whether the report says the profile is usage-priced: an API-billed
83    /// harness has no subscription window to fill, so it reports the API label
84    /// in place of one rather than inventing a percentage.
85    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    // This is the stable user-facing marker emitted by the Claude usage
140    // adapter. Keep the display contract independent of the controller crate.
141    if error == "login expired" {
142        error.to_string()
143    } else if error.starts_with("rate limited") {
144        // The provider is throttling the usage endpoint, which is not the same
145        // as the quota being unknown for good.
146        "rate limited".to_string()
147    } else {
148        "unavailable".to_string()
149    }
150}
151
152/// The dashboard's long-window column. A harness billed monthly rather than
153/// weekly belongs in the same column; the label itself names the real period.
154fn 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/// Whether this window is on course to run out before it resets.
169#[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/// Which existing TUI countdown convention applies to a quota window.
199#[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    /// Only the long quota window carries the account's banked reset balance.
229    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
237/// Render a reset time and its optional banked balance. The browser equivalent
238/// is checked against the same examples in quota/reset_display_cases.json.
239pub 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        // Under ten hours the minutes decide whether to wait, so show them.
282        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(&quota)
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(&quota)
382                .unwrap()
383                .get("banked_resets")
384                .is_none()
385        );
386    }
387}