Skip to main content

codex_cli/rate_limits/
render.rs

1use chrono::{Local, TimeZone};
2use serde_json::Value;
3
4pub struct UsageData {
5    pub primary: Window,
6    pub secondary: Window,
7}
8
9pub struct Window {
10    pub limit_window_seconds: i64,
11    pub used_percent: f64,
12    pub reset_at: i64,
13}
14
15pub struct RenderValues {
16    pub primary_label: String,
17    pub secondary_label: String,
18    pub primary_remaining: i64,
19    pub secondary_remaining: i64,
20    pub primary_reset_epoch: i64,
21    pub secondary_reset_epoch: i64,
22}
23
24pub struct WeeklyValues {
25    pub weekly_remaining: i64,
26    pub weekly_reset_epoch: i64,
27    pub non_weekly_label: String,
28    pub non_weekly_remaining: i64,
29    pub non_weekly_reset_epoch: Option<i64>,
30}
31
32pub fn parse_usage(json: &Value) -> Option<UsageData> {
33    let rate_limit = json.get("rate_limit")?;
34    let primary = parse_window(rate_limit.get("primary_window")?)?;
35    let secondary = parse_window(rate_limit.get("secondary_window")?)?;
36    Some(UsageData { primary, secondary })
37}
38
39/// True when the usage payload is a valid response that explicitly reports no
40/// active rate-limit window (`"rate_limit": null`).
41///
42/// The ChatGPT backend returns this when there is no usage recorded in the
43/// current window (and it transiently returned it for every account during an
44/// upstream incident). It is a benign "no data yet" state, not a malformed
45/// payload, so callers should degrade gracefully (serve cache / show n/a)
46/// rather than reporting an error. This mirrors the official codex client,
47/// which maps a null `rate_limit` to empty windows instead of failing.
48pub fn rate_limit_is_explicit_null(json: &Value) -> bool {
49    matches!(json.get("rate_limit"), Some(Value::Null))
50}
51
52fn parse_window(value: &Value) -> Option<Window> {
53    let limit_window_seconds = value.get("limit_window_seconds")?.as_i64()?;
54    let used_percent = value
55        .get("used_percent")
56        .and_then(|v| v.as_f64())
57        .unwrap_or(0.0);
58    let reset_at = value.get("reset_at")?.as_i64()?;
59    Some(Window {
60        limit_window_seconds,
61        used_percent,
62        reset_at,
63    })
64}
65
66pub fn render_values(data: &UsageData) -> RenderValues {
67    let primary_label = format_window_seconds(data.primary.limit_window_seconds)
68        .unwrap_or_else(|| "Primary".to_string());
69    let secondary_label = format_window_seconds(data.secondary.limit_window_seconds)
70        .unwrap_or_else(|| "Secondary".to_string());
71
72    let primary_remaining = remaining_percent(data.primary.used_percent);
73    let secondary_remaining = remaining_percent(data.secondary.used_percent);
74
75    RenderValues {
76        primary_label,
77        secondary_label,
78        primary_remaining,
79        secondary_remaining,
80        primary_reset_epoch: data.primary.reset_at,
81        secondary_reset_epoch: data.secondary.reset_at,
82    }
83}
84
85pub fn weekly_values(values: &RenderValues) -> WeeklyValues {
86    let (
87        weekly_remaining,
88        weekly_reset_epoch,
89        non_weekly_label,
90        non_weekly_remaining,
91        non_weekly_reset_epoch,
92    ) = if values.primary_label == "Weekly" {
93        (
94            values.primary_remaining,
95            values.primary_reset_epoch,
96            values.secondary_label.clone(),
97            values.secondary_remaining,
98            Some(values.secondary_reset_epoch),
99        )
100    } else {
101        (
102            values.secondary_remaining,
103            values.secondary_reset_epoch,
104            values.primary_label.clone(),
105            values.primary_remaining,
106            Some(values.primary_reset_epoch),
107        )
108    };
109
110    WeeklyValues {
111        weekly_remaining,
112        weekly_reset_epoch,
113        non_weekly_label,
114        non_weekly_remaining,
115        non_weekly_reset_epoch,
116    }
117}
118
119pub fn format_window_seconds(raw: i64) -> Option<String> {
120    if raw <= 0 {
121        return None;
122    }
123    if raw % 604_800 == 0 {
124        let weeks = raw / 604_800;
125        if weeks == 1 {
126            return Some("Weekly".to_string());
127        }
128        return Some(format!("{weeks}w"));
129    }
130    if raw % 86_400 == 0 {
131        return Some(format!("{}d", raw / 86_400));
132    }
133    if raw % 3_600 == 0 {
134        return Some(format!("{}h", raw / 3_600));
135    }
136    if raw % 60 == 0 {
137        return Some(format!("{}m", raw / 60));
138    }
139    Some(format!("{raw}s"))
140}
141
142pub fn format_epoch_local_datetime(epoch: i64) -> Option<String> {
143    let dt = Local.timestamp_opt(epoch, 0).single()?;
144    Some(dt.format("%m-%d %H:%M").to_string())
145}
146
147pub fn format_epoch_local_datetime_with_offset(epoch: i64) -> Option<String> {
148    let dt = Local.timestamp_opt(epoch, 0).single()?;
149    Some(dt.format("%m-%d %H:%M %:z").to_string())
150}
151
152pub fn format_epoch_local(epoch: i64, fmt: &str) -> Option<String> {
153    let dt = Local.timestamp_opt(epoch, 0).single()?;
154    Some(dt.format(fmt).to_string())
155}
156
157pub fn format_until_epoch_compact(target_epoch: i64, now_epoch: i64) -> Option<String> {
158    if target_epoch <= 0 || now_epoch <= 0 {
159        return None;
160    }
161    let remaining = target_epoch - now_epoch;
162    if remaining <= 0 {
163        return Some(format!("{:>2}h {:>2}m", 0, 0));
164    }
165
166    if remaining >= 86_400 {
167        let days = remaining / 86_400;
168        let hours = (remaining % 86_400) / 3_600;
169        return Some(format!("{:>2}d {:>2}h", days, hours));
170    }
171
172    let hours = remaining / 3_600;
173    let minutes = (remaining % 3_600) / 60;
174    Some(format!("{:>2}h {:>2}m", hours, minutes))
175}
176
177fn remaining_percent(used_percent: f64) -> i64 {
178    let remaining = 100.0 - used_percent;
179    remaining.round() as i64
180}