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, Clone, Deserialize)]
41pub struct Window {
42    #[serde(deserialize_with = "de_percent_number_or_string")]
43    pub used_percent: f64,
44    #[serde(deserialize_with = "de_i64_number_or_string")]
45    pub limit_window_seconds: i64,
46    /// Unix seconds. May be absent on older Codex CLIs.
47    #[serde(default, deserialize_with = "de_opt_int_or_float")]
48    pub reset_at: Option<i64>,
49    /// Fallback when `reset_at` is absent. Unix seconds offset from "now".
50    #[serde(default, deserialize_with = "de_opt_int_or_float")]
51    pub reset_after_seconds: Option<i64>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
55pub struct CreditsBlock {
56    #[serde(default, deserialize_with = "de_opt_money_string")]
57    pub balance: Option<String>,
58    pub has_credits: bool,
59    pub unlimited: bool,
60    #[serde(default)]
61    pub approx_local_messages: Option<Vec<i64>>,
62    #[serde(default)]
63    pub approx_cloud_messages: Option<Vec<i64>>,
64}
65
66/// Accept a JSON number or numeric string without turning malformed, non-finite
67/// or out-of-range values into plausible counters. `fetch_usage` validates
68/// before writing the cache, so a fabricated value here would be persisted and
69/// rendered as genuine usage.
70fn numeric_value<E: serde::de::Error>(v: serde_json::Value) -> Result<f64, E> {
71    let value = match v {
72        serde_json::Value::Number(n) => n
73            .as_f64()
74            .ok_or_else(|| E::custom("number is not representable as f64"))?,
75        serde_json::Value::String(s) => s
76            .parse::<f64>()
77            .map_err(|_| E::custom(format!("expected numeric string, got {s:?}")))?,
78        other => {
79            return Err(E::custom(format!(
80                "expected number or numeric string, got {other:?}"
81            )));
82        }
83    };
84    if value.is_finite() {
85        Ok(value)
86    } else {
87        Err(E::custom("number is not finite"))
88    }
89}
90
91fn de_percent_number_or_string<'de, D>(d: D) -> Result<f64, D::Error>
92where
93    D: serde::Deserializer<'de>,
94{
95    let v = serde_json::Value::deserialize(d)?;
96    let value = numeric_value::<D::Error>(v)?;
97    if (0.0..=101.0).contains(&value) {
98        Ok(value)
99    } else {
100        Err(serde::de::Error::custom(format!(
101            "percentage {value} outside 0..=100"
102        )))
103    }
104}
105
106fn de_i64_number_or_string<'de, D>(d: D) -> Result<i64, D::Error>
107where
108    D: serde::Deserializer<'de>,
109{
110    i64_value(serde_json::Value::deserialize(d)?)
111}
112
113fn i64_value<E: serde::de::Error>(v: serde_json::Value) -> Result<i64, E> {
114    match &v {
115        serde_json::Value::Number(n) => {
116            if let Some(i) = n.as_i64() {
117                return Ok(i);
118            }
119        }
120        serde_json::Value::String(s) => {
121            if let Ok(i) = s.parse::<i64>() {
122                return Ok(i);
123            }
124        }
125        _ => {}
126    }
127    exact_i64(numeric_value::<E>(v)?).ok_or_else(|| E::custom("expected an integer in i64 range"))
128}
129
130/// `f as i64` saturates instead of failing, so `NaN` would coin a `0` and
131/// `1e300` an `i64::MAX` — both indistinguishable from a counter the API
132/// really sent. Only an integral magnitude that an `f64` represents exactly
133/// survives; timestamps and window lengths cannot silently lose a fraction or
134/// low bit. Plain JSON/string integers take the exact `i64` path above.
135fn exact_i64(f: f64) -> Option<i64> {
136    const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
137    if f.is_finite() && f.trunc() == f && f.abs() <= MAX_EXACT_F64_INT {
138        Some(f as i64)
139    } else {
140        None
141    }
142}
143
144fn de_opt_int_or_float<'de, D>(d: D) -> Result<Option<i64>, D::Error>
145where
146    D: serde::Deserializer<'de>,
147{
148    let v = serde_json::Value::deserialize(d)?;
149    if v.is_null() {
150        Ok(None)
151    } else {
152        i64_value::<D::Error>(v).map(Some)
153    }
154}
155
156/// Accept either a string ("$0.00") or a finite number (0.0) — codexbar
157/// treats both. Null and an omitted field mean that no balance was supplied.
158fn de_opt_money_string<'de, D>(d: D) -> Result<Option<String>, D::Error>
159where
160    D: serde::Deserializer<'de>,
161{
162    let v = serde_json::Value::deserialize(d)?;
163    match v {
164        serde_json::Value::Null => Ok(None),
165        serde_json::Value::String(s) => Ok(Some(s)),
166        serde_json::Value::Number(n) => match n.as_f64() {
167            Some(value) if value.is_finite() => Ok(Some(format!("${value:.2}"))),
168            _ => Err(serde::de::Error::custom(
169                "credit balance is not a finite number",
170            )),
171        },
172        other => Err(serde::de::Error::custom(format!(
173            "expected credit balance string, number, or null; got {other:?}"
174        ))),
175    }
176}
177
178impl UsageResponse {
179    pub fn into_snapshot(self, plan_hint: Option<&str>) -> OpenAiSnapshot {
180        let plan_type = self.plan_type.as_deref().or(plan_hint).unwrap_or("Unknown");
181        let plan = format!("ChatGPT {}", capitalize(plan_type));
182
183        let rl = self.rate_limit.unwrap_or_default();
184        let session = window_or_default(rl.primary_window, chrono::Duration::hours(5));
185        let weekly = window_or_default(rl.secondary_window, chrono::Duration::days(7));
186        let code_review = self
187            .code_review_rate_limit
188            .and_then(|c| c.primary_window)
189            .map(|w| to_window(&w, chrono::Duration::days(7)));
190
191        let credits = self.credits.map(|c| OpenAiCredits {
192            balance: c.balance.unwrap_or_default(),
193            has_credits: c.has_credits,
194            unlimited: c.unlimited,
195            approx_local_messages: range_from_vec(c.approx_local_messages),
196            approx_cloud_messages: range_from_vec(c.approx_cloud_messages),
197        });
198
199        OpenAiSnapshot {
200            plan,
201            session,
202            weekly,
203            code_review,
204            credits,
205            source: OpenAiSource::CodexOauth,
206        }
207    }
208}
209
210fn window_or_default(w: Option<Window>, default_dur: chrono::Duration) -> UsageWindow {
211    let Some(w) = w else {
212        return UsageWindow {
213            utilization_pct: 0,
214            resets_at: None,
215            window_duration: default_dur,
216        };
217    };
218    to_window(&w, default_dur)
219}
220
221fn to_window(w: &Window, default_dur: chrono::Duration) -> UsageWindow {
222    // `Duration::seconds` panics past ~1e16, and the widget must always exit 0
223    // — so an absurd counter degrades to the caller's default, never a crash.
224    let dur = match chrono::Duration::try_seconds(w.limit_window_seconds) {
225        Some(d) if w.limit_window_seconds > 0 => d,
226        _ => default_dur,
227    };
228    let resets_at = match w.reset_at {
229        Some(secs) => chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0),
230        None => w
231            .reset_after_seconds
232            .and_then(chrono::Duration::try_seconds)
233            .and_then(|d| chrono::Utc::now().checked_add_signed(d)),
234    };
235    UsageWindow {
236        utilization_pct: (w.used_percent.round() as i32).clamp(0, 100),
237        resets_at,
238        window_duration: dur,
239    }
240}
241
242fn range_from_vec(v: Option<Vec<i64>>) -> Option<(i64, i64)> {
243    let v = v?;
244    if v.len() >= 2 {
245        Some((v[0], v[1]))
246    } else if v.len() == 1 {
247        Some((v[0], v[0]))
248    } else {
249        None
250    }
251}
252
253fn capitalize(s: &str) -> String {
254    let mut chars = s.chars();
255    match chars.next() {
256        Some(c) => {
257            let mut out = String::with_capacity(s.len());
258            for u in c.to_uppercase() {
259                out.push(u);
260            }
261            out.push_str(chars.as_str());
262            out
263        }
264        None => String::new(),
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    const REAL: &str = r#"{
273        "user_id":"u","account_id":"a","email":"e",
274        "plan_type":"plus",
275        "rate_limit":{"allowed":true,"limit_reached":false,
276            "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_after_seconds":18000,"reset_at":1779597324},
277            "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_after_seconds":604800,"reset_at":1780184124}
278        }
279    }"#;
280
281    #[test]
282    fn parses_real_shape() {
283        let r: UsageResponse = serde_json::from_str(REAL).unwrap();
284        let s = r.into_snapshot(None);
285        assert_eq!(s.plan, "ChatGPT Plus");
286        assert_eq!(s.session.utilization_pct, 1);
287        assert_eq!(s.weekly.utilization_pct, 0);
288        assert_eq!(s.session.window_duration, chrono::Duration::hours(5));
289        assert_eq!(s.weekly.window_duration, chrono::Duration::days(7));
290        assert!(s.session.resets_at.is_some());
291        assert!(s.code_review.is_none());
292        assert!(s.credits.is_none());
293        assert!(matches!(s.source, OpenAiSource::CodexOauth));
294    }
295
296    #[test]
297    fn missing_rate_limit_yields_neutral() {
298        let r: UsageResponse = serde_json::from_str(r#"{"plan_type":"pro"}"#).unwrap();
299        let s = r.into_snapshot(None);
300        assert_eq!(s.plan, "ChatGPT Pro");
301        assert_eq!(s.session.utilization_pct, 0);
302        assert_eq!(s.weekly.utilization_pct, 0);
303    }
304
305    #[test]
306    fn credits_block_parses_with_message_ranges() {
307        let body = r#"{
308            "plan_type":"plus",
309            "credits":{"balance":"$2.50","has_credits":true,"unlimited":false,
310                "approx_local_messages":[100,200],"approx_cloud_messages":[40,60]}
311        }"#;
312        let r: UsageResponse = serde_json::from_str(body).unwrap();
313        let s = r.into_snapshot(None);
314        let c = s.credits.unwrap();
315        assert_eq!(c.balance, "$2.50");
316        assert!(c.has_credits);
317        assert_eq!(c.approx_local_messages, Some((100, 200)));
318        assert_eq!(c.approx_cloud_messages, Some((40, 60)));
319    }
320
321    #[test]
322    fn balance_as_number_formats_to_dollars() {
323        let body = r#"{"credits":{"balance":1.5,"has_credits":true,"unlimited":false}}"#;
324        let r: UsageResponse = serde_json::from_str(body).unwrap();
325        let s = r.into_snapshot(None);
326        assert_eq!(s.credits.unwrap().balance, "$1.50");
327    }
328
329    #[test]
330    fn benign_percent_overshoot_clamps_to_hundred() {
331        let body =
332            r#"{"rate_limit":{"primary_window":{"used_percent":100.6,"limit_window_seconds":1}}}"#;
333        let r: UsageResponse = serde_json::from_str(body).unwrap();
334        let s = r.into_snapshot(None);
335        assert_eq!(s.session.utilization_pct, 100);
336    }
337
338    #[test]
339    fn out_of_range_percent_is_schema_drift() {
340        for used_percent in ["-1", "101.5", "250"] {
341            let body = format!(
342                r#"{{"rate_limit":{{"primary_window":{{"used_percent":{used_percent},"limit_window_seconds":1}}}}}}"#
343            );
344            assert!(
345                serde_json::from_str::<UsageResponse>(&body).is_err(),
346                "{used_percent} must not become a clamped usage value"
347            );
348        }
349    }
350
351    #[test]
352    fn plan_hint_used_when_response_omits_plan_type() {
353        let r: UsageResponse = serde_json::from_str("{}").unwrap();
354        let s = r.into_snapshot(Some("team"));
355        assert_eq!(s.plan, "ChatGPT Team");
356    }
357
358    #[test]
359    fn window_counters_accept_fractional_percent_and_integral_number_forms() {
360        let w: Window =
361            serde_json::from_str(r#"{"used_percent":7.4,"limit_window_seconds":18000.0}"#).unwrap();
362        assert_eq!(w.used_percent, 7.4);
363        assert_eq!(w.limit_window_seconds, 18000);
364
365        let w: Window =
366            serde_json::from_str(r#"{"used_percent":"42.7","limit_window_seconds":"604800.0"}"#)
367                .unwrap();
368        assert_eq!(w.used_percent, 42.7);
369        assert_eq!(w.limit_window_seconds, 604800);
370
371        let r: UsageResponse = serde_json::from_str(
372            r#"{"rate_limit":{"primary_window":{"used_percent":42.7,"limit_window_seconds":18000}}}"#,
373        )
374        .unwrap();
375        assert_eq!(r.into_snapshot(None).session.utilization_pct, 43);
376    }
377
378    #[test]
379    fn fractional_integer_counters_are_schema_drift() {
380        for value in ["18000.9", r#""604800.5""#] {
381            let body = format!(r#"{{"used_percent":7,"limit_window_seconds":{value}}}"#);
382            assert!(serde_json::from_str::<Window>(&body).is_err(), "{value}");
383        }
384    }
385
386    #[test]
387    fn null_counter_is_schema_drift() {
388        // `reset_at` is the only field the API is documented to omit, and it
389        // carries its own Option deserializer. A null counter is drift.
390        let body = r#"{"used_percent":null,"limit_window_seconds":1}"#;
391        assert!(serde_json::from_str::<Window>(body).is_err());
392        let body = r#"{"used_percent":1,"limit_window_seconds":null}"#;
393        assert!(serde_json::from_str::<Window>(body).is_err());
394    }
395
396    #[test]
397    fn non_numeric_counter_shapes_are_schema_drift() {
398        for bad in [
399            r#""many""#,
400            r#"{"value":1}"#,
401            "[1]",
402            "true",
403            // Each parses as an f64, but `as i64` saturates rather than
404            // failing, so it would coin a 0 / i64::MAX that reads as real.
405            r#""NaN""#,
406            r#""inf""#,
407            "1e300",
408            "-1e300",
409            r#""1e300""#,
410        ] {
411            let body = format!(r#"{{"used_percent":{bad},"limit_window_seconds":1}}"#);
412            assert!(
413                serde_json::from_str::<Window>(&body).is_err(),
414                "used_percent {bad} must not deserialize"
415            );
416        }
417    }
418
419    #[test]
420    fn drifted_counter_fails_whole_usage_response() {
421        // The error has to reach `parse_payload` so the widget shows `⚠`
422        // rather than caching and rendering a 0% bar.
423        let body = r#"{"plan_type":"plus","rate_limit":{
424            "primary_window":{"used_percent":"n/a","limit_window_seconds":18000}
425        }}"#;
426        assert!(serde_json::from_str::<UsageResponse>(body).is_err());
427    }
428
429    #[test]
430    fn a_present_window_requires_both_counters() {
431        for body in [
432            r#"{"used_percent":1}"#,
433            r#"{"limit_window_seconds":18000}"#,
434            "{}",
435        ] {
436            assert!(serde_json::from_str::<Window>(body).is_err(), "{body}");
437        }
438    }
439
440    #[test]
441    fn malformed_optional_counters_are_not_treated_as_absent() {
442        for field in ["reset_at", "reset_after_seconds"] {
443            for bad in ["true", r#""tomorrow""#, "1.5", "{}"] {
444                let body =
445                    format!(r#"{{"used_percent":1,"limit_window_seconds":18000,"{field}":{bad}}}"#);
446                assert!(serde_json::from_str::<Window>(&body).is_err(), "{body}");
447            }
448        }
449    }
450
451    #[test]
452    fn credits_reject_invalid_present_values_without_inventing_zero() {
453        for balance in ["true", "{}", "[]"] {
454            let body = format!(
455                r#"{{"credits":{{"balance":{balance},"has_credits":true,"unlimited":false}}}}"#
456            );
457            assert!(serde_json::from_str::<UsageResponse>(&body).is_err());
458        }
459        assert!(
460            serde_json::from_str::<UsageResponse>(r#"{"credits":{"balance":"$1.00"}}"#).is_err(),
461            "a present credits block must not default its status flags"
462        );
463
464        let response: UsageResponse = serde_json::from_str(
465            r#"{"credits":{"balance":null,"has_credits":false,"unlimited":true}}"#,
466        )
467        .unwrap();
468        assert_eq!(response.into_snapshot(None).credits.unwrap().balance, "");
469    }
470
471    #[test]
472    fn oversized_window_seconds_degrades_instead_of_panicking() {
473        // i64::MAX is a faithful integer, so it clears the deserializer — but
474        // `chrono::Duration::seconds` panics on it, and a panicking widget
475        // exits non-zero and gets hidden by Waybar.
476        let body = r#"{"rate_limit":{"primary_window":{
477            "used_percent":1,"limit_window_seconds":9223372036854775807,
478            "reset_after_seconds":9223372036854775807
479        }}}"#;
480        let r: UsageResponse = serde_json::from_str(body).unwrap();
481        let s = r.into_snapshot(None);
482        assert_eq!(s.session.window_duration, chrono::Duration::hours(5));
483        assert!(s.session.resets_at.is_none());
484    }
485
486    #[test]
487    fn missing_reset_at_falls_back_to_after_seconds() {
488        let body = r#"{"rate_limit":{"primary_window":{
489            "used_percent":50,"limit_window_seconds":1000,"reset_after_seconds":500
490        }}}"#;
491        let r: UsageResponse = serde_json::from_str(body).unwrap();
492        let s = r.into_snapshot(None);
493        // The reset should be ~500s from now (within tolerance).
494        let now = chrono::Utc::now();
495        let reset = s.session.resets_at.unwrap();
496        let delta = reset.signed_duration_since(now).num_seconds();
497        assert!((400..=600).contains(&delta), "got delta={delta}");
498    }
499}