Skip to main content

ai_usagebar/openai/
types.rs

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