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//! }
18//! ```
19
20use serde::Deserialize;
21
22use crate::error::{AppError, Result as AppResult};
23use crate::usage::{OpenAiCredits, OpenAiSnapshot, OpenAiSource, UsageWindow};
24
25#[derive(Debug, Default, Clone, Deserialize)]
26#[serde(default)]
27pub struct UsageResponse {
28    pub plan_type: Option<String>,
29    pub rate_limit: Option<RateLimit>,
30    pub code_review_rate_limit: Option<RateLimit>,
31    pub credits: Option<CreditsBlock>,
32}
33
34#[derive(Debug, Default, Clone, Deserialize)]
35#[serde(default)]
36pub struct RateLimit {
37    pub primary_window: Option<Window>,
38    pub secondary_window: Option<Window>,
39}
40
41#[derive(Debug, Clone, Deserialize)]
42pub struct Window {
43    #[serde(deserialize_with = "de_percent_number_or_string")]
44    pub used_percent: f64,
45    #[serde(deserialize_with = "de_i64_number_or_string")]
46    pub limit_window_seconds: i64,
47    /// Unix seconds. May be absent on older Codex CLIs.
48    #[serde(default, deserialize_with = "de_opt_int_or_float")]
49    pub reset_at: Option<i64>,
50    /// Fallback when `reset_at` is absent. Unix seconds offset from "now".
51    #[serde(default, deserialize_with = "de_opt_int_or_float")]
52    pub reset_after_seconds: Option<i64>,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56pub struct CreditsBlock {
57    #[serde(default, deserialize_with = "de_opt_money_string")]
58    pub balance: Option<String>,
59    pub has_credits: bool,
60    pub unlimited: bool,
61    #[serde(default)]
62    pub approx_local_messages: Option<Vec<i64>>,
63    #[serde(default)]
64    pub approx_cloud_messages: Option<Vec<i64>>,
65}
66
67/// Accept a JSON number or numeric string without turning malformed, non-finite
68/// or out-of-range values into plausible counters. `fetch_usage` validates
69/// before writing the cache, so a fabricated value here would be persisted and
70/// rendered as genuine usage.
71fn numeric_value<E: serde::de::Error>(v: serde_json::Value) -> Result<f64, E> {
72    let value = match v {
73        serde_json::Value::Number(n) => n
74            .as_f64()
75            .ok_or_else(|| E::custom("number is not representable as f64"))?,
76        serde_json::Value::String(s) => s
77            .parse::<f64>()
78            .map_err(|_| E::custom(format!("expected numeric string, got {s:?}")))?,
79        other => {
80            return Err(E::custom(format!(
81                "expected number or numeric string, got {other:?}"
82            )));
83        }
84    };
85    if value.is_finite() {
86        Ok(value)
87    } else {
88        Err(E::custom("number is not finite"))
89    }
90}
91
92fn de_percent_number_or_string<'de, D>(d: D) -> Result<f64, D::Error>
93where
94    D: serde::Deserializer<'de>,
95{
96    let v = serde_json::Value::deserialize(d)?;
97    let value = numeric_value::<D::Error>(v)?;
98    if (0.0..=101.0).contains(&value) {
99        Ok(value)
100    } else {
101        Err(serde::de::Error::custom(format!(
102            "percentage {value} outside 0..=100"
103        )))
104    }
105}
106
107fn de_i64_number_or_string<'de, D>(d: D) -> Result<i64, D::Error>
108where
109    D: serde::Deserializer<'de>,
110{
111    i64_value(serde_json::Value::deserialize(d)?)
112}
113
114fn i64_value<E: serde::de::Error>(v: serde_json::Value) -> Result<i64, E> {
115    match &v {
116        serde_json::Value::Number(n) => {
117            if let Some(i) = n.as_i64() {
118                return Ok(i);
119            }
120        }
121        serde_json::Value::String(s) => {
122            if let Ok(i) = s.parse::<i64>() {
123                return Ok(i);
124            }
125        }
126        _ => {}
127    }
128    exact_i64(numeric_value::<E>(v)?).ok_or_else(|| E::custom("expected an integer in i64 range"))
129}
130
131/// `f as i64` saturates instead of failing, so `NaN` would coin a `0` and
132/// `1e300` an `i64::MAX` — both indistinguishable from a counter the API
133/// really sent. Only an integral magnitude that an `f64` represents exactly
134/// survives; timestamps and window lengths cannot silently lose a fraction or
135/// low bit. Plain JSON/string integers take the exact `i64` path above.
136fn exact_i64(f: f64) -> Option<i64> {
137    const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
138    if f.is_finite() && f.trunc() == f && f.abs() <= MAX_EXACT_F64_INT {
139        Some(f as i64)
140    } else {
141        None
142    }
143}
144
145fn de_opt_int_or_float<'de, D>(d: D) -> Result<Option<i64>, D::Error>
146where
147    D: serde::Deserializer<'de>,
148{
149    let v = serde_json::Value::deserialize(d)?;
150    if v.is_null() {
151        Ok(None)
152    } else {
153        i64_value::<D::Error>(v).map(Some)
154    }
155}
156
157/// Accept either a string ("$0.00") or a finite number (0.0) — codexbar
158/// treats both. Null and an omitted field mean that no balance was supplied.
159fn de_opt_money_string<'de, D>(d: D) -> Result<Option<String>, D::Error>
160where
161    D: serde::Deserializer<'de>,
162{
163    let v = serde_json::Value::deserialize(d)?;
164    match v {
165        serde_json::Value::Null => Ok(None),
166        serde_json::Value::String(s) => Ok(Some(s)),
167        serde_json::Value::Number(n) => match n.as_f64() {
168            Some(value) if value.is_finite() => Ok(Some(crate::format::usd(value))),
169            _ => Err(serde::de::Error::custom(
170                "credit balance is not a finite number",
171            )),
172        },
173        other => Err(serde::de::Error::custom(format!(
174            "expected credit balance string, number, or null; got {other:?}"
175        ))),
176    }
177}
178
179impl UsageResponse {
180    pub fn into_snapshot(self, plan_hint: Option<&str>) -> AppResult<OpenAiSnapshot> {
181        let plan_type = self.plan_type.as_deref().or(plan_hint).unwrap_or("Unknown");
182        let plan = format!("ChatGPT {}", crate::format::capitalize(plan_type));
183
184        let (session, weekly) = classify_rate_limit(self.rate_limit.unwrap_or_default())?;
185        let code_review = self
186            .code_review_rate_limit
187            .and_then(|c| c.primary_window)
188            .map(|w| to_window(&w, chrono::Duration::days(7)));
189
190        let credits = self.credits.map(|c| OpenAiCredits {
191            balance: c.balance.unwrap_or_default(),
192            has_credits: c.has_credits,
193            unlimited: c.unlimited,
194            approx_local_messages: range_from_vec(c.approx_local_messages),
195            approx_cloud_messages: range_from_vec(c.approx_cloud_messages),
196        });
197
198        Ok(OpenAiSnapshot {
199            plan,
200            session,
201            weekly,
202            code_review,
203            credits,
204            source: OpenAiSource::CodexOauth,
205        })
206    }
207}
208
209#[derive(Clone, Copy, Debug)]
210enum WindowKind {
211    Session,
212    Weekly,
213}
214
215/// `limit_window_seconds` value the Codex API reports for the 5-hour window.
216pub(crate) const SESSION_WINDOW_SECS: u64 = 18_000;
217/// `limit_window_seconds` value the Codex API reports for the 7-day window.
218pub(crate) const WEEKLY_WINDOW_SECS: u64 = 604_800;
219
220fn classify_rate_limit(
221    rate_limit: RateLimit,
222) -> AppResult<(Option<UsageWindow>, Option<UsageWindow>)> {
223    let mut session = None;
224    let mut weekly = None;
225    insert_window(
226        rate_limit.primary_window,
227        WindowKind::Session,
228        &mut session,
229        &mut weekly,
230    )?;
231    insert_window(
232        rate_limit.secondary_window,
233        WindowKind::Weekly,
234        &mut session,
235        &mut weekly,
236    )?;
237    Ok((session, weekly))
238}
239
240fn insert_window(
241    wire_window: Option<Window>,
242    fallback_kind: WindowKind,
243    session: &mut Option<UsageWindow>,
244    weekly: &mut Option<UsageWindow>,
245) -> AppResult<()> {
246    let Some(wire_window) = wire_window else {
247        return Ok(());
248    };
249    let kind = window_kind(&wire_window).unwrap_or(fallback_kind);
250    let default_duration = kind.default_duration();
251    let target = semantic_window_target(kind, session, weekly);
252    if target.is_some() {
253        return Err(duplicate_window_error(
254            kind,
255            wire_window.limit_window_seconds,
256        ));
257    }
258    *target = Some(to_window(&wire_window, default_duration));
259    Ok(())
260}
261
262fn semantic_window_target<'a>(
263    kind: WindowKind,
264    session: &'a mut Option<UsageWindow>,
265    weekly: &'a mut Option<UsageWindow>,
266) -> &'a mut Option<UsageWindow> {
267    match kind {
268        WindowKind::Session => session,
269        WindowKind::Weekly => weekly,
270    }
271}
272
273fn window_kind(window: &Window) -> Option<WindowKind> {
274    // OpenAI temporarily moved the 7d window into `primary_window` and omitted
275    // `secondary_window`; wire position is not semantic (openai/codex#32707).
276    match window.limit_window_seconds {
277        s if s == SESSION_WINDOW_SECS as i64 => Some(WindowKind::Session),
278        s if s == WEEKLY_WINDOW_SECS as i64 => Some(WindowKind::Weekly),
279        _ => None,
280    }
281}
282
283fn duplicate_window_error(kind: WindowKind, seconds: i64) -> AppError {
284    let label = match kind {
285        WindowKind::Session => "5h",
286        WindowKind::Weekly => "7d",
287    };
288    AppError::Schema(format!(
289        "duplicate OpenAI {label} window with limit_window_seconds={seconds}; expected at most one 5h and one 7d window"
290    ))
291}
292
293impl WindowKind {
294    fn default_duration(self) -> chrono::Duration {
295        match self {
296            Self::Session => chrono::Duration::seconds(SESSION_WINDOW_SECS as i64),
297            Self::Weekly => chrono::Duration::seconds(WEEKLY_WINDOW_SECS as i64),
298        }
299    }
300}
301
302fn to_window(w: &Window, default_dur: chrono::Duration) -> UsageWindow {
303    // `Duration::seconds` panics past ~1e16, and the widget must always exit 0
304    // — so an absurd counter degrades to the caller's default, never a crash.
305    let dur = match chrono::Duration::try_seconds(w.limit_window_seconds) {
306        Some(d) if w.limit_window_seconds > 0 => d,
307        _ => default_dur,
308    };
309    let resets_at = match w.reset_at {
310        Some(secs) => chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0),
311        None => w
312            .reset_after_seconds
313            .and_then(chrono::Duration::try_seconds)
314            .and_then(|d| chrono::Utc::now().checked_add_signed(d)),
315    };
316    UsageWindow {
317        utilization_pct: (w.used_percent.round() as i32).clamp(0, 100),
318        resets_at,
319        window_duration: dur,
320    }
321}
322
323fn range_from_vec(v: Option<Vec<i64>>) -> Option<(i64, i64)> {
324    let v = v?;
325    if v.len() >= 2 {
326        Some((v[0], v[1]))
327    } else if v.len() == 1 {
328        Some((v[0], v[0]))
329    } else {
330        None
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    const REAL: &str = r#"{
339        "user_id":"u","account_id":"a","email":"e",
340        "plan_type":"plus",
341        "rate_limit":{"allowed":true,"limit_reached":false,
342            "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_after_seconds":18000,"reset_at":1779597324},
343            "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_after_seconds":604800,"reset_at":1780184124}
344        }
345    }"#;
346
347    #[test]
348    fn parses_real_shape() {
349        let r: UsageResponse = serde_json::from_str(REAL).unwrap();
350        let s = r.into_snapshot(None).unwrap();
351        assert_eq!(s.plan, "ChatGPT Plus");
352        assert_eq!(s.session.as_ref().unwrap().utilization_pct, 1);
353        assert_eq!(s.weekly.as_ref().unwrap().utilization_pct, 0);
354        assert_eq!(
355            s.session.as_ref().unwrap().window_duration,
356            chrono::Duration::hours(5)
357        );
358        assert_eq!(
359            s.weekly.as_ref().unwrap().window_duration,
360            chrono::Duration::days(7)
361        );
362        assert!(s.session.as_ref().unwrap().resets_at.is_some());
363        assert!(s.code_review.is_none());
364        assert!(s.credits.is_none());
365        assert!(matches!(s.source, OpenAiSource::CodexOauth));
366    }
367
368    #[test]
369    fn missing_rate_limit_reports_no_windows() {
370        let r: UsageResponse = serde_json::from_str(r#"{"plan_type":"pro"}"#).unwrap();
371        let s = r.into_snapshot(None).unwrap();
372        assert_eq!(s.plan, "ChatGPT Pro");
373        assert!(s.session.is_none());
374        assert!(s.weekly.is_none());
375    }
376
377    #[test]
378    fn weekly_only_primary_window_is_not_mislabeled_as_session() {
379        // Sanitized live response captured 2026-07-23 during OpenAI's
380        // temporary weekly-only rollout (openai/codex#32707).
381        let body = r#"{
382            "plan_type":"prolite",
383            "rate_limit":{
384                "primary_window":{
385                    "used_percent":66,
386                    "limit_window_seconds":604800,
387                    "reset_at":1785261834
388                },
389                "secondary_window":null
390            }
391        }"#;
392        let response: UsageResponse = serde_json::from_str(body).unwrap();
393        let snapshot = response.into_snapshot(None).unwrap();
394        assert!(snapshot.session.is_none());
395        assert_eq!(snapshot.weekly.unwrap().utilization_pct, 66);
396    }
397
398    #[test]
399    fn duration_classification_survives_reordered_wire_windows() {
400        let body = r#"{"rate_limit":{
401            "primary_window":{"used_percent":41,"limit_window_seconds":604800},
402            "secondary_window":{"used_percent":7,"limit_window_seconds":18000}
403        }}"#;
404        let response: UsageResponse = serde_json::from_str(body).unwrap();
405        let snapshot = response.into_snapshot(None).unwrap();
406        assert_eq!(snapshot.session.unwrap().utilization_pct, 7);
407        assert_eq!(snapshot.weekly.unwrap().utilization_pct, 41);
408    }
409
410    #[test]
411    fn duplicate_semantic_windows_are_schema_drift() {
412        let body = r#"{"rate_limit":{
413            "primary_window":{"used_percent":41,"limit_window_seconds":604800},
414            "secondary_window":{"used_percent":7,"limit_window_seconds":604800}
415        }}"#;
416        let response: UsageResponse = serde_json::from_str(body).unwrap();
417        let error = response.into_snapshot(None).unwrap_err().to_string();
418        assert!(error.contains("duplicate OpenAI 7d window"));
419        assert!(error.contains("limit_window_seconds=604800"));
420    }
421
422    #[test]
423    fn unknown_duration_falls_back_to_wire_position() {
424        // A `limit_window_seconds` value we do not recognize (e.g. 3600) is
425        // classified by wire position: `primary_window` → session,
426        // `secondary_window` → weekly.
427        let body = r#"{"rate_limit":{
428            "primary_window":{"used_percent":10,"limit_window_seconds":3600},
429            "secondary_window":{"used_percent":20,"limit_window_seconds":3600}
430        }}"#;
431        let response: UsageResponse = serde_json::from_str(body).unwrap();
432        let snapshot = response.into_snapshot(None).unwrap();
433        assert_eq!(snapshot.session.unwrap().utilization_pct, 10);
434        assert_eq!(snapshot.weekly.unwrap().utilization_pct, 20);
435    }
436
437    #[test]
438    fn credits_block_parses_with_message_ranges() {
439        let body = r#"{
440            "plan_type":"plus",
441            "credits":{"balance":"$2.50","has_credits":true,"unlimited":false,
442                "approx_local_messages":[100,200],"approx_cloud_messages":[40,60]}
443        }"#;
444        let r: UsageResponse = serde_json::from_str(body).unwrap();
445        let s = r.into_snapshot(None).unwrap();
446        let c = s.credits.unwrap();
447        assert_eq!(c.balance, "$2.50");
448        assert!(c.has_credits);
449        assert_eq!(c.approx_local_messages, Some((100, 200)));
450        assert_eq!(c.approx_cloud_messages, Some((40, 60)));
451    }
452
453    #[test]
454    fn balance_as_number_formats_to_dollars() {
455        let body = r#"{"credits":{"balance":1.5,"has_credits":true,"unlimited":false}}"#;
456        let r: UsageResponse = serde_json::from_str(body).unwrap();
457        let s = r.into_snapshot(None).unwrap();
458        assert_eq!(s.credits.unwrap().balance, "$1.50");
459    }
460
461    #[test]
462    fn benign_percent_overshoot_clamps_to_hundred() {
463        let body =
464            r#"{"rate_limit":{"primary_window":{"used_percent":100.6,"limit_window_seconds":1}}}"#;
465        let r: UsageResponse = serde_json::from_str(body).unwrap();
466        let s = r.into_snapshot(None).unwrap();
467        assert_eq!(s.session.unwrap().utilization_pct, 100);
468    }
469
470    #[test]
471    fn out_of_range_percent_is_schema_drift() {
472        for used_percent in ["-1", "101.5", "250"] {
473            let body = format!(
474                r#"{{"rate_limit":{{"primary_window":{{"used_percent":{used_percent},"limit_window_seconds":1}}}}}}"#
475            );
476            assert!(
477                serde_json::from_str::<UsageResponse>(&body).is_err(),
478                "{used_percent} must not become a clamped usage value"
479            );
480        }
481    }
482
483    #[test]
484    fn plan_hint_used_when_response_omits_plan_type() {
485        let r: UsageResponse = serde_json::from_str("{}").unwrap();
486        let s = r.into_snapshot(Some("team")).unwrap();
487        assert_eq!(s.plan, "ChatGPT Team");
488    }
489
490    #[test]
491    fn window_counters_accept_fractional_percent_and_integral_number_forms() {
492        let w: Window =
493            serde_json::from_str(r#"{"used_percent":7.4,"limit_window_seconds":18000.0}"#).unwrap();
494        assert_eq!(w.used_percent, 7.4);
495        assert_eq!(w.limit_window_seconds, 18000);
496
497        let w: Window =
498            serde_json::from_str(r#"{"used_percent":"42.7","limit_window_seconds":"604800.0"}"#)
499                .unwrap();
500        assert_eq!(w.used_percent, 42.7);
501        assert_eq!(w.limit_window_seconds, 604800);
502
503        let r: UsageResponse = serde_json::from_str(
504            r#"{"rate_limit":{"primary_window":{"used_percent":42.7,"limit_window_seconds":18000}}}"#,
505        )
506        .unwrap();
507        assert_eq!(
508            r.into_snapshot(None)
509                .unwrap()
510                .session
511                .unwrap()
512                .utilization_pct,
513            43
514        );
515    }
516
517    #[test]
518    fn fractional_integer_counters_are_schema_drift() {
519        for value in ["18000.9", r#""604800.5""#] {
520            let body = format!(r#"{{"used_percent":7,"limit_window_seconds":{value}}}"#);
521            assert!(serde_json::from_str::<Window>(&body).is_err(), "{value}");
522        }
523    }
524
525    #[test]
526    fn null_counter_is_schema_drift() {
527        // `reset_at` is the only field the API is documented to omit, and it
528        // carries its own Option deserializer. A null counter is drift.
529        let body = r#"{"used_percent":null,"limit_window_seconds":1}"#;
530        assert!(serde_json::from_str::<Window>(body).is_err());
531        let body = r#"{"used_percent":1,"limit_window_seconds":null}"#;
532        assert!(serde_json::from_str::<Window>(body).is_err());
533    }
534
535    #[test]
536    fn non_numeric_counter_shapes_are_schema_drift() {
537        for bad in [
538            r#""many""#,
539            r#"{"value":1}"#,
540            "[1]",
541            "true",
542            // Each parses as an f64, but `as i64` saturates rather than
543            // failing, so it would coin a 0 / i64::MAX that reads as real.
544            r#""NaN""#,
545            r#""inf""#,
546            "1e300",
547            "-1e300",
548            r#""1e300""#,
549        ] {
550            let body = format!(r#"{{"used_percent":{bad},"limit_window_seconds":1}}"#);
551            assert!(
552                serde_json::from_str::<Window>(&body).is_err(),
553                "used_percent {bad} must not deserialize"
554            );
555        }
556    }
557
558    #[test]
559    fn drifted_counter_fails_whole_usage_response() {
560        // The error has to reach `parse_payload` so the widget shows `⚠`
561        // rather than caching and rendering a 0% bar.
562        let body = r#"{"plan_type":"plus","rate_limit":{
563            "primary_window":{"used_percent":"n/a","limit_window_seconds":18000}
564        }}"#;
565        assert!(serde_json::from_str::<UsageResponse>(body).is_err());
566    }
567
568    #[test]
569    fn a_present_window_requires_both_counters() {
570        for body in [
571            r#"{"used_percent":1}"#,
572            r#"{"limit_window_seconds":18000}"#,
573            "{}",
574        ] {
575            assert!(serde_json::from_str::<Window>(body).is_err(), "{body}");
576        }
577    }
578
579    #[test]
580    fn malformed_optional_counters_are_not_treated_as_absent() {
581        for field in ["reset_at", "reset_after_seconds"] {
582            for bad in ["true", r#""tomorrow""#, "1.5", "{}"] {
583                let body =
584                    format!(r#"{{"used_percent":1,"limit_window_seconds":18000,"{field}":{bad}}}"#);
585                assert!(serde_json::from_str::<Window>(&body).is_err(), "{body}");
586            }
587        }
588    }
589
590    #[test]
591    fn credits_reject_invalid_present_values_without_inventing_zero() {
592        for balance in ["true", "{}", "[]"] {
593            let body = format!(
594                r#"{{"credits":{{"balance":{balance},"has_credits":true,"unlimited":false}}}}"#
595            );
596            assert!(serde_json::from_str::<UsageResponse>(&body).is_err());
597        }
598        assert!(
599            serde_json::from_str::<UsageResponse>(r#"{"credits":{"balance":"$1.00"}}"#).is_err(),
600            "a present credits block must not default its status flags"
601        );
602
603        let response: UsageResponse = serde_json::from_str(
604            r#"{"credits":{"balance":null,"has_credits":false,"unlimited":true}}"#,
605        )
606        .unwrap();
607        assert_eq!(
608            response
609                .into_snapshot(None)
610                .unwrap()
611                .credits
612                .unwrap()
613                .balance,
614            ""
615        );
616    }
617
618    #[test]
619    fn oversized_window_seconds_degrades_instead_of_panicking() {
620        // i64::MAX is a faithful integer, so it clears the deserializer — but
621        // `chrono::Duration::seconds` panics on it, and a panicking widget
622        // exits non-zero and gets hidden by Waybar.
623        let body = r#"{"rate_limit":{"primary_window":{
624            "used_percent":1,"limit_window_seconds":9223372036854775807,
625            "reset_after_seconds":9223372036854775807
626        }}}"#;
627        let r: UsageResponse = serde_json::from_str(body).unwrap();
628        let s = r.into_snapshot(None).unwrap();
629        let session = s.session.unwrap();
630        assert_eq!(session.window_duration, chrono::Duration::hours(5));
631        assert!(session.resets_at.is_none());
632    }
633
634    #[test]
635    fn missing_reset_at_falls_back_to_after_seconds() {
636        let body = r#"{"rate_limit":{"primary_window":{
637            "used_percent":50,"limit_window_seconds":1000,"reset_after_seconds":500
638        }}}"#;
639        let r: UsageResponse = serde_json::from_str(body).unwrap();
640        let s = r.into_snapshot(None).unwrap();
641        // The reset should be ~500s from now (within tolerance).
642        let now = chrono::Utc::now();
643        let reset = s.session.unwrap().resets_at.unwrap();
644        let delta = reset.signed_duration_since(now).num_seconds();
645        assert!((400..=600).contains(&delta), "got delta={delta}");
646    }
647}