Skip to main content

ai_usagebar/openai/
types.rs

1//! Wire types for `GET https://chatgpt.com/backend-api/wham/usage`.
2//!
3//! Reverse-engineered from `~/Projects/codexbar/codexbar` and the official
4//! `openai/codex` Rust client. Real captured shape (2026-05-23):
5//!
6//! ```json
7//! {
8//!   "user_id": "...", "account_id": "...", "email": "...",
9//!   "plan_type": "plus",
10//!   "rate_limit": {
11//!     "allowed": true, "limit_reached": false,
12//!     "primary_window":   {"used_percent": 1, "limit_window_seconds": 18000, "reset_at": 1779597324},
13//!     "secondary_window": {"used_percent": 0, "limit_window_seconds": 604800, "reset_at": 1780184124}
14//!   },
15//!   "code_review_rate_limit": {...optional...},
16//!   "credits": {...optional...}
17//! }
18//! ```
19
20use serde::Deserialize;
21
22use crate::usage::{OpenAiCredits, OpenAiSnapshot, OpenAiSource, UsageWindow};
23
24#[derive(Debug, Default, Clone, Deserialize)]
25#[serde(default)]
26pub struct UsageResponse {
27    pub plan_type: Option<String>,
28    pub rate_limit: Option<RateLimit>,
29    pub code_review_rate_limit: Option<RateLimit>,
30    pub credits: Option<CreditsBlock>,
31}
32
33#[derive(Debug, Default, Clone, Deserialize)]
34#[serde(default)]
35pub struct RateLimit {
36    pub primary_window: Option<Window>,
37    pub secondary_window: Option<Window>,
38}
39
40#[derive(Debug, Default, Clone, Deserialize)]
41#[serde(default)]
42pub struct Window {
43    #[serde(deserialize_with = "de_int_or_float_lenient")]
44    pub used_percent: i64,
45    #[serde(deserialize_with = "de_int_or_float_lenient")]
46    pub limit_window_seconds: i64,
47    /// Unix seconds. May be absent on older Codex CLIs.
48    #[serde(default, deserialize_with = "de_opt_int_or_float")]
49    pub reset_at: Option<i64>,
50    /// Fallback when `reset_at` is absent. Unix seconds offset from "now".
51    #[serde(default, deserialize_with = "de_opt_int_or_float")]
52    pub reset_after_seconds: Option<i64>,
53}
54
55#[derive(Debug, Default, Clone, Deserialize)]
56#[serde(default)]
57pub struct CreditsBlock {
58    #[serde(deserialize_with = "de_money_string")]
59    pub balance: String,
60    pub has_credits: bool,
61    pub unlimited: bool,
62    #[serde(default)]
63    pub approx_local_messages: Option<Vec<i64>>,
64    #[serde(default)]
65    pub approx_cloud_messages: Option<Vec<i64>>,
66}
67
68fn de_int_or_float_lenient<'de, D>(d: D) -> Result<i64, D::Error>
69where
70    D: serde::Deserializer<'de>,
71{
72    let v = serde_json::Value::deserialize(d)?;
73    Ok(match v {
74        serde_json::Value::Null => 0,
75        serde_json::Value::Number(n) => n
76            .as_i64()
77            .or_else(|| n.as_f64().map(|f| f as i64))
78            .unwrap_or(0),
79        _ => 0,
80    })
81}
82
83fn de_opt_int_or_float<'de, D>(d: D) -> Result<Option<i64>, D::Error>
84where
85    D: serde::Deserializer<'de>,
86{
87    let v = serde_json::Value::deserialize(d)?;
88    Ok(match v {
89        serde_json::Value::Null => None,
90        serde_json::Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
91        _ => None,
92    })
93}
94
95/// Accept either a string ("$0.00") or a number (0.0) — codexbar treats both.
96fn de_money_string<'de, D>(d: D) -> Result<String, D::Error>
97where
98    D: serde::Deserializer<'de>,
99{
100    let v = serde_json::Value::deserialize(d)?;
101    Ok(match v {
102        serde_json::Value::String(s) => s,
103        serde_json::Value::Number(n) => format!("${:.2}", n.as_f64().unwrap_or(0.0)),
104        _ => "$0.00".to_string(),
105    })
106}
107
108impl UsageResponse {
109    pub fn into_snapshot(self, plan_hint: Option<&str>) -> OpenAiSnapshot {
110        let plan_type = self.plan_type.as_deref().or(plan_hint).unwrap_or("Unknown");
111        let plan = format!("ChatGPT {}", capitalize(plan_type));
112
113        let rl = self.rate_limit.unwrap_or_default();
114        let session = window_or_default(rl.primary_window, chrono::Duration::hours(5));
115        let weekly = window_or_default(rl.secondary_window, chrono::Duration::days(7));
116        let code_review = self
117            .code_review_rate_limit
118            .and_then(|c| c.primary_window)
119            .map(|w| to_window(&w, chrono::Duration::days(7)));
120
121        let credits = self.credits.map(|c| OpenAiCredits {
122            balance: c.balance,
123            has_credits: c.has_credits,
124            unlimited: c.unlimited,
125            approx_local_messages: range_from_vec(c.approx_local_messages),
126            approx_cloud_messages: range_from_vec(c.approx_cloud_messages),
127        });
128
129        OpenAiSnapshot {
130            plan,
131            session,
132            weekly,
133            code_review,
134            credits,
135            source: OpenAiSource::CodexOauth,
136        }
137    }
138}
139
140fn window_or_default(w: Option<Window>, default_dur: chrono::Duration) -> UsageWindow {
141    let Some(w) = w else {
142        return UsageWindow {
143            utilization_pct: 0,
144            resets_at: None,
145            window_duration: default_dur,
146        };
147    };
148    to_window(&w, default_dur)
149}
150
151fn to_window(w: &Window, default_dur: chrono::Duration) -> UsageWindow {
152    let dur = if w.limit_window_seconds > 0 {
153        chrono::Duration::seconds(w.limit_window_seconds)
154    } else {
155        default_dur
156    };
157    let resets_at = match w.reset_at {
158        Some(secs) => chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0),
159        None => w
160            .reset_after_seconds
161            .map(|s| chrono::Utc::now() + chrono::Duration::seconds(s)),
162    };
163    UsageWindow {
164        utilization_pct: (w.used_percent as i32).clamp(0, 100),
165        resets_at,
166        window_duration: dur,
167    }
168}
169
170fn range_from_vec(v: Option<Vec<i64>>) -> Option<(i64, i64)> {
171    let v = v?;
172    if v.len() >= 2 {
173        Some((v[0], v[1]))
174    } else if v.len() == 1 {
175        Some((v[0], v[0]))
176    } else {
177        None
178    }
179}
180
181fn capitalize(s: &str) -> String {
182    let mut chars = s.chars();
183    match chars.next() {
184        Some(c) => {
185            let mut out = String::with_capacity(s.len());
186            for u in c.to_uppercase() {
187                out.push(u);
188            }
189            out.push_str(chars.as_str());
190            out
191        }
192        None => String::new(),
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    const REAL: &str = r#"{
201        "user_id":"u","account_id":"a","email":"e",
202        "plan_type":"plus",
203        "rate_limit":{"allowed":true,"limit_reached":false,
204            "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_after_seconds":18000,"reset_at":1779597324},
205            "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_after_seconds":604800,"reset_at":1780184124}
206        }
207    }"#;
208
209    #[test]
210    fn parses_real_shape() {
211        let r: UsageResponse = serde_json::from_str(REAL).unwrap();
212        let s = r.into_snapshot(None);
213        assert_eq!(s.plan, "ChatGPT Plus");
214        assert_eq!(s.session.utilization_pct, 1);
215        assert_eq!(s.weekly.utilization_pct, 0);
216        assert_eq!(s.session.window_duration, chrono::Duration::hours(5));
217        assert_eq!(s.weekly.window_duration, chrono::Duration::days(7));
218        assert!(s.session.resets_at.is_some());
219        assert!(s.code_review.is_none());
220        assert!(s.credits.is_none());
221        assert!(matches!(s.source, OpenAiSource::CodexOauth));
222    }
223
224    #[test]
225    fn missing_rate_limit_yields_neutral() {
226        let r: UsageResponse = serde_json::from_str(r#"{"plan_type":"pro"}"#).unwrap();
227        let s = r.into_snapshot(None);
228        assert_eq!(s.plan, "ChatGPT Pro");
229        assert_eq!(s.session.utilization_pct, 0);
230        assert_eq!(s.weekly.utilization_pct, 0);
231    }
232
233    #[test]
234    fn credits_block_parses_with_message_ranges() {
235        let body = r#"{
236            "plan_type":"plus",
237            "credits":{"balance":"$2.50","has_credits":true,"unlimited":false,
238                "approx_local_messages":[100,200],"approx_cloud_messages":[40,60]}
239        }"#;
240        let r: UsageResponse = serde_json::from_str(body).unwrap();
241        let s = r.into_snapshot(None);
242        let c = s.credits.unwrap();
243        assert_eq!(c.balance, "$2.50");
244        assert!(c.has_credits);
245        assert_eq!(c.approx_local_messages, Some((100, 200)));
246        assert_eq!(c.approx_cloud_messages, Some((40, 60)));
247    }
248
249    #[test]
250    fn balance_as_number_formats_to_dollars() {
251        let body = r#"{"credits":{"balance":1.5,"has_credits":true,"unlimited":false}}"#;
252        let r: UsageResponse = serde_json::from_str(body).unwrap();
253        let s = r.into_snapshot(None);
254        assert_eq!(s.credits.unwrap().balance, "$1.50");
255    }
256
257    #[test]
258    fn used_percent_clamps_to_hundred() {
259        let body =
260            r#"{"rate_limit":{"primary_window":{"used_percent":250,"limit_window_seconds":1}}}"#;
261        let r: UsageResponse = serde_json::from_str(body).unwrap();
262        let s = r.into_snapshot(None);
263        assert_eq!(s.session.utilization_pct, 100);
264    }
265
266    #[test]
267    fn plan_hint_used_when_response_omits_plan_type() {
268        let r: UsageResponse = serde_json::from_str("{}").unwrap();
269        let s = r.into_snapshot(Some("team"));
270        assert_eq!(s.plan, "ChatGPT Team");
271    }
272
273    #[test]
274    fn missing_reset_at_falls_back_to_after_seconds() {
275        let body = r#"{"rate_limit":{"primary_window":{
276            "used_percent":50,"limit_window_seconds":1000,"reset_after_seconds":500
277        }}}"#;
278        let r: UsageResponse = serde_json::from_str(body).unwrap();
279        let s = r.into_snapshot(None);
280        // The reset should be ~500s from now (within tolerance).
281        let now = chrono::Utc::now();
282        let reset = s.session.resets_at.unwrap();
283        let delta = reset.signed_duration_since(now).num_seconds();
284        assert!((400..=600).contains(&delta), "got delta={delta}");
285    }
286}