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