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