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, checked_reset_title,
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
261impl UsageResponse {
262    pub fn into_snapshot(self, plan_hint: Option<&str>) -> AppResult<OpenAiSnapshot> {
263        let plan_type = self.plan_type.as_deref().or(plan_hint).unwrap_or("Unknown");
264        let plan = format!("ChatGPT {}", crate::format::capitalize(plan_type));
265
266        let (session, weekly) = classify_rate_limit(self.rate_limit.unwrap_or_default())?;
267        let code_review = self
268            .code_review_rate_limit
269            .and_then(|c| c.primary_window)
270            .map(|w| to_window(&w, chrono::Duration::days(7)));
271
272        let credits = self.credits.map(|c| OpenAiCredits {
273            balance: c.balance.unwrap_or_default(),
274            has_credits: c.has_credits,
275            unlimited: c.unlimited,
276            approx_local_messages: range_from_vec(c.approx_local_messages),
277            approx_cloud_messages: range_from_vec(c.approx_cloud_messages),
278        });
279        let reset_credits = self
280            .rate_limit_reset_credits
281            .map(|credits| ResetCredits {
282                available: credits.available_count,
283                credits: credits
284                    .credits
285                    .into_iter()
286                    .filter(|credit| credit.status == "available")
287                    .map(|credit| BankedReset {
288                        title: checked_reset_title(credit.title),
289                        expires_at: credit.expires_at,
290                    })
291                    .collect(),
292            })
293            .unwrap_or_default();
294
295        let additional_limits = self
296            .additional_rate_limits
297            .into_iter()
298            .filter_map(named_limit)
299            .collect();
300        // Only the unavailable ones: a roster of working models is noise, and
301        // this list exists to name a refusal nothing else accounts for.
302        let unavailable_models = self
303            .model_usage
304            .into_iter()
305            .filter(|(_, usage)| usage.available == Some(false))
306            .map(|(model, usage)| OpenAiUnavailableModel {
307                model,
308                available_at: usage.available_at,
309            })
310            .collect();
311
312        Ok(OpenAiSnapshot {
313            plan,
314            session,
315            weekly,
316            code_review,
317            additional_limits,
318            unavailable_models,
319            credits,
320            reset_credits,
321            source: OpenAiSource::CodexOauth,
322        })
323    }
324}
325
326#[derive(Clone, Copy, Debug)]
327enum WindowKind {
328    Session,
329    Weekly,
330}
331
332/// `limit_window_seconds` value the Codex API reports for the 5-hour window.
333pub(crate) const SESSION_WINDOW_SECS: u64 = 18_000;
334/// `limit_window_seconds` value the Codex API reports for the 7-day window.
335pub(crate) const WEEKLY_WINDOW_SECS: u64 = 604_800;
336
337/// One named limit, or `None` when it carries no window we can show. Windows
338/// go through the same classifier as the main limit — identified by duration,
339/// not wire position — so a named 5h reads as a 5h everywhere.
340fn named_limit(entry: AdditionalRateLimit) -> Option<OpenAiNamedLimit> {
341    let name = entry
342        .limit_name
343        .or(entry.metered_feature)
344        .filter(|name| !name.trim().is_empty())?;
345    let (session, weekly) = classify_rate_limit(entry.rate_limit?).ok()?;
346    if session.is_none() && weekly.is_none() {
347        return None;
348    }
349    Some(OpenAiNamedLimit {
350        name: crate::display::sanitize_untrusted_field(&name),
351        session,
352        weekly,
353    })
354}
355
356fn classify_rate_limit(
357    rate_limit: RateLimit,
358) -> AppResult<(Option<UsageWindow>, Option<UsageWindow>)> {
359    let mut session = None;
360    let mut weekly = None;
361    insert_window(
362        rate_limit.primary_window,
363        WindowKind::Session,
364        &mut session,
365        &mut weekly,
366    )?;
367    insert_window(
368        rate_limit.secondary_window,
369        WindowKind::Weekly,
370        &mut session,
371        &mut weekly,
372    )?;
373    Ok((session, weekly))
374}
375
376fn insert_window(
377    wire_window: Option<Window>,
378    fallback_kind: WindowKind,
379    session: &mut Option<UsageWindow>,
380    weekly: &mut Option<UsageWindow>,
381) -> AppResult<()> {
382    let Some(wire_window) = wire_window else {
383        return Ok(());
384    };
385    let kind = window_kind(&wire_window).unwrap_or(fallback_kind);
386    let default_duration = kind.default_duration();
387    let target = semantic_window_target(kind, session, weekly);
388    if target.is_some() {
389        return Err(duplicate_window_error(
390            kind,
391            wire_window.limit_window_seconds,
392        ));
393    }
394    *target = Some(to_window(&wire_window, default_duration));
395    Ok(())
396}
397
398fn semantic_window_target<'a>(
399    kind: WindowKind,
400    session: &'a mut Option<UsageWindow>,
401    weekly: &'a mut Option<UsageWindow>,
402) -> &'a mut Option<UsageWindow> {
403    match kind {
404        WindowKind::Session => session,
405        WindowKind::Weekly => weekly,
406    }
407}
408
409fn window_kind(window: &Window) -> Option<WindowKind> {
410    // OpenAI temporarily moved the 7d window into `primary_window` and omitted
411    // `secondary_window`; wire position is not semantic (openai/codex#32707).
412    match window.limit_window_seconds {
413        s if s == SESSION_WINDOW_SECS as i64 => Some(WindowKind::Session),
414        s if s == WEEKLY_WINDOW_SECS as i64 => Some(WindowKind::Weekly),
415        _ => None,
416    }
417}
418
419fn duplicate_window_error(kind: WindowKind, seconds: i64) -> AppError {
420    let label = match kind {
421        WindowKind::Session => "5h",
422        WindowKind::Weekly => "7d",
423    };
424    AppError::Schema(format!(
425        "duplicate OpenAI {label} window with limit_window_seconds={seconds}; expected at most one 5h and one 7d window"
426    ))
427}
428
429impl WindowKind {
430    fn default_duration(self) -> chrono::Duration {
431        match self {
432            Self::Session => chrono::Duration::seconds(SESSION_WINDOW_SECS as i64),
433            Self::Weekly => chrono::Duration::seconds(WEEKLY_WINDOW_SECS as i64),
434        }
435    }
436}
437
438fn to_window(w: &Window, default_dur: chrono::Duration) -> UsageWindow {
439    // `Duration::seconds` panics past ~1e16, and the widget must always exit 0
440    // — so an absurd counter degrades to the caller's default, never a crash.
441    let dur = match chrono::Duration::try_seconds(w.limit_window_seconds) {
442        Some(d) if w.limit_window_seconds > 0 => d,
443        _ => default_dur,
444    };
445    let resets_at = match w.reset_at {
446        Some(secs) => chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0),
447        None => w
448            .reset_after_seconds
449            .and_then(chrono::Duration::try_seconds)
450            .and_then(|d| chrono::Utc::now().checked_add_signed(d)),
451    };
452    UsageWindow {
453        utilization_pct: i32::from(crate::format::clamp_pct(w.used_percent)),
454        resets_at,
455        window_duration: dur,
456    }
457}
458
459fn range_from_vec(v: Option<Vec<i64>>) -> Option<(i64, i64)> {
460    let v = v?;
461    if v.len() >= 2 {
462        Some((v[0], v[1]))
463    } else if v.len() == 1 {
464        Some((v[0], v[0]))
465    } else {
466        None
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    const REAL: &str = r#"{
475        "user_id":"u","account_id":"a","email":"e",
476        "plan_type":"plus",
477        "rate_limit":{"allowed":true,"limit_reached":false,
478            "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_after_seconds":18000,"reset_at":1779597324},
479            "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_after_seconds":604800,"reset_at":1780184124}
480        }
481    }"#;
482
483    #[test]
484    fn parses_real_shape() {
485        let r: UsageResponse = serde_json::from_str(REAL).unwrap();
486        let s = r.into_snapshot(None).unwrap();
487        assert_eq!(s.plan, "ChatGPT Plus");
488        assert_eq!(s.session.as_ref().unwrap().utilization_pct, 1);
489        assert_eq!(s.weekly.as_ref().unwrap().utilization_pct, 0);
490        assert_eq!(
491            s.session.as_ref().unwrap().window_duration,
492            chrono::Duration::hours(5)
493        );
494        assert_eq!(
495            s.weekly.as_ref().unwrap().window_duration,
496            chrono::Duration::days(7)
497        );
498        assert!(s.session.as_ref().unwrap().resets_at.is_some());
499        assert!(s.code_review.is_none());
500        assert!(s.credits.is_none());
501        assert!(matches!(s.source, OpenAiSource::CodexOauth));
502    }
503
504    #[test]
505    fn missing_rate_limit_reports_no_windows() {
506        let r: UsageResponse = serde_json::from_str(r#"{"plan_type":"pro"}"#).unwrap();
507        let s = r.into_snapshot(None).unwrap();
508        assert_eq!(s.plan, "ChatGPT Pro");
509        assert!(s.session.is_none());
510        assert!(s.weekly.is_none());
511    }
512
513    #[test]
514    fn weekly_only_primary_window_is_not_mislabeled_as_session() {
515        // Sanitized live response captured 2026-07-23 during OpenAI's
516        // temporary weekly-only rollout (openai/codex#32707).
517        let body = r#"{
518            "plan_type":"prolite",
519            "rate_limit":{
520                "primary_window":{
521                    "used_percent":66,
522                    "limit_window_seconds":604800,
523                    "reset_at":1785261834
524                },
525                "secondary_window":null
526            }
527        }"#;
528        let response: UsageResponse = serde_json::from_str(body).unwrap();
529        let snapshot = response.into_snapshot(None).unwrap();
530        assert!(snapshot.session.is_none());
531        assert_eq!(snapshot.weekly.unwrap().utilization_pct, 66);
532    }
533
534    #[test]
535    fn duration_classification_survives_reordered_wire_windows() {
536        let body = r#"{"rate_limit":{
537            "primary_window":{"used_percent":41,"limit_window_seconds":604800},
538            "secondary_window":{"used_percent":7,"limit_window_seconds":18000}
539        }}"#;
540        let response: UsageResponse = serde_json::from_str(body).unwrap();
541        let snapshot = response.into_snapshot(None).unwrap();
542        assert_eq!(snapshot.session.unwrap().utilization_pct, 7);
543        assert_eq!(snapshot.weekly.unwrap().utilization_pct, 41);
544    }
545
546    #[test]
547    fn duplicate_semantic_windows_are_schema_drift() {
548        let body = r#"{"rate_limit":{
549            "primary_window":{"used_percent":41,"limit_window_seconds":604800},
550            "secondary_window":{"used_percent":7,"limit_window_seconds":604800}
551        }}"#;
552        let response: UsageResponse = serde_json::from_str(body).unwrap();
553        let error = response.into_snapshot(None).unwrap_err().to_string();
554        assert!(error.contains("duplicate OpenAI 7d window"));
555        assert!(error.contains("limit_window_seconds=604800"));
556    }
557
558    #[test]
559    fn unknown_duration_falls_back_to_wire_position() {
560        // A `limit_window_seconds` value we do not recognize (e.g. 3600) is
561        // classified by wire position: `primary_window` → session,
562        // `secondary_window` → weekly.
563        let body = r#"{"rate_limit":{
564            "primary_window":{"used_percent":10,"limit_window_seconds":3600},
565            "secondary_window":{"used_percent":20,"limit_window_seconds":3600}
566        }}"#;
567        let response: UsageResponse = serde_json::from_str(body).unwrap();
568        let snapshot = response.into_snapshot(None).unwrap();
569        assert_eq!(snapshot.session.unwrap().utilization_pct, 10);
570        assert_eq!(snapshot.weekly.unwrap().utilization_pct, 20);
571    }
572
573    #[test]
574    fn credits_block_parses_with_message_ranges() {
575        let body = r#"{
576            "plan_type":"plus",
577            "credits":{"balance":"$2.50","has_credits":true,"unlimited":false,
578                "approx_local_messages":[100,200],"approx_cloud_messages":[40,60]}
579        }"#;
580        let r: UsageResponse = serde_json::from_str(body).unwrap();
581        let s = r.into_snapshot(None).unwrap();
582        let c = s.credits.unwrap();
583        assert_eq!(c.balance, "$2.50");
584        assert!(c.has_credits);
585        assert_eq!(c.approx_local_messages, Some((100, 200)));
586        assert_eq!(c.approx_cloud_messages, Some((40, 60)));
587    }
588
589    /// The count travels with the usage response; the per-credit detail only
590    /// arrives from the second endpoint, so a snapshot must be able to report
591    /// "2 available" with no expiry attached to either of them.
592    #[test]
593    fn reset_credit_count_stands_on_its_own_without_the_detail_call() {
594        let body = r#"{"plan_type":"plus","rate_limit_reset_credits":{"available_count":2}}"#;
595        let s: UsageResponse = serde_json::from_str(body).unwrap();
596        let s = s.into_snapshot(None).unwrap();
597        assert_eq!(s.reset_credits.available, 2);
598        assert!(s.reset_credits.credits.is_empty());
599        assert!(!s.reset_credits.is_empty());
600    }
601
602    /// A redeemed credit still appears in the detail list. Its expiry is not a
603    /// deadline the user can act on, so it must not become the next one shown.
604    #[test]
605    fn only_an_available_credit_contributes_an_expiry() {
606        let body = r#"{
607            "rate_limit_reset_credits":{
608                "available_count":1,
609                "credits":[
610                    {"id":"c1","status":"redeemed","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-07-01T00:00:00Z"},
611                    {"id":"c2","status":"available","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-07-17T00:00:00Z"},
612                    {"id":"c3","status":"available","expires_at":null}
613                ]
614            }
615        }"#;
616        let s: UsageResponse = serde_json::from_str(body).unwrap();
617        let s = s.into_snapshot(None).unwrap();
618        assert_eq!(s.reset_credits.available, 1);
619        assert_eq!(s.reset_credits.credits.len(), 2);
620        assert_eq!(
621            s.reset_credits.credits[0].title.as_deref(),
622            Some("Full reset (Weekly + 5 hr)")
623        );
624        assert_eq!(
625            s.reset_credits.next_expiry(),
626            Some("2026-07-17T00:00:00Z".parse::<DateTime<Utc>>().unwrap())
627        );
628    }
629
630    /// Every other vendor's absent block means "none". This one is load-bearing
631    /// in the same way: a response from an account with no banked resets, or
632    /// from a Codex build that predates them, reports none rather than failing.
633    #[test]
634    fn an_absent_reset_block_is_no_credits_rather_than_an_error() {
635        let s: UsageResponse = serde_json::from_str(r#"{"plan_type":"plus"}"#).unwrap();
636        assert!(s.into_snapshot(None).unwrap().reset_credits.is_empty());
637    }
638
639    #[test]
640    fn balance_as_number_formats_to_dollars() {
641        let body = r#"{"credits":{"balance":1.5,"has_credits":true,"unlimited":false}}"#;
642        let r: UsageResponse = serde_json::from_str(body).unwrap();
643        let s = r.into_snapshot(None).unwrap();
644        assert_eq!(s.credits.unwrap().balance, "$1.50");
645    }
646
647    #[test]
648    fn benign_percent_overshoot_clamps_to_hundred() {
649        let body =
650            r#"{"rate_limit":{"primary_window":{"used_percent":100.6,"limit_window_seconds":1}}}"#;
651        let r: UsageResponse = serde_json::from_str(body).unwrap();
652        let s = r.into_snapshot(None).unwrap();
653        assert_eq!(s.session.unwrap().utilization_pct, 100);
654    }
655
656    #[test]
657    fn out_of_range_percent_is_schema_drift() {
658        for used_percent in ["-1", "101.5", "250"] {
659            let body = format!(
660                r#"{{"rate_limit":{{"primary_window":{{"used_percent":{used_percent},"limit_window_seconds":1}}}}}}"#
661            );
662            assert!(
663                serde_json::from_str::<UsageResponse>(&body).is_err(),
664                "{used_percent} must not become a clamped usage value"
665            );
666        }
667    }
668
669    #[test]
670    fn plan_hint_used_when_response_omits_plan_type() {
671        let r: UsageResponse = serde_json::from_str("{}").unwrap();
672        let s = r.into_snapshot(Some("team")).unwrap();
673        assert_eq!(s.plan, "ChatGPT Team");
674    }
675
676    #[test]
677    fn window_counters_accept_fractional_percent_and_integral_number_forms() {
678        let w: Window =
679            serde_json::from_str(r#"{"used_percent":7.4,"limit_window_seconds":18000.0}"#).unwrap();
680        assert_eq!(w.used_percent, 7.4);
681        assert_eq!(w.limit_window_seconds, 18000);
682
683        let w: Window =
684            serde_json::from_str(r#"{"used_percent":"42.7","limit_window_seconds":"604800.0"}"#)
685                .unwrap();
686        assert_eq!(w.used_percent, 42.7);
687        assert_eq!(w.limit_window_seconds, 604800);
688
689        let r: UsageResponse = serde_json::from_str(
690            r#"{"rate_limit":{"primary_window":{"used_percent":42.7,"limit_window_seconds":18000}}}"#,
691        )
692        .unwrap();
693        assert_eq!(
694            r.into_snapshot(None)
695                .unwrap()
696                .session
697                .unwrap()
698                .utilization_pct,
699            43
700        );
701    }
702
703    #[test]
704    fn fractional_integer_counters_are_schema_drift() {
705        for value in ["18000.9", r#""604800.5""#] {
706            let body = format!(r#"{{"used_percent":7,"limit_window_seconds":{value}}}"#);
707            assert!(serde_json::from_str::<Window>(&body).is_err(), "{value}");
708        }
709    }
710
711    #[test]
712    fn null_counter_is_schema_drift() {
713        // `reset_at` is the only field the API is documented to omit, and it
714        // carries its own Option deserializer. A null counter is drift.
715        let body = r#"{"used_percent":null,"limit_window_seconds":1}"#;
716        assert!(serde_json::from_str::<Window>(body).is_err());
717        let body = r#"{"used_percent":1,"limit_window_seconds":null}"#;
718        assert!(serde_json::from_str::<Window>(body).is_err());
719    }
720
721    #[test]
722    fn non_numeric_counter_shapes_are_schema_drift() {
723        for bad in [
724            r#""many""#,
725            r#"{"value":1}"#,
726            "[1]",
727            "true",
728            // Each parses as an f64, but `as i64` saturates rather than
729            // failing, so it would coin a 0 / i64::MAX that reads as real.
730            r#""NaN""#,
731            r#""inf""#,
732            "1e300",
733            "-1e300",
734            r#""1e300""#,
735        ] {
736            let body = format!(r#"{{"used_percent":{bad},"limit_window_seconds":1}}"#);
737            assert!(
738                serde_json::from_str::<Window>(&body).is_err(),
739                "used_percent {bad} must not deserialize"
740            );
741        }
742    }
743
744    #[test]
745    fn drifted_counter_fails_whole_usage_response() {
746        // The error has to reach `parse_payload` so the widget shows `⚠`
747        // rather than caching and rendering a 0% bar.
748        let body = r#"{"plan_type":"plus","rate_limit":{
749            "primary_window":{"used_percent":"n/a","limit_window_seconds":18000}
750        }}"#;
751        assert!(serde_json::from_str::<UsageResponse>(body).is_err());
752    }
753
754    #[test]
755    fn a_present_window_requires_both_counters() {
756        for body in [
757            r#"{"used_percent":1}"#,
758            r#"{"limit_window_seconds":18000}"#,
759            "{}",
760        ] {
761            assert!(serde_json::from_str::<Window>(body).is_err(), "{body}");
762        }
763    }
764
765    #[test]
766    fn malformed_optional_counters_are_not_treated_as_absent() {
767        for field in ["reset_at", "reset_after_seconds"] {
768            for bad in ["true", r#""tomorrow""#, "1.5", "{}"] {
769                let body =
770                    format!(r#"{{"used_percent":1,"limit_window_seconds":18000,"{field}":{bad}}}"#);
771                assert!(serde_json::from_str::<Window>(&body).is_err(), "{body}");
772            }
773        }
774    }
775
776    #[test]
777    fn credits_reject_invalid_present_values_without_inventing_zero() {
778        for balance in ["true", "{}", "[]"] {
779            let body = format!(
780                r#"{{"credits":{{"balance":{balance},"has_credits":true,"unlimited":false}}}}"#
781            );
782            assert!(serde_json::from_str::<UsageResponse>(&body).is_err());
783        }
784        assert!(
785            serde_json::from_str::<UsageResponse>(r#"{"credits":{"balance":"$1.00"}}"#).is_err(),
786            "a present credits block must not default its status flags"
787        );
788
789        let response: UsageResponse = serde_json::from_str(
790            r#"{"credits":{"balance":null,"has_credits":false,"unlimited":true}}"#,
791        )
792        .unwrap();
793        assert_eq!(
794            response
795                .into_snapshot(None)
796                .unwrap()
797                .credits
798                .unwrap()
799                .balance,
800            ""
801        );
802    }
803
804    #[test]
805    fn oversized_window_seconds_degrades_instead_of_panicking() {
806        // i64::MAX is a faithful integer, so it clears the deserializer — but
807        // `chrono::Duration::seconds` panics on it, and a panicking widget
808        // exits non-zero and gets hidden by Waybar.
809        let body = r#"{"rate_limit":{"primary_window":{
810            "used_percent":1,"limit_window_seconds":9223372036854775807,
811            "reset_after_seconds":9223372036854775807
812        }}}"#;
813        let r: UsageResponse = serde_json::from_str(body).unwrap();
814        let s = r.into_snapshot(None).unwrap();
815        let session = s.session.unwrap();
816        assert_eq!(session.window_duration, chrono::Duration::hours(5));
817        assert!(session.resets_at.is_none());
818    }
819
820    #[test]
821    fn missing_reset_at_falls_back_to_after_seconds() {
822        let body = r#"{"rate_limit":{"primary_window":{
823            "used_percent":50,"limit_window_seconds":1000,"reset_after_seconds":500
824        }}}"#;
825        let r: UsageResponse = serde_json::from_str(body).unwrap();
826        let s = r.into_snapshot(None).unwrap();
827        // The reset should be ~500s from now (within tolerance).
828        let now = chrono::Utc::now();
829        let reset = s.session.unwrap().resets_at.unwrap();
830        let delta = reset.signed_duration_since(now).num_seconds();
831        assert!((400..=600).contains(&delta), "got delta={delta}");
832    }
833    /// The shape that prompted this: a real account whose headline window read
834    /// 5% while two named limits and a per-model availability flag went
835    /// unparsed entirely. Field names and nesting are from a live
836    /// `wham/usage` response; the numbers are made up.
837    #[test]
838    fn named_limits_and_unavailable_models_are_read_from_the_live_shape() {
839        let response: UsageResponse = serde_json::from_str(
840            r#"{
841              "plan_type": "pro",
842              "rate_limit": {
843                "allowed": true, "limit_reached": false,
844                "primary_window": {"used_percent": 5, "limit_window_seconds": 604800,
845                                   "reset_after_seconds": 400000, "reset_at": 1789000000},
846                "secondary_window": null
847              },
848              "code_review_rate_limit": null,
849              "additional_rate_limits": [
850                {"limit_name": "GPT-5.3-Codex-Spark", "metered_feature": "codex_bengalfox",
851                 "rate_limit": {
852                   "primary_window": {"used_percent": 12, "limit_window_seconds": 18000,
853                                      "reset_at": 1788000000},
854                   "secondary_window": {"used_percent": 34, "limit_window_seconds": 604800,
855                                        "reset_at": 1789500000}},
856                 "normal_model_slug": null},
857                {"limit_name": "gpt-reserve", "metered_feature": "base_model_inference",
858                 "rate_limit": {
859                   "primary_window": {"used_percent": 71, "limit_window_seconds": 604800,
860                                      "reset_at": 1789500000},
861                   "secondary_window": null}}
862              ],
863              "model_usage": {
864                "gpt-6-astra": {"available": false, "available_at": "2026-09-05T22:00:00Z",
865                                "credits_would_enable": false},
866                "gpt-5.3-codex": {"available": true, "available_at": null}
867              }
868            }"#,
869        )
870        .expect("the live response shape parses");
871
872        let snap = response.into_snapshot(None).unwrap();
873
874        // The headline window is unchanged and still low — which is the point:
875        // it is not what stopped the request.
876        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 5);
877
878        assert_eq!(snap.additional_limits.len(), 2);
879        let spark = &snap.additional_limits[0];
880        assert_eq!(spark.name, "GPT-5.3-Codex-Spark");
881        assert_eq!(spark.session.as_ref().unwrap().utilization_pct, 12);
882        assert_eq!(spark.weekly.as_ref().unwrap().utilization_pct, 34);
883        // Classified by duration, not wire position: a 7d in the primary slot
884        // is still the weekly one.
885        let reserve = &snap.additional_limits[1];
886        assert_eq!(reserve.name, "gpt-reserve");
887        assert!(reserve.session.is_none());
888        assert_eq!(reserve.weekly.as_ref().unwrap().utilization_pct, 71);
889
890        // Only the unavailable model is kept.
891        assert_eq!(snap.unavailable_models.len(), 1);
892        assert_eq!(snap.unavailable_models[0].model, "gpt-6-astra");
893        assert!(snap.unavailable_models[0].available_at.is_some());
894    }
895
896    /// An account with none of this — which is most of them — must look
897    /// exactly as it did before, not gain empty rows.
898    #[test]
899    fn an_account_without_extra_limits_reports_none_rather_than_empty_rows() {
900        let response: UsageResponse = serde_json::from_str(
901            r#"{"plan_type": "plus",
902                "rate_limit": {"primary_window": {"used_percent": 3,
903                               "limit_window_seconds": 604800}}}"#,
904        )
905        .unwrap();
906        let snap = response.into_snapshot(None).unwrap();
907
908        assert!(snap.additional_limits.is_empty());
909        assert!(snap.unavailable_models.is_empty());
910    }
911
912    /// A named limit with no usable window is dropped rather than drawn as a
913    /// nameless empty row, and one with no name at all falls back to the
914    /// metered feature before being dropped.
915    #[test]
916    fn nameless_or_windowless_limits_are_dropped() {
917        let response: UsageResponse = serde_json::from_str(
918            r#"{"additional_rate_limits": [
919                 {"limit_name": null, "metered_feature": "base_model_inference",
920                  "rate_limit": {"primary_window": {"used_percent": 9,
921                                 "limit_window_seconds": 604800}}},
922                 {"limit_name": "no windows", "rate_limit": {"primary_window": null,
923                                                             "secondary_window": null}},
924                 {"limit_name": "  ", "rate_limit": {"primary_window":
925                   {"used_percent": 1, "limit_window_seconds": 18000}}}
926               ]}"#,
927        )
928        .unwrap();
929        let snap = response.into_snapshot(None).unwrap();
930
931        assert_eq!(snap.additional_limits.len(), 1);
932        assert_eq!(snap.additional_limits[0].name, "base_model_inference");
933    }
934
935    /// `available` absent is "not stated", which is not the same as
936    /// unavailable — inventing a capacity warning is worse than staying quiet.
937    #[test]
938    fn a_model_without_an_availability_flag_is_not_reported_as_down() {
939        let response: UsageResponse =
940            serde_json::from_str(r#"{"model_usage": {"gpt-6-astra": {"available_at": null}}}"#)
941                .unwrap();
942        assert!(
943            response
944                .into_snapshot(None)
945                .unwrap()
946                .unavailable_models
947                .is_empty()
948        );
949    }
950
951    /// Live shape observed 2026-09-05: OpenAI returns explicit `null` for
952    /// empty collections instead of omitting them. `#[serde(default)]` alone
953    /// covers a missing field but still rejects `null` with "invalid type:
954    /// null, expected a sequence" — which surfaced as `⚠ API schema drift`
955    /// in Waybar. Null must mean "none", not drift.
956    /// The exact payload from the reports: every optional collection null at
957    /// once, including the nested `rate_limit_reset_credits.credits`. Three
958    /// people hit this within a day of 1.11.0, so the shape earns a test of
959    /// its own rather than only the per-field one below.
960    #[test]
961    fn the_reported_all_null_payload_parses() {
962        let response: UsageResponse = serde_json::from_str(
963            r#"{"plan_type":"plus",
964                "rate_limit":{"primary_window":{"used_percent":5,
965                              "limit_window_seconds":604800}},
966                "code_review_rate_limit":null,
967                "additional_rate_limits":null,
968                "model_usage":null,
969                "rate_limit_reset_credits":{"available_count":0,"credits":null}}"#,
970        )
971        .expect("the reported shape must parse");
972
973        let snap = response.into_snapshot(None).unwrap();
974        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 5);
975        assert!(snap.additional_limits.is_empty());
976        assert!(snap.unavailable_models.is_empty());
977    }
978
979    /// Null means "none", but a wrong *type* is still drift. Reading a string
980    /// or a number as an empty collection would hide a real schema change
981    /// behind a plausible-looking empty panel.
982    #[test]
983    fn a_mistyped_collection_is_still_schema_drift() {
984        for bad in [
985            r#"{"additional_rate_limits": "none"}"#,
986            r#"{"additional_rate_limits": 0}"#,
987            r#"{"model_usage": []}"#,
988            r#"{"model_usage": "none"}"#,
989        ] {
990            assert!(
991                serde_json::from_str::<UsageResponse>(bad).is_err(),
992                "{bad} should not be read as empty"
993            );
994        }
995    }
996
997    #[test]
998    fn null_collections_parse_as_empty_rather_than_schema_drift() {
999        let response: UsageResponse = serde_json::from_str(
1000            r#"{
1001                "plan_type": "plus",
1002                "rate_limit": {
1003                    "primary_window": {"used_percent": 0, "limit_window_seconds": 18000,
1004                                       "reset_after_seconds": 18000, "reset_at": 1788646037},
1005                    "secondary_window": {"used_percent": 64, "limit_window_seconds": 604800,
1006                                         "reset_after_seconds": 152957, "reset_at": 1788780993}
1007                },
1008                "code_review_rate_limit": null,
1009                "additional_rate_limits": null,
1010                "model_usage": null,
1011                "rate_limit_reset_credits": {"available_count": 3, "credits": null}
1012            }"#,
1013        )
1014        .expect("null collections must parse");
1015        let snap = response.into_snapshot(None).unwrap();
1016        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 0);
1017        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 64);
1018        assert!(snap.additional_limits.is_empty());
1019        assert!(snap.unavailable_models.is_empty());
1020        assert_eq!(snap.reset_credits.available, 3);
1021        assert!(snap.reset_credits.credits.is_empty());
1022    }
1023}