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(format!("${value:.2}"))),
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 {}", 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
334fn capitalize(s: &str) -> String {
335    let mut chars = s.chars();
336    match chars.next() {
337        Some(c) => {
338            let mut out = String::with_capacity(s.len());
339            for u in c.to_uppercase() {
340                out.push(u);
341            }
342            out.push_str(chars.as_str());
343            out
344        }
345        None => String::new(),
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    const REAL: &str = r#"{
354        "user_id":"u","account_id":"a","email":"e",
355        "plan_type":"plus",
356        "rate_limit":{"allowed":true,"limit_reached":false,
357            "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_after_seconds":18000,"reset_at":1779597324},
358            "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_after_seconds":604800,"reset_at":1780184124}
359        }
360    }"#;
361
362    #[test]
363    fn parses_real_shape() {
364        let r: UsageResponse = serde_json::from_str(REAL).unwrap();
365        let s = r.into_snapshot(None).unwrap();
366        assert_eq!(s.plan, "ChatGPT Plus");
367        assert_eq!(s.session.as_ref().unwrap().utilization_pct, 1);
368        assert_eq!(s.weekly.as_ref().unwrap().utilization_pct, 0);
369        assert_eq!(
370            s.session.as_ref().unwrap().window_duration,
371            chrono::Duration::hours(5)
372        );
373        assert_eq!(
374            s.weekly.as_ref().unwrap().window_duration,
375            chrono::Duration::days(7)
376        );
377        assert!(s.session.as_ref().unwrap().resets_at.is_some());
378        assert!(s.code_review.is_none());
379        assert!(s.credits.is_none());
380        assert!(matches!(s.source, OpenAiSource::CodexOauth));
381    }
382
383    #[test]
384    fn missing_rate_limit_reports_no_windows() {
385        let r: UsageResponse = serde_json::from_str(r#"{"plan_type":"pro"}"#).unwrap();
386        let s = r.into_snapshot(None).unwrap();
387        assert_eq!(s.plan, "ChatGPT Pro");
388        assert!(s.session.is_none());
389        assert!(s.weekly.is_none());
390    }
391
392    #[test]
393    fn weekly_only_primary_window_is_not_mislabeled_as_session() {
394        // Sanitized live response captured 2026-07-23 during OpenAI's
395        // temporary weekly-only rollout (openai/codex#32707).
396        let body = r#"{
397            "plan_type":"prolite",
398            "rate_limit":{
399                "primary_window":{
400                    "used_percent":66,
401                    "limit_window_seconds":604800,
402                    "reset_at":1785261834
403                },
404                "secondary_window":null
405            }
406        }"#;
407        let response: UsageResponse = serde_json::from_str(body).unwrap();
408        let snapshot = response.into_snapshot(None).unwrap();
409        assert!(snapshot.session.is_none());
410        assert_eq!(snapshot.weekly.unwrap().utilization_pct, 66);
411    }
412
413    #[test]
414    fn duration_classification_survives_reordered_wire_windows() {
415        let body = r#"{"rate_limit":{
416            "primary_window":{"used_percent":41,"limit_window_seconds":604800},
417            "secondary_window":{"used_percent":7,"limit_window_seconds":18000}
418        }}"#;
419        let response: UsageResponse = serde_json::from_str(body).unwrap();
420        let snapshot = response.into_snapshot(None).unwrap();
421        assert_eq!(snapshot.session.unwrap().utilization_pct, 7);
422        assert_eq!(snapshot.weekly.unwrap().utilization_pct, 41);
423    }
424
425    #[test]
426    fn duplicate_semantic_windows_are_schema_drift() {
427        let body = r#"{"rate_limit":{
428            "primary_window":{"used_percent":41,"limit_window_seconds":604800},
429            "secondary_window":{"used_percent":7,"limit_window_seconds":604800}
430        }}"#;
431        let response: UsageResponse = serde_json::from_str(body).unwrap();
432        let error = response.into_snapshot(None).unwrap_err().to_string();
433        assert!(error.contains("duplicate OpenAI 7d window"));
434        assert!(error.contains("limit_window_seconds=604800"));
435    }
436
437    #[test]
438    fn unknown_duration_falls_back_to_wire_position() {
439        // A `limit_window_seconds` value we do not recognize (e.g. 3600) is
440        // classified by wire position: `primary_window` → session,
441        // `secondary_window` → weekly.
442        let body = r#"{"rate_limit":{
443            "primary_window":{"used_percent":10,"limit_window_seconds":3600},
444            "secondary_window":{"used_percent":20,"limit_window_seconds":3600}
445        }}"#;
446        let response: UsageResponse = serde_json::from_str(body).unwrap();
447        let snapshot = response.into_snapshot(None).unwrap();
448        assert_eq!(snapshot.session.unwrap().utilization_pct, 10);
449        assert_eq!(snapshot.weekly.unwrap().utilization_pct, 20);
450    }
451
452    #[test]
453    fn credits_block_parses_with_message_ranges() {
454        let body = r#"{
455            "plan_type":"plus",
456            "credits":{"balance":"$2.50","has_credits":true,"unlimited":false,
457                "approx_local_messages":[100,200],"approx_cloud_messages":[40,60]}
458        }"#;
459        let r: UsageResponse = serde_json::from_str(body).unwrap();
460        let s = r.into_snapshot(None).unwrap();
461        let c = s.credits.unwrap();
462        assert_eq!(c.balance, "$2.50");
463        assert!(c.has_credits);
464        assert_eq!(c.approx_local_messages, Some((100, 200)));
465        assert_eq!(c.approx_cloud_messages, Some((40, 60)));
466    }
467
468    #[test]
469    fn balance_as_number_formats_to_dollars() {
470        let body = r#"{"credits":{"balance":1.5,"has_credits":true,"unlimited":false}}"#;
471        let r: UsageResponse = serde_json::from_str(body).unwrap();
472        let s = r.into_snapshot(None).unwrap();
473        assert_eq!(s.credits.unwrap().balance, "$1.50");
474    }
475
476    #[test]
477    fn benign_percent_overshoot_clamps_to_hundred() {
478        let body =
479            r#"{"rate_limit":{"primary_window":{"used_percent":100.6,"limit_window_seconds":1}}}"#;
480        let r: UsageResponse = serde_json::from_str(body).unwrap();
481        let s = r.into_snapshot(None).unwrap();
482        assert_eq!(s.session.unwrap().utilization_pct, 100);
483    }
484
485    #[test]
486    fn out_of_range_percent_is_schema_drift() {
487        for used_percent in ["-1", "101.5", "250"] {
488            let body = format!(
489                r#"{{"rate_limit":{{"primary_window":{{"used_percent":{used_percent},"limit_window_seconds":1}}}}}}"#
490            );
491            assert!(
492                serde_json::from_str::<UsageResponse>(&body).is_err(),
493                "{used_percent} must not become a clamped usage value"
494            );
495        }
496    }
497
498    #[test]
499    fn plan_hint_used_when_response_omits_plan_type() {
500        let r: UsageResponse = serde_json::from_str("{}").unwrap();
501        let s = r.into_snapshot(Some("team")).unwrap();
502        assert_eq!(s.plan, "ChatGPT Team");
503    }
504
505    #[test]
506    fn window_counters_accept_fractional_percent_and_integral_number_forms() {
507        let w: Window =
508            serde_json::from_str(r#"{"used_percent":7.4,"limit_window_seconds":18000.0}"#).unwrap();
509        assert_eq!(w.used_percent, 7.4);
510        assert_eq!(w.limit_window_seconds, 18000);
511
512        let w: Window =
513            serde_json::from_str(r#"{"used_percent":"42.7","limit_window_seconds":"604800.0"}"#)
514                .unwrap();
515        assert_eq!(w.used_percent, 42.7);
516        assert_eq!(w.limit_window_seconds, 604800);
517
518        let r: UsageResponse = serde_json::from_str(
519            r#"{"rate_limit":{"primary_window":{"used_percent":42.7,"limit_window_seconds":18000}}}"#,
520        )
521        .unwrap();
522        assert_eq!(
523            r.into_snapshot(None)
524                .unwrap()
525                .session
526                .unwrap()
527                .utilization_pct,
528            43
529        );
530    }
531
532    #[test]
533    fn fractional_integer_counters_are_schema_drift() {
534        for value in ["18000.9", r#""604800.5""#] {
535            let body = format!(r#"{{"used_percent":7,"limit_window_seconds":{value}}}"#);
536            assert!(serde_json::from_str::<Window>(&body).is_err(), "{value}");
537        }
538    }
539
540    #[test]
541    fn null_counter_is_schema_drift() {
542        // `reset_at` is the only field the API is documented to omit, and it
543        // carries its own Option deserializer. A null counter is drift.
544        let body = r#"{"used_percent":null,"limit_window_seconds":1}"#;
545        assert!(serde_json::from_str::<Window>(body).is_err());
546        let body = r#"{"used_percent":1,"limit_window_seconds":null}"#;
547        assert!(serde_json::from_str::<Window>(body).is_err());
548    }
549
550    #[test]
551    fn non_numeric_counter_shapes_are_schema_drift() {
552        for bad in [
553            r#""many""#,
554            r#"{"value":1}"#,
555            "[1]",
556            "true",
557            // Each parses as an f64, but `as i64` saturates rather than
558            // failing, so it would coin a 0 / i64::MAX that reads as real.
559            r#""NaN""#,
560            r#""inf""#,
561            "1e300",
562            "-1e300",
563            r#""1e300""#,
564        ] {
565            let body = format!(r#"{{"used_percent":{bad},"limit_window_seconds":1}}"#);
566            assert!(
567                serde_json::from_str::<Window>(&body).is_err(),
568                "used_percent {bad} must not deserialize"
569            );
570        }
571    }
572
573    #[test]
574    fn drifted_counter_fails_whole_usage_response() {
575        // The error has to reach `parse_payload` so the widget shows `⚠`
576        // rather than caching and rendering a 0% bar.
577        let body = r#"{"plan_type":"plus","rate_limit":{
578            "primary_window":{"used_percent":"n/a","limit_window_seconds":18000}
579        }}"#;
580        assert!(serde_json::from_str::<UsageResponse>(body).is_err());
581    }
582
583    #[test]
584    fn a_present_window_requires_both_counters() {
585        for body in [
586            r#"{"used_percent":1}"#,
587            r#"{"limit_window_seconds":18000}"#,
588            "{}",
589        ] {
590            assert!(serde_json::from_str::<Window>(body).is_err(), "{body}");
591        }
592    }
593
594    #[test]
595    fn malformed_optional_counters_are_not_treated_as_absent() {
596        for field in ["reset_at", "reset_after_seconds"] {
597            for bad in ["true", r#""tomorrow""#, "1.5", "{}"] {
598                let body =
599                    format!(r#"{{"used_percent":1,"limit_window_seconds":18000,"{field}":{bad}}}"#);
600                assert!(serde_json::from_str::<Window>(&body).is_err(), "{body}");
601            }
602        }
603    }
604
605    #[test]
606    fn credits_reject_invalid_present_values_without_inventing_zero() {
607        for balance in ["true", "{}", "[]"] {
608            let body = format!(
609                r#"{{"credits":{{"balance":{balance},"has_credits":true,"unlimited":false}}}}"#
610            );
611            assert!(serde_json::from_str::<UsageResponse>(&body).is_err());
612        }
613        assert!(
614            serde_json::from_str::<UsageResponse>(r#"{"credits":{"balance":"$1.00"}}"#).is_err(),
615            "a present credits block must not default its status flags"
616        );
617
618        let response: UsageResponse = serde_json::from_str(
619            r#"{"credits":{"balance":null,"has_credits":false,"unlimited":true}}"#,
620        )
621        .unwrap();
622        assert_eq!(
623            response
624                .into_snapshot(None)
625                .unwrap()
626                .credits
627                .unwrap()
628                .balance,
629            ""
630        );
631    }
632
633    #[test]
634    fn oversized_window_seconds_degrades_instead_of_panicking() {
635        // i64::MAX is a faithful integer, so it clears the deserializer — but
636        // `chrono::Duration::seconds` panics on it, and a panicking widget
637        // exits non-zero and gets hidden by Waybar.
638        let body = r#"{"rate_limit":{"primary_window":{
639            "used_percent":1,"limit_window_seconds":9223372036854775807,
640            "reset_after_seconds":9223372036854775807
641        }}}"#;
642        let r: UsageResponse = serde_json::from_str(body).unwrap();
643        let s = r.into_snapshot(None).unwrap();
644        let session = s.session.unwrap();
645        assert_eq!(session.window_duration, chrono::Duration::hours(5));
646        assert!(session.resets_at.is_none());
647    }
648
649    #[test]
650    fn missing_reset_at_falls_back_to_after_seconds() {
651        let body = r#"{"rate_limit":{"primary_window":{
652            "used_percent":50,"limit_window_seconds":1000,"reset_after_seconds":500
653        }}}"#;
654        let r: UsageResponse = serde_json::from_str(body).unwrap();
655        let s = r.into_snapshot(None).unwrap();
656        // The reset should be ~500s from now (within tolerance).
657        let now = chrono::Utc::now();
658        let reset = s.session.unwrap().resets_at.unwrap();
659        let delta = reset.signed_duration_since(now).num_seconds();
660        assert!((400..=600).contains(&delta), "got delta={delta}");
661    }
662}