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