Skip to main content

mj_client/
quota.rs

1//! Quota data and display helpers shared by Mjolnir's control surfaces.
2
3use serde::{Deserialize, Serialize};
4
5use hel::hel_config::HarnessKind;
6
7/// Label used when a harness is billed by API usage rather than a subscription.
8pub const API_LABEL: &str = "API";
9
10/// A quota window reported by a harness.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct QuotaWindow {
13    pub label: String,
14    pub remaining_percent: Option<u8>,
15    pub used: Option<i64>,
16    pub limit: Option<i64>,
17    pub resets: Option<String>,
18    #[serde(default)]
19    pub resets_at_epoch_seconds: Option<i64>,
20}
21
22/// The quota report shown for one harness profile.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct ProfileQuota {
25    pub profile_id: String,
26    pub harness: HarnessKind,
27    pub windows: Vec<QuotaWindow>,
28    pub extra: Option<String>,
29    pub error: Option<String>,
30    pub refreshed_at_epoch_seconds: u64,
31}
32
33impl ProfileQuota {
34    pub fn weekly_window(&self) -> Option<&QuotaWindow> {
35        self.windows
36            .iter()
37            .find(|window| is_weekly_quota_window(&window.label))
38    }
39
40    pub fn five_hour_window(&self) -> Option<&QuotaWindow> {
41        self.windows
42            .iter()
43            .find(|window| is_short_quota_window(&window.label))
44    }
45
46    /// Whether the report says the profile is usage-priced: an API-billed
47    /// harness has no subscription window to fill, so it reports the API label
48    /// in place of one rather than inventing a percentage.
49    pub fn is_usage_priced(&self) -> bool {
50        self.error.is_none() && self.windows.is_empty() && self.extra.as_deref() == Some(API_LABEL)
51    }
52
53    pub fn five_hour_projects_exhaustion(&self) -> bool {
54        self.five_hour_window().is_some_and(|window| {
55            projects_exhaustion_before_reset(window, self.refreshed_at_epoch_seconds)
56        })
57    }
58
59    pub fn compact(&self) -> String {
60        if let Some(error) = &self.error {
61            return quota_error_label(error);
62        }
63        let mut seen_resets = std::collections::BTreeSet::new();
64        let mut parts = self
65            .windows
66            .iter()
67            .filter(|window| {
68                !is_short_quota_window(&window.label)
69                    || projects_exhaustion_before_reset(window, self.refreshed_at_epoch_seconds)
70            })
71            .map(|window| {
72                let usage = match (window.remaining_percent, window.used, window.limit) {
73                    (Some(remaining), _, _) => format!("{remaining}% left"),
74                    (_, Some(used), Some(limit)) => format!("{used}/{limit}"),
75                    _ => "available".to_string(),
76                };
77                match window
78                    .resets
79                    .as_ref()
80                    .filter(|reset| seen_resets.insert((*reset).clone()))
81                {
82                    Some(reset) => format!("{} {usage}, resets {reset}", window.label),
83                    None => format!("{} {usage}", window.label),
84                }
85            })
86            .collect::<Vec<_>>();
87        if let Some(extra) = &self.extra {
88            parts.push(extra.clone());
89        }
90        if parts.is_empty() {
91            "no quota windows reported".to_string()
92        } else {
93            parts.join(" ยท ")
94        }
95    }
96
97    pub fn error_label(&self) -> Option<String> {
98        self.error.as_deref().map(quota_error_label)
99    }
100}
101
102fn quota_error_label(error: &str) -> String {
103    // This is the stable user-facing marker emitted by the Claude usage
104    // adapter. Keep the display contract independent of the controller crate.
105    if error == "login expired" {
106        error.to_string()
107    } else if error.starts_with("rate limited") {
108        // The provider is throttling the usage endpoint, which is not the same
109        // as the quota being unknown for good.
110        "rate limited".to_string()
111    } else {
112        "unavailable".to_string()
113    }
114}
115
116/// The dashboard's long-window column. A harness billed monthly rather than
117/// weekly belongs in the same column; the label itself names the real period.
118fn is_weekly_quota_window(label: &str) -> bool {
119    matches!(
120        label.to_ascii_lowercase().as_str(),
121        "week" | "weekly" | "7d" | "month" | "monthly"
122    )
123}
124
125fn is_short_quota_window(label: &str) -> bool {
126    matches!(
127        label.to_ascii_lowercase().as_str(),
128        "5h" | "5-hour" | "5 hour"
129    )
130}
131
132/// Whether this window is on course to run out before it resets.
133#[must_use]
134pub fn projects_exhaustion(window: &QuotaWindow, now: u64) -> bool {
135    projects_exhaustion_before_reset(window, now)
136}
137
138fn projects_exhaustion_before_reset(window: &QuotaWindow, now: u64) -> bool {
139    const FIVE_HOURS_SECONDS: i64 = 5 * 60 * 60;
140    let Some(reset) = window.resets_at_epoch_seconds else {
141        return false;
142    };
143    let Ok(now) = i64::try_from(now) else {
144        return false;
145    };
146    let remaining_time = reset - now;
147    let elapsed = FIVE_HOURS_SECONDS - remaining_time;
148    if remaining_time <= 0 || elapsed <= 0 || elapsed >= FIVE_HOURS_SECONDS {
149        return false;
150    }
151    if let (Some(used), Some(limit)) = (window.used, window.limit)
152        && limit > 0
153    {
154        return i128::from(used.clamp(0, limit)) * i128::from(FIVE_HOURS_SECONDS)
155            > i128::from(limit) * i128::from(elapsed);
156    }
157    window
158        .remaining_percent
159        .is_some_and(|remaining| i64::from(100 - remaining) * FIVE_HOURS_SECONDS > 100 * elapsed)
160}