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//!   "rate_limit_reset_credits": {"available_count": 2}
18//! }
19//! ```
20
21use std::collections::BTreeMap;
22
23use chrono::{DateTime, Utc};
24use serde::{Deserialize, Serialize};
25
26use crate::error::{AppError, Result as AppResult};
27use crate::usage::{
28    OpenAiCredits, OpenAiNamedLimit, OpenAiSnapshot, OpenAiSource, OpenAiUnavailableModel,
29    ResetCredit as BankedReset, ResetCredits, UsageWindow,
30};
31
32#[derive(Debug, Default, Clone, Deserialize, Serialize)]
33#[serde(default)]
34pub struct UsageResponse {
35    pub plan_type: Option<String>,
36    pub rate_limit: Option<RateLimit>,
37    pub code_review_rate_limit: Option<RateLimit>,
38    pub credits: Option<CreditsBlock>,
39    pub rate_limit_reset_credits: Option<ResetCreditsBlock>,
40    /// Named limits alongside the main one — a reserved pool, a
41    /// model-specific allowance. Each carries its own windows and can be the
42    /// binding constraint while `rate_limit` still reads low, which is
43    /// precisely when a user needs to see it.
44    /// Null from the API means "none" (observed 2026-09-05: OpenAI returns
45    /// `"additional_rate_limits": null` for accounts with no extra limits).
46    #[serde(default, deserialize_with = "de_null_as_default")]
47    pub additional_rate_limits: Vec<AdditionalRateLimit>,
48    /// Per-model availability. `available: false` is what "Selected model is
49    /// at capacity" looks like in the data — a dispatch-time refusal, not a
50    /// quota, so no percentage anywhere else reflects it.
51    #[serde(default, deserialize_with = "de_null_as_default")]
52    pub model_usage: BTreeMap<String, ModelUsage>,
53}
54
55#[derive(Debug, Default, Clone, Deserialize, Serialize)]
56#[serde(default)]
57pub struct AdditionalRateLimit {
58    pub limit_name: Option<String>,
59    pub metered_feature: Option<String>,
60    pub rate_limit: Option<RateLimit>,
61}
62
63#[derive(Debug, Default, Clone, Deserialize, Serialize)]
64#[serde(default)]
65pub struct ModelUsage {
66    /// Absent means "not stated", which is not the same as unavailable.
67    pub available: Option<bool>,
68    pub available_at: Option<DateTime<Utc>>,
69}
70
71#[derive(Debug, Default, Clone, Deserialize, Serialize)]
72#[serde(default)]
73pub struct RateLimit {
74    pub primary_window: Option<Window>,
75    pub secondary_window: Option<Window>,
76}
77
78#[derive(Debug, Clone, Deserialize, Serialize)]
79pub struct Window {
80    #[serde(deserialize_with = "de_percent_number_or_string")]
81    pub used_percent: f64,
82    #[serde(deserialize_with = "de_i64_number_or_string")]
83    pub limit_window_seconds: i64,
84    /// Unix seconds. May be absent on older Codex CLIs.
85    #[serde(default, deserialize_with = "de_opt_int_or_float")]
86    pub reset_at: Option<i64>,
87    /// Fallback when `reset_at` is absent. Unix seconds offset from "now".
88    #[serde(default, deserialize_with = "de_opt_int_or_float")]
89    pub reset_after_seconds: Option<i64>,
90}
91
92#[derive(Debug, Clone, Deserialize, Serialize)]
93pub struct CreditsBlock {
94    #[serde(default, deserialize_with = "de_opt_money_string")]
95    pub balance: Option<String>,
96    pub has_credits: bool,
97    pub unlimited: bool,
98    #[serde(default)]
99    pub approx_local_messages: Option<Vec<i64>>,
100    #[serde(default)]
101    pub approx_cloud_messages: Option<Vec<i64>>,
102}
103
104/// Banked rate-limit reset credits. `available_count` rides along with the
105/// usage response; `credits` only ever arrives from the separate
106/// `/rate-limit-reset-credits` call, so it is routinely empty while the count
107/// is not. The redemption `id` each entry carries is deliberately not
108/// deserialized.
109#[derive(Debug, Default, Clone, Deserialize, Serialize)]
110#[serde(default)]
111pub struct ResetCreditsBlock {
112    pub available_count: u32,
113    #[serde(default, deserialize_with = "de_null_as_default")]
114    pub credits: Vec<ResetCredit>,
115}
116
117/// Cached beside the usage payload. Status, title, and expiry are written
118/// back — the wire's redemption `id` is never deserialized.
119#[derive(Debug, Default, Clone, Deserialize, Serialize)]
120#[serde(default)]
121pub struct ResetCredit {
122    /// "available", "redeemed", … — only an available credit is one you still
123    /// have, so a redeemed entry's expiry must not become a deadline on screen.
124    pub status: String,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub title: Option<String>,
127    pub expires_at: Option<DateTime<Utc>>,
128}
129
130/// Accept a JSON number or numeric string without turning malformed, non-finite
131/// or out-of-range values into plausible counters. `fetch_usage` validates
132/// before writing the cache, so a fabricated value here would be persisted and
133/// rendered as genuine usage.
134fn numeric_value<E: serde::de::Error>(v: serde_json::Value) -> Result<f64, E> {
135    let value = match v {
136        serde_json::Value::Number(n) => n
137            .as_f64()
138            .ok_or_else(|| E::custom("number is not representable as f64"))?,
139        serde_json::Value::String(s) => s
140            .parse::<f64>()
141            .map_err(|_| E::custom(format!("expected numeric string, got {s:?}")))?,
142        other => {
143            return Err(E::custom(format!(
144                "expected number or numeric string, got {other:?}"
145            )));
146        }
147    };
148    if value.is_finite() {
149        Ok(value)
150    } else {
151        Err(E::custom("number is not finite"))
152    }
153}
154
155fn de_percent_number_or_string<'de, D>(d: D) -> Result<f64, D::Error>
156where
157    D: serde::Deserializer<'de>,
158{
159    let v = serde_json::Value::deserialize(d)?;
160    let value = numeric_value::<D::Error>(v)?;
161    if (0.0..=101.0).contains(&value) {
162        Ok(value)
163    } else {
164        Err(serde::de::Error::custom(format!(
165            "percentage {value} outside 0..=100"
166        )))
167    }
168}
169
170fn de_i64_number_or_string<'de, D>(d: D) -> Result<i64, D::Error>
171where
172    D: serde::Deserializer<'de>,
173{
174    i64_value(serde_json::Value::deserialize(d)?)
175}
176
177fn i64_value<E: serde::de::Error>(v: serde_json::Value) -> Result<i64, E> {
178    match &v {
179        serde_json::Value::Number(n) => {
180            if let Some(i) = n.as_i64() {
181                return Ok(i);
182            }
183        }
184        serde_json::Value::String(s) => {
185            if let Ok(i) = s.parse::<i64>() {
186                return Ok(i);
187            }
188        }
189        _ => {}
190    }
191    exact_i64(numeric_value::<E>(v)?).ok_or_else(|| E::custom("expected an integer in i64 range"))
192}
193
194/// `f as i64` saturates instead of failing, so `NaN` would coin a `0` and
195/// `1e300` an `i64::MAX` — both indistinguishable from a counter the API
196/// really sent. Only an integral magnitude that an `f64` represents exactly
197/// survives; timestamps and window lengths cannot silently lose a fraction or
198/// low bit. Plain JSON/string integers take the exact `i64` path above.
199fn exact_i64(f: f64) -> Option<i64> {
200    const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
201    if f.is_finite() && f.trunc() == f && f.abs() <= MAX_EXACT_F64_INT {
202        Some(f as i64)
203    } else {
204        None
205    }
206}
207
208fn de_opt_int_or_float<'de, D>(d: D) -> Result<Option<i64>, D::Error>
209where
210    D: serde::Deserializer<'de>,
211{
212    let v = serde_json::Value::deserialize(d)?;
213    if v.is_null() {
214        Ok(None)
215    } else {
216        i64_value::<D::Error>(v).map(Some)
217    }
218}
219
220/// Accept either a string ("$0.00") or a finite number (0.0) — codexbar
221/// treats both. Null and an omitted field mean that no balance was supplied.
222fn de_opt_money_string<'de, D>(d: D) -> Result<Option<String>, D::Error>
223where
224    D: serde::Deserializer<'de>,
225{
226    let v = serde_json::Value::deserialize(d)?;
227    match v {
228        serde_json::Value::Null => Ok(None),
229        serde_json::Value::String(s) => Ok(Some(s)),
230        serde_json::Value::Number(n) => match n.as_f64() {
231            Some(value) if value.is_finite() => Ok(Some(crate::format::usd(value))),
232            _ => Err(serde::de::Error::custom(
233                "credit balance is not a finite number",
234            )),
235        },
236        other => Err(serde::de::Error::custom(format!(
237            "expected credit balance string, number, or null; got {other:?}"
238        ))),
239    }
240}
241
242/// OpenAI sends `null` for an empty collection rather than `[]`/`{}` (observed
243/// 2026-09-05 for `additional_rate_limits`). `#[serde(default)]` alone covers a
244/// *missing* field but not a present-but-null one, which fails with "invalid
245/// type: null, expected a sequence" and takes the whole response with it.
246///
247/// This is deliberately not applied to every collection in the codebase. It is
248/// right here because an absent named limit genuinely means "none" and renders
249/// nothing. For a balance or usage array — DeepSeek's `balance_infos`, the
250/// Anthropic API's `data` — an empty list is not the same as a null one, and
251/// silently reading it as empty would render a confident zero for a figure we
252/// never received.
253fn de_null_as_default<'de, D, T>(d: D) -> Result<T, D::Error>
254where
255    D: serde::Deserializer<'de>,
256    T: Default + Deserialize<'de>,
257{
258    Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
259}
260
261const MAX_RESET_TITLE_CHARS: usize = 80;
262
263fn checked_reset_title(value: Option<String>) -> Option<String> {
264    let value = value
265        .map(|s| s.trim().to_string())
266        .filter(|s| !s.is_empty())?;
267    if value.chars().count() > MAX_RESET_TITLE_CHARS || value.chars().any(char::is_control) {
268        None
269    } else {
270        Some(value)
271    }
272}
273
274impl UsageResponse {
275    pub fn into_snapshot(self, plan_hint: Option<&str>) -> AppResult<OpenAiSnapshot> {
276        let plan_type = self.plan_type.as_deref().or(plan_hint).unwrap_or("Unknown");
277        let plan = format!("ChatGPT {}", crate::format::capitalize(plan_type));
278
279        let (session, weekly) = classify_rate_limit(self.rate_limit.unwrap_or_default())?;
280        let code_review = self
281            .code_review_rate_limit
282            .and_then(|c| c.primary_window)
283            .map(|w| to_window(&w, chrono::Duration::days(7)));
284
285        let credits = self.credits.map(|c| OpenAiCredits {
286            balance: c.balance.unwrap_or_default(),
287            has_credits: c.has_credits,
288            unlimited: c.unlimited,
289            approx_local_messages: range_from_vec(c.approx_local_messages),
290            approx_cloud_messages: range_from_vec(c.approx_cloud_messages),
291        });
292        let reset_credits = self
293            .rate_limit_reset_credits
294            .map(|credits| ResetCredits {
295                available: credits.available_count,
296                credits: credits
297                    .credits
298                    .into_iter()
299                    .filter(|credit| credit.status == "available")
300                    .map(|credit| BankedReset {
301                        title: checked_reset_title(credit.title),
302                        expires_at: credit.expires_at,
303                    })
304                    .collect(),
305            })
306            .unwrap_or_default();
307
308        let additional_limits = self
309            .additional_rate_limits
310            .into_iter()
311            .filter_map(named_limit)
312            .collect();
313        // Only the unavailable ones: a roster of working models is noise, and
314        // this list exists to name a refusal nothing else accounts for.
315        let unavailable_models = self
316            .model_usage
317            .into_iter()
318            .filter(|(_, usage)| usage.available == Some(false))
319            .map(|(model, usage)| OpenAiUnavailableModel {
320                model,
321                available_at: usage.available_at,
322            })
323            .collect();
324
325        Ok(OpenAiSnapshot {
326            plan,
327            session,
328            weekly,
329            code_review,
330            additional_limits,
331            unavailable_models,
332            credits,
333            reset_credits,
334            source: OpenAiSource::CodexOauth,
335        })
336    }
337}
338
339#[derive(Clone, Copy, Debug)]
340enum WindowKind {
341    Session,
342    Weekly,
343}
344
345/// `limit_window_seconds` value the Codex API reports for the 5-hour window.
346pub(crate) const SESSION_WINDOW_SECS: u64 = 18_000;
347/// `limit_window_seconds` value the Codex API reports for the 7-day window.
348pub(crate) const WEEKLY_WINDOW_SECS: u64 = 604_800;
349
350/// One named limit, or `None` when it carries no window we can show. Windows
351/// go through the same classifier as the main limit — identified by duration,
352/// not wire position — so a named 5h reads as a 5h everywhere.
353fn named_limit(entry: AdditionalRateLimit) -> Option<OpenAiNamedLimit> {
354    let name = entry
355        .limit_name
356        .or(entry.metered_feature)
357        .filter(|name| !name.trim().is_empty())?;
358    let (session, weekly) = classify_rate_limit(entry.rate_limit?).ok()?;
359    if session.is_none() && weekly.is_none() {
360        return None;
361    }
362    Some(OpenAiNamedLimit {
363        name: crate::display::sanitize_untrusted_field(&name),
364        session,
365        weekly,
366    })
367}
368
369fn classify_rate_limit(
370    rate_limit: RateLimit,
371) -> AppResult<(Option<UsageWindow>, Option<UsageWindow>)> {
372    let mut session = None;
373    let mut weekly = None;
374    insert_window(
375        rate_limit.primary_window,
376        WindowKind::Session,
377        &mut session,
378        &mut weekly,
379    )?;
380    insert_window(
381        rate_limit.secondary_window,
382        WindowKind::Weekly,
383        &mut session,
384        &mut weekly,
385    )?;
386    Ok((session, weekly))
387}
388
389fn insert_window(
390    wire_window: Option<Window>,
391    fallback_kind: WindowKind,
392    session: &mut Option<UsageWindow>,
393    weekly: &mut Option<UsageWindow>,
394) -> AppResult<()> {
395    let Some(wire_window) = wire_window else {
396        return Ok(());
397    };
398    let kind = window_kind(&wire_window).unwrap_or(fallback_kind);
399    let default_duration = kind.default_duration();
400    let target = semantic_window_target(kind, session, weekly);
401    if target.is_some() {
402        return Err(duplicate_window_error(
403            kind,
404            wire_window.limit_window_seconds,
405        ));
406    }
407    *target = Some(to_window(&wire_window, default_duration));
408    Ok(())
409}
410
411fn semantic_window_target<'a>(
412    kind: WindowKind,
413    session: &'a mut Option<UsageWindow>,
414    weekly: &'a mut Option<UsageWindow>,
415) -> &'a mut Option<UsageWindow> {
416    match kind {
417        WindowKind::Session => session,
418        WindowKind::Weekly => weekly,
419    }
420}
421
422fn window_kind(window: &Window) -> Option<WindowKind> {
423    // OpenAI temporarily moved the 7d window into `primary_window` and omitted
424    // `secondary_window`; wire position is not semantic (openai/codex#32707).
425    match window.limit_window_seconds {
426        s if s == SESSION_WINDOW_SECS as i64 => Some(WindowKind::Session),
427        s if s == WEEKLY_WINDOW_SECS as i64 => Some(WindowKind::Weekly),
428        _ => None,
429    }
430}
431
432fn duplicate_window_error(kind: WindowKind, seconds: i64) -> AppError {
433    let label = match kind {
434        WindowKind::Session => "5h",
435        WindowKind::Weekly => "7d",
436    };
437    AppError::Schema(format!(
438        "duplicate OpenAI {label} window with limit_window_seconds={seconds}; expected at most one 5h and one 7d window"
439    ))
440}
441
442impl WindowKind {
443    fn default_duration(self) -> chrono::Duration {
444        match self {
445            Self::Session => chrono::Duration::seconds(SESSION_WINDOW_SECS as i64),
446            Self::Weekly => chrono::Duration::seconds(WEEKLY_WINDOW_SECS as i64),
447        }
448    }
449}
450
451fn to_window(w: &Window, default_dur: chrono::Duration) -> UsageWindow {
452    // `Duration::seconds` panics past ~1e16, and the widget must always exit 0
453    // — so an absurd counter degrades to the caller's default, never a crash.
454    let dur = match chrono::Duration::try_seconds(w.limit_window_seconds) {
455        Some(d) if w.limit_window_seconds > 0 => d,
456        _ => default_dur,
457    };
458    let resets_at = match w.reset_at {
459        Some(secs) => chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0),
460        None => w
461            .reset_after_seconds
462            .and_then(chrono::Duration::try_seconds)
463            .and_then(|d| chrono::Utc::now().checked_add_signed(d)),
464    };
465    UsageWindow {
466        utilization_pct: (w.used_percent.round() as i32).clamp(0, 100),
467        resets_at,
468        window_duration: dur,
469    }
470}
471
472fn range_from_vec(v: Option<Vec<i64>>) -> Option<(i64, i64)> {
473    let v = v?;
474    if v.len() >= 2 {
475        Some((v[0], v[1]))
476    } else if v.len() == 1 {
477        Some((v[0], v[0]))
478    } else {
479        None
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    const REAL: &str = r#"{
488        "user_id":"u","account_id":"a","email":"e",
489        "plan_type":"plus",
490        "rate_limit":{"allowed":true,"limit_reached":false,
491            "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_after_seconds":18000,"reset_at":1779597324},
492            "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_after_seconds":604800,"reset_at":1780184124}
493        }
494    }"#;
495
496    #[test]
497    fn parses_real_shape() {
498        let r: UsageResponse = serde_json::from_str(REAL).unwrap();
499        let s = r.into_snapshot(None).unwrap();
500        assert_eq!(s.plan, "ChatGPT Plus");
501        assert_eq!(s.session.as_ref().unwrap().utilization_pct, 1);
502        assert_eq!(s.weekly.as_ref().unwrap().utilization_pct, 0);
503        assert_eq!(
504            s.session.as_ref().unwrap().window_duration,
505            chrono::Duration::hours(5)
506        );
507        assert_eq!(
508            s.weekly.as_ref().unwrap().window_duration,
509            chrono::Duration::days(7)
510        );
511        assert!(s.session.as_ref().unwrap().resets_at.is_some());
512        assert!(s.code_review.is_none());
513        assert!(s.credits.is_none());
514        assert!(matches!(s.source, OpenAiSource::CodexOauth));
515    }
516
517    #[test]
518    fn missing_rate_limit_reports_no_windows() {
519        let r: UsageResponse = serde_json::from_str(r#"{"plan_type":"pro"}"#).unwrap();
520        let s = r.into_snapshot(None).unwrap();
521        assert_eq!(s.plan, "ChatGPT Pro");
522        assert!(s.session.is_none());
523        assert!(s.weekly.is_none());
524    }
525
526    #[test]
527    fn weekly_only_primary_window_is_not_mislabeled_as_session() {
528        // Sanitized live response captured 2026-07-23 during OpenAI's
529        // temporary weekly-only rollout (openai/codex#32707).
530        let body = r#"{
531            "plan_type":"prolite",
532            "rate_limit":{
533                "primary_window":{
534                    "used_percent":66,
535                    "limit_window_seconds":604800,
536                    "reset_at":1785261834
537                },
538                "secondary_window":null
539            }
540        }"#;
541        let response: UsageResponse = serde_json::from_str(body).unwrap();
542        let snapshot = response.into_snapshot(None).unwrap();
543        assert!(snapshot.session.is_none());
544        assert_eq!(snapshot.weekly.unwrap().utilization_pct, 66);
545    }
546
547    #[test]
548    fn duration_classification_survives_reordered_wire_windows() {
549        let body = r#"{"rate_limit":{
550            "primary_window":{"used_percent":41,"limit_window_seconds":604800},
551            "secondary_window":{"used_percent":7,"limit_window_seconds":18000}
552        }}"#;
553        let response: UsageResponse = serde_json::from_str(body).unwrap();
554        let snapshot = response.into_snapshot(None).unwrap();
555        assert_eq!(snapshot.session.unwrap().utilization_pct, 7);
556        assert_eq!(snapshot.weekly.unwrap().utilization_pct, 41);
557    }
558
559    #[test]
560    fn duplicate_semantic_windows_are_schema_drift() {
561        let body = r#"{"rate_limit":{
562            "primary_window":{"used_percent":41,"limit_window_seconds":604800},
563            "secondary_window":{"used_percent":7,"limit_window_seconds":604800}
564        }}"#;
565        let response: UsageResponse = serde_json::from_str(body).unwrap();
566        let error = response.into_snapshot(None).unwrap_err().to_string();
567        assert!(error.contains("duplicate OpenAI 7d window"));
568        assert!(error.contains("limit_window_seconds=604800"));
569    }
570
571    #[test]
572    fn unknown_duration_falls_back_to_wire_position() {
573        // A `limit_window_seconds` value we do not recognize (e.g. 3600) is
574        // classified by wire position: `primary_window` → session,
575        // `secondary_window` → weekly.
576        let body = r#"{"rate_limit":{
577            "primary_window":{"used_percent":10,"limit_window_seconds":3600},
578            "secondary_window":{"used_percent":20,"limit_window_seconds":3600}
579        }}"#;
580        let response: UsageResponse = serde_json::from_str(body).unwrap();
581        let snapshot = response.into_snapshot(None).unwrap();
582        assert_eq!(snapshot.session.unwrap().utilization_pct, 10);
583        assert_eq!(snapshot.weekly.unwrap().utilization_pct, 20);
584    }
585
586    #[test]
587    fn credits_block_parses_with_message_ranges() {
588        let body = r#"{
589            "plan_type":"plus",
590            "credits":{"balance":"$2.50","has_credits":true,"unlimited":false,
591                "approx_local_messages":[100,200],"approx_cloud_messages":[40,60]}
592        }"#;
593        let r: UsageResponse = serde_json::from_str(body).unwrap();
594        let s = r.into_snapshot(None).unwrap();
595        let c = s.credits.unwrap();
596        assert_eq!(c.balance, "$2.50");
597        assert!(c.has_credits);
598        assert_eq!(c.approx_local_messages, Some((100, 200)));
599        assert_eq!(c.approx_cloud_messages, Some((40, 60)));
600    }
601
602    /// The count travels with the usage response; the per-credit detail only
603    /// arrives from the second endpoint, so a snapshot must be able to report
604    /// "2 available" with no expiry attached to either of them.
605    #[test]
606    fn reset_credit_count_stands_on_its_own_without_the_detail_call() {
607        let body = r#"{"plan_type":"plus","rate_limit_reset_credits":{"available_count":2}}"#;
608        let s: UsageResponse = serde_json::from_str(body).unwrap();
609        let s = s.into_snapshot(None).unwrap();
610        assert_eq!(s.reset_credits.available, 2);
611        assert!(s.reset_credits.credits.is_empty());
612        assert!(!s.reset_credits.is_empty());
613    }
614
615    /// A redeemed credit still appears in the detail list. Its expiry is not a
616    /// deadline the user can act on, so it must not become the next one shown.
617    #[test]
618    fn only_an_available_credit_contributes_an_expiry() {
619        let body = r#"{
620            "rate_limit_reset_credits":{
621                "available_count":1,
622                "credits":[
623                    {"id":"c1","status":"redeemed","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-07-01T00:00:00Z"},
624                    {"id":"c2","status":"available","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-07-17T00:00:00Z"},
625                    {"id":"c3","status":"available","expires_at":null}
626                ]
627            }
628        }"#;
629        let s: UsageResponse = serde_json::from_str(body).unwrap();
630        let s = s.into_snapshot(None).unwrap();
631        assert_eq!(s.reset_credits.available, 1);
632        assert_eq!(s.reset_credits.credits.len(), 2);
633        assert_eq!(
634            s.reset_credits.credits[0].title.as_deref(),
635            Some("Full reset (Weekly + 5 hr)")
636        );
637        assert_eq!(
638            s.reset_credits.next_expiry(),
639            Some("2026-07-17T00:00:00Z".parse::<DateTime<Utc>>().unwrap())
640        );
641    }
642
643    /// Every other vendor's absent block means "none". This one is load-bearing
644    /// in the same way: a response from an account with no banked resets, or
645    /// from a Codex build that predates them, reports none rather than failing.
646    #[test]
647    fn an_absent_reset_block_is_no_credits_rather_than_an_error() {
648        let s: UsageResponse = serde_json::from_str(r#"{"plan_type":"plus"}"#).unwrap();
649        assert!(s.into_snapshot(None).unwrap().reset_credits.is_empty());
650    }
651
652    #[test]
653    fn balance_as_number_formats_to_dollars() {
654        let body = r#"{"credits":{"balance":1.5,"has_credits":true,"unlimited":false}}"#;
655        let r: UsageResponse = serde_json::from_str(body).unwrap();
656        let s = r.into_snapshot(None).unwrap();
657        assert_eq!(s.credits.unwrap().balance, "$1.50");
658    }
659
660    #[test]
661    fn benign_percent_overshoot_clamps_to_hundred() {
662        let body =
663            r#"{"rate_limit":{"primary_window":{"used_percent":100.6,"limit_window_seconds":1}}}"#;
664        let r: UsageResponse = serde_json::from_str(body).unwrap();
665        let s = r.into_snapshot(None).unwrap();
666        assert_eq!(s.session.unwrap().utilization_pct, 100);
667    }
668
669    #[test]
670    fn out_of_range_percent_is_schema_drift() {
671        for used_percent in ["-1", "101.5", "250"] {
672            let body = format!(
673                r#"{{"rate_limit":{{"primary_window":{{"used_percent":{used_percent},"limit_window_seconds":1}}}}}}"#
674            );
675            assert!(
676                serde_json::from_str::<UsageResponse>(&body).is_err(),
677                "{used_percent} must not become a clamped usage value"
678            );
679        }
680    }
681
682    #[test]
683    fn plan_hint_used_when_response_omits_plan_type() {
684        let r: UsageResponse = serde_json::from_str("{}").unwrap();
685        let s = r.into_snapshot(Some("team")).unwrap();
686        assert_eq!(s.plan, "ChatGPT Team");
687    }
688
689    #[test]
690    fn window_counters_accept_fractional_percent_and_integral_number_forms() {
691        let w: Window =
692            serde_json::from_str(r#"{"used_percent":7.4,"limit_window_seconds":18000.0}"#).unwrap();
693        assert_eq!(w.used_percent, 7.4);
694        assert_eq!(w.limit_window_seconds, 18000);
695
696        let w: Window =
697            serde_json::from_str(r#"{"used_percent":"42.7","limit_window_seconds":"604800.0"}"#)
698                .unwrap();
699        assert_eq!(w.used_percent, 42.7);
700        assert_eq!(w.limit_window_seconds, 604800);
701
702        let r: UsageResponse = serde_json::from_str(
703            r#"{"rate_limit":{"primary_window":{"used_percent":42.7,"limit_window_seconds":18000}}}"#,
704        )
705        .unwrap();
706        assert_eq!(
707            r.into_snapshot(None)
708                .unwrap()
709                .session
710                .unwrap()
711                .utilization_pct,
712            43
713        );
714    }
715
716    #[test]
717    fn fractional_integer_counters_are_schema_drift() {
718        for value in ["18000.9", r#""604800.5""#] {
719            let body = format!(r#"{{"used_percent":7,"limit_window_seconds":{value}}}"#);
720            assert!(serde_json::from_str::<Window>(&body).is_err(), "{value}");
721        }
722    }
723
724    #[test]
725    fn null_counter_is_schema_drift() {
726        // `reset_at` is the only field the API is documented to omit, and it
727        // carries its own Option deserializer. A null counter is drift.
728        let body = r#"{"used_percent":null,"limit_window_seconds":1}"#;
729        assert!(serde_json::from_str::<Window>(body).is_err());
730        let body = r#"{"used_percent":1,"limit_window_seconds":null}"#;
731        assert!(serde_json::from_str::<Window>(body).is_err());
732    }
733
734    #[test]
735    fn non_numeric_counter_shapes_are_schema_drift() {
736        for bad in [
737            r#""many""#,
738            r#"{"value":1}"#,
739            "[1]",
740            "true",
741            // Each parses as an f64, but `as i64` saturates rather than
742            // failing, so it would coin a 0 / i64::MAX that reads as real.
743            r#""NaN""#,
744            r#""inf""#,
745            "1e300",
746            "-1e300",
747            r#""1e300""#,
748        ] {
749            let body = format!(r#"{{"used_percent":{bad},"limit_window_seconds":1}}"#);
750            assert!(
751                serde_json::from_str::<Window>(&body).is_err(),
752                "used_percent {bad} must not deserialize"
753            );
754        }
755    }
756
757    #[test]
758    fn drifted_counter_fails_whole_usage_response() {
759        // The error has to reach `parse_payload` so the widget shows `⚠`
760        // rather than caching and rendering a 0% bar.
761        let body = r#"{"plan_type":"plus","rate_limit":{
762            "primary_window":{"used_percent":"n/a","limit_window_seconds":18000}
763        }}"#;
764        assert!(serde_json::from_str::<UsageResponse>(body).is_err());
765    }
766
767    #[test]
768    fn a_present_window_requires_both_counters() {
769        for body in [
770            r#"{"used_percent":1}"#,
771            r#"{"limit_window_seconds":18000}"#,
772            "{}",
773        ] {
774            assert!(serde_json::from_str::<Window>(body).is_err(), "{body}");
775        }
776    }
777
778    #[test]
779    fn malformed_optional_counters_are_not_treated_as_absent() {
780        for field in ["reset_at", "reset_after_seconds"] {
781            for bad in ["true", r#""tomorrow""#, "1.5", "{}"] {
782                let body =
783                    format!(r#"{{"used_percent":1,"limit_window_seconds":18000,"{field}":{bad}}}"#);
784                assert!(serde_json::from_str::<Window>(&body).is_err(), "{body}");
785            }
786        }
787    }
788
789    #[test]
790    fn credits_reject_invalid_present_values_without_inventing_zero() {
791        for balance in ["true", "{}", "[]"] {
792            let body = format!(
793                r#"{{"credits":{{"balance":{balance},"has_credits":true,"unlimited":false}}}}"#
794            );
795            assert!(serde_json::from_str::<UsageResponse>(&body).is_err());
796        }
797        assert!(
798            serde_json::from_str::<UsageResponse>(r#"{"credits":{"balance":"$1.00"}}"#).is_err(),
799            "a present credits block must not default its status flags"
800        );
801
802        let response: UsageResponse = serde_json::from_str(
803            r#"{"credits":{"balance":null,"has_credits":false,"unlimited":true}}"#,
804        )
805        .unwrap();
806        assert_eq!(
807            response
808                .into_snapshot(None)
809                .unwrap()
810                .credits
811                .unwrap()
812                .balance,
813            ""
814        );
815    }
816
817    #[test]
818    fn oversized_window_seconds_degrades_instead_of_panicking() {
819        // i64::MAX is a faithful integer, so it clears the deserializer — but
820        // `chrono::Duration::seconds` panics on it, and a panicking widget
821        // exits non-zero and gets hidden by Waybar.
822        let body = r#"{"rate_limit":{"primary_window":{
823            "used_percent":1,"limit_window_seconds":9223372036854775807,
824            "reset_after_seconds":9223372036854775807
825        }}}"#;
826        let r: UsageResponse = serde_json::from_str(body).unwrap();
827        let s = r.into_snapshot(None).unwrap();
828        let session = s.session.unwrap();
829        assert_eq!(session.window_duration, chrono::Duration::hours(5));
830        assert!(session.resets_at.is_none());
831    }
832
833    #[test]
834    fn missing_reset_at_falls_back_to_after_seconds() {
835        let body = r#"{"rate_limit":{"primary_window":{
836            "used_percent":50,"limit_window_seconds":1000,"reset_after_seconds":500
837        }}}"#;
838        let r: UsageResponse = serde_json::from_str(body).unwrap();
839        let s = r.into_snapshot(None).unwrap();
840        // The reset should be ~500s from now (within tolerance).
841        let now = chrono::Utc::now();
842        let reset = s.session.unwrap().resets_at.unwrap();
843        let delta = reset.signed_duration_since(now).num_seconds();
844        assert!((400..=600).contains(&delta), "got delta={delta}");
845    }
846    /// The shape that prompted this: a real account whose headline window read
847    /// 5% while two named limits and a per-model availability flag went
848    /// unparsed entirely. Field names and nesting are from a live
849    /// `wham/usage` response; the numbers are made up.
850    #[test]
851    fn named_limits_and_unavailable_models_are_read_from_the_live_shape() {
852        let response: UsageResponse = serde_json::from_str(
853            r#"{
854              "plan_type": "pro",
855              "rate_limit": {
856                "allowed": true, "limit_reached": false,
857                "primary_window": {"used_percent": 5, "limit_window_seconds": 604800,
858                                   "reset_after_seconds": 400000, "reset_at": 1789000000},
859                "secondary_window": null
860              },
861              "code_review_rate_limit": null,
862              "additional_rate_limits": [
863                {"limit_name": "GPT-5.3-Codex-Spark", "metered_feature": "codex_bengalfox",
864                 "rate_limit": {
865                   "primary_window": {"used_percent": 12, "limit_window_seconds": 18000,
866                                      "reset_at": 1788000000},
867                   "secondary_window": {"used_percent": 34, "limit_window_seconds": 604800,
868                                        "reset_at": 1789500000}},
869                 "normal_model_slug": null},
870                {"limit_name": "gpt-reserve", "metered_feature": "base_model_inference",
871                 "rate_limit": {
872                   "primary_window": {"used_percent": 71, "limit_window_seconds": 604800,
873                                      "reset_at": 1789500000},
874                   "secondary_window": null}}
875              ],
876              "model_usage": {
877                "gpt-6-astra": {"available": false, "available_at": "2026-09-05T22:00:00Z",
878                                "credits_would_enable": false},
879                "gpt-5.3-codex": {"available": true, "available_at": null}
880              }
881            }"#,
882        )
883        .expect("the live response shape parses");
884
885        let snap = response.into_snapshot(None).unwrap();
886
887        // The headline window is unchanged and still low — which is the point:
888        // it is not what stopped the request.
889        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 5);
890
891        assert_eq!(snap.additional_limits.len(), 2);
892        let spark = &snap.additional_limits[0];
893        assert_eq!(spark.name, "GPT-5.3-Codex-Spark");
894        assert_eq!(spark.session.as_ref().unwrap().utilization_pct, 12);
895        assert_eq!(spark.weekly.as_ref().unwrap().utilization_pct, 34);
896        // Classified by duration, not wire position: a 7d in the primary slot
897        // is still the weekly one.
898        let reserve = &snap.additional_limits[1];
899        assert_eq!(reserve.name, "gpt-reserve");
900        assert!(reserve.session.is_none());
901        assert_eq!(reserve.weekly.as_ref().unwrap().utilization_pct, 71);
902
903        // Only the unavailable model is kept.
904        assert_eq!(snap.unavailable_models.len(), 1);
905        assert_eq!(snap.unavailable_models[0].model, "gpt-6-astra");
906        assert!(snap.unavailable_models[0].available_at.is_some());
907    }
908
909    /// An account with none of this — which is most of them — must look
910    /// exactly as it did before, not gain empty rows.
911    #[test]
912    fn an_account_without_extra_limits_reports_none_rather_than_empty_rows() {
913        let response: UsageResponse = serde_json::from_str(
914            r#"{"plan_type": "plus",
915                "rate_limit": {"primary_window": {"used_percent": 3,
916                               "limit_window_seconds": 604800}}}"#,
917        )
918        .unwrap();
919        let snap = response.into_snapshot(None).unwrap();
920
921        assert!(snap.additional_limits.is_empty());
922        assert!(snap.unavailable_models.is_empty());
923    }
924
925    /// A named limit with no usable window is dropped rather than drawn as a
926    /// nameless empty row, and one with no name at all falls back to the
927    /// metered feature before being dropped.
928    #[test]
929    fn nameless_or_windowless_limits_are_dropped() {
930        let response: UsageResponse = serde_json::from_str(
931            r#"{"additional_rate_limits": [
932                 {"limit_name": null, "metered_feature": "base_model_inference",
933                  "rate_limit": {"primary_window": {"used_percent": 9,
934                                 "limit_window_seconds": 604800}}},
935                 {"limit_name": "no windows", "rate_limit": {"primary_window": null,
936                                                             "secondary_window": null}},
937                 {"limit_name": "  ", "rate_limit": {"primary_window":
938                   {"used_percent": 1, "limit_window_seconds": 18000}}}
939               ]}"#,
940        )
941        .unwrap();
942        let snap = response.into_snapshot(None).unwrap();
943
944        assert_eq!(snap.additional_limits.len(), 1);
945        assert_eq!(snap.additional_limits[0].name, "base_model_inference");
946    }
947
948    /// `available` absent is "not stated", which is not the same as
949    /// unavailable — inventing a capacity warning is worse than staying quiet.
950    #[test]
951    fn a_model_without_an_availability_flag_is_not_reported_as_down() {
952        let response: UsageResponse =
953            serde_json::from_str(r#"{"model_usage": {"gpt-6-astra": {"available_at": null}}}"#)
954                .unwrap();
955        assert!(
956            response
957                .into_snapshot(None)
958                .unwrap()
959                .unavailable_models
960                .is_empty()
961        );
962    }
963
964    /// Live shape observed 2026-09-05: OpenAI returns explicit `null` for
965    /// empty collections instead of omitting them. `#[serde(default)]` alone
966    /// covers a missing field but still rejects `null` with "invalid type:
967    /// null, expected a sequence" — which surfaced as `⚠ API schema drift`
968    /// in Waybar. Null must mean "none", not drift.
969    /// The exact payload from the reports: every optional collection null at
970    /// once, including the nested `rate_limit_reset_credits.credits`. Three
971    /// people hit this within a day of 1.11.0, so the shape earns a test of
972    /// its own rather than only the per-field one below.
973    #[test]
974    fn the_reported_all_null_payload_parses() {
975        let response: UsageResponse = serde_json::from_str(
976            r#"{"plan_type":"plus",
977                "rate_limit":{"primary_window":{"used_percent":5,
978                              "limit_window_seconds":604800}},
979                "code_review_rate_limit":null,
980                "additional_rate_limits":null,
981                "model_usage":null,
982                "rate_limit_reset_credits":{"available_count":0,"credits":null}}"#,
983        )
984        .expect("the reported shape must parse");
985
986        let snap = response.into_snapshot(None).unwrap();
987        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 5);
988        assert!(snap.additional_limits.is_empty());
989        assert!(snap.unavailable_models.is_empty());
990    }
991
992    /// Null means "none", but a wrong *type* is still drift. Reading a string
993    /// or a number as an empty collection would hide a real schema change
994    /// behind a plausible-looking empty panel.
995    #[test]
996    fn a_mistyped_collection_is_still_schema_drift() {
997        for bad in [
998            r#"{"additional_rate_limits": "none"}"#,
999            r#"{"additional_rate_limits": 0}"#,
1000            r#"{"model_usage": []}"#,
1001            r#"{"model_usage": "none"}"#,
1002        ] {
1003            assert!(
1004                serde_json::from_str::<UsageResponse>(bad).is_err(),
1005                "{bad} should not be read as empty"
1006            );
1007        }
1008    }
1009
1010    #[test]
1011    fn null_collections_parse_as_empty_rather_than_schema_drift() {
1012        let response: UsageResponse = serde_json::from_str(
1013            r#"{
1014                "plan_type": "plus",
1015                "rate_limit": {
1016                    "primary_window": {"used_percent": 0, "limit_window_seconds": 18000,
1017                                       "reset_after_seconds": 18000, "reset_at": 1788646037},
1018                    "secondary_window": {"used_percent": 64, "limit_window_seconds": 604800,
1019                                         "reset_after_seconds": 152957, "reset_at": 1788780993}
1020                },
1021                "code_review_rate_limit": null,
1022                "additional_rate_limits": null,
1023                "model_usage": null,
1024                "rate_limit_reset_credits": {"available_count": 3, "credits": null}
1025            }"#,
1026        )
1027        .expect("null collections must parse");
1028        let snap = response.into_snapshot(None).unwrap();
1029        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 0);
1030        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 64);
1031        assert!(snap.additional_limits.is_empty());
1032        assert!(snap.unavailable_models.is_empty());
1033        assert_eq!(snap.reset_credits.available, 3);
1034        assert!(snap.reset_credits.credits.is_empty());
1035    }
1036}