1use 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 #[serde(default, deserialize_with = "de_null_as_default")]
47 pub additional_rate_limits: Vec<AdditionalRateLimit>,
48 #[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 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 #[serde(default, deserialize_with = "de_opt_int_or_float")]
86 pub reset_at: Option<i64>,
87 #[serde(default, deserialize_with = "de_opt_int_or_float")]
89 pub reset_after_seconds: Option<i64>,
90}
91
92#[derive(Debug, Clone, Deserialize, Serialize)]
93pub struct CreditsBlock {
94 #[serde(default, deserialize_with = "de_opt_money_string")]
95 pub balance: Option<String>,
96 pub has_credits: bool,
97 pub unlimited: bool,
98 #[serde(default)]
99 pub approx_local_messages: Option<Vec<i64>>,
100 #[serde(default)]
101 pub approx_cloud_messages: Option<Vec<i64>>,
102}
103
104#[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#[derive(Debug, Default, Clone, Deserialize, Serialize)]
120#[serde(default)]
121pub struct ResetCredit {
122 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
130fn 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
194fn 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
220fn de_opt_money_string<'de, D>(d: D) -> Result<Option<String>, D::Error>
226where
227 D: serde::Deserializer<'de>,
228{
229 let v = serde_json::Value::deserialize(d)?;
230 match v {
231 serde_json::Value::Null => Ok(None),
232 serde_json::Value::String(s) => Ok(Some(match s.trim().parse::<f64>() {
233 Ok(value) if value.is_finite() => crate::format::usd(value),
234 _ => s,
235 })),
236 serde_json::Value::Number(n) => match n.as_f64() {
237 Some(value) if value.is_finite() => Ok(Some(crate::format::usd(value))),
238 _ => Err(serde::de::Error::custom(
239 "credit balance is not a finite number",
240 )),
241 },
242 other => Err(serde::de::Error::custom(format!(
243 "expected credit balance string, number, or null; got {other:?}"
244 ))),
245 }
246}
247
248fn de_null_as_default<'de, D, T>(d: D) -> Result<T, D::Error>
260where
261 D: serde::Deserializer<'de>,
262 T: Default + Deserialize<'de>,
263{
264 Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
265}
266
267impl UsageResponse {
268 pub fn into_snapshot(self, plan_hint: Option<&str>) -> AppResult<OpenAiSnapshot> {
269 let plan_type = self.plan_type.as_deref().or(plan_hint).unwrap_or("Unknown");
270 let plan = format!("ChatGPT {}", crate::format::capitalize(plan_type));
271
272 let (session, weekly) = classify_rate_limit(self.rate_limit.unwrap_or_default())?;
273 let code_review = self
274 .code_review_rate_limit
275 .and_then(|c| c.primary_window)
276 .map(|w| to_window(&w, chrono::Duration::days(7)));
277
278 let credits = self.credits.map(|c| OpenAiCredits {
279 balance: c.balance.unwrap_or_default(),
280 has_credits: c.has_credits,
281 unlimited: c.unlimited,
282 approx_local_messages: range_from_vec(c.approx_local_messages),
283 approx_cloud_messages: range_from_vec(c.approx_cloud_messages),
284 });
285 let reset_credits = self.rate_limit_reset_credits.map(|credits| ResetCredits {
286 available: credits.available_count,
287 credits: credits
288 .credits
289 .into_iter()
290 .filter(|credit| credit.status == "available")
291 .map(|credit| BankedReset {
292 title: checked_reset_title(credit.title),
293 expires_at: credit.expires_at,
294 })
295 .collect(),
296 });
297
298 let additional_limits = self
299 .additional_rate_limits
300 .into_iter()
301 .filter_map(named_limit)
302 .collect();
303 let unavailable_models = self
306 .model_usage
307 .into_iter()
308 .filter(|(_, usage)| usage.available == Some(false))
309 .map(|(model, usage)| OpenAiUnavailableModel {
310 model,
311 available_at: usage.available_at,
312 })
313 .collect();
314
315 Ok(OpenAiSnapshot {
316 plan,
317 session,
318 weekly,
319 code_review,
320 additional_limits,
321 unavailable_models,
322 credits,
323 reset_credits,
324 source: OpenAiSource::CodexOauth,
325 })
326 }
327}
328
329#[derive(Clone, Copy, Debug)]
330enum WindowKind {
331 Session,
332 Weekly,
333}
334
335pub(crate) const SESSION_WINDOW_SECS: u64 = 18_000;
337pub(crate) const WEEKLY_WINDOW_SECS: u64 = 604_800;
339
340fn named_limit(entry: AdditionalRateLimit) -> Option<OpenAiNamedLimit> {
344 let name = entry
345 .limit_name
346 .or(entry.metered_feature)
347 .filter(|name| !name.trim().is_empty())?;
348 let (session, weekly) = classify_rate_limit(entry.rate_limit?).ok()?;
349 if session.is_none() && weekly.is_none() {
350 return None;
351 }
352 Some(OpenAiNamedLimit {
353 name: crate::display::sanitize_untrusted_field(&name),
354 session,
355 weekly,
356 })
357}
358
359fn classify_rate_limit(
360 rate_limit: RateLimit,
361) -> AppResult<(Option<UsageWindow>, Option<UsageWindow>)> {
362 let mut session = None;
363 let mut weekly = None;
364 insert_window(
365 rate_limit.primary_window,
366 WindowKind::Session,
367 &mut session,
368 &mut weekly,
369 )?;
370 insert_window(
371 rate_limit.secondary_window,
372 WindowKind::Weekly,
373 &mut session,
374 &mut weekly,
375 )?;
376 Ok((session, weekly))
377}
378
379fn insert_window(
380 wire_window: Option<Window>,
381 fallback_kind: WindowKind,
382 session: &mut Option<UsageWindow>,
383 weekly: &mut Option<UsageWindow>,
384) -> AppResult<()> {
385 let Some(wire_window) = wire_window else {
386 return Ok(());
387 };
388 let kind = window_kind(&wire_window).unwrap_or(fallback_kind);
389 let default_duration = kind.default_duration();
390 let target = semantic_window_target(kind, session, weekly);
391 if target.is_some() {
392 return Err(duplicate_window_error(
393 kind,
394 wire_window.limit_window_seconds,
395 ));
396 }
397 *target = Some(to_window(&wire_window, default_duration));
398 Ok(())
399}
400
401fn semantic_window_target<'a>(
402 kind: WindowKind,
403 session: &'a mut Option<UsageWindow>,
404 weekly: &'a mut Option<UsageWindow>,
405) -> &'a mut Option<UsageWindow> {
406 match kind {
407 WindowKind::Session => session,
408 WindowKind::Weekly => weekly,
409 }
410}
411
412fn window_kind(window: &Window) -> Option<WindowKind> {
413 match window.limit_window_seconds {
416 s if s == SESSION_WINDOW_SECS as i64 => Some(WindowKind::Session),
417 s if s == WEEKLY_WINDOW_SECS as i64 => Some(WindowKind::Weekly),
418 _ => None,
419 }
420}
421
422fn duplicate_window_error(kind: WindowKind, seconds: i64) -> AppError {
423 let label = match kind {
424 WindowKind::Session => "5h",
425 WindowKind::Weekly => "7d",
426 };
427 AppError::Schema(format!(
428 "duplicate OpenAI {label} window with limit_window_seconds={seconds}; expected at most one 5h and one 7d window"
429 ))
430}
431
432impl WindowKind {
433 fn default_duration(self) -> chrono::Duration {
434 match self {
435 Self::Session => chrono::Duration::seconds(SESSION_WINDOW_SECS as i64),
436 Self::Weekly => chrono::Duration::seconds(WEEKLY_WINDOW_SECS as i64),
437 }
438 }
439}
440
441fn to_window(w: &Window, default_dur: chrono::Duration) -> UsageWindow {
442 let dur = match chrono::Duration::try_seconds(w.limit_window_seconds) {
445 Some(d) if w.limit_window_seconds > 0 => d,
446 _ => default_dur,
447 };
448 let resets_at = match w.reset_at {
449 Some(secs) => chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0),
450 None => w
451 .reset_after_seconds
452 .and_then(chrono::Duration::try_seconds)
453 .and_then(|d| chrono::Utc::now().checked_add_signed(d)),
454 };
455 UsageWindow {
456 utilization_pct: i32::from(crate::format::clamp_pct(w.used_percent)),
457 resets_at,
458 window_duration: dur,
459 }
460}
461
462fn range_from_vec(v: Option<Vec<i64>>) -> Option<(i64, i64)> {
463 let v = v?;
464 if v.len() >= 2 {
465 Some((v[0], v[1]))
466 } else if v.len() == 1 {
467 Some((v[0], v[0]))
468 } else {
469 None
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 const REAL: &str = r#"{
478 "user_id":"u","account_id":"a","email":"e",
479 "plan_type":"plus",
480 "rate_limit":{"allowed":true,"limit_reached":false,
481 "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_after_seconds":18000,"reset_at":1779597324},
482 "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_after_seconds":604800,"reset_at":1780184124}
483 }
484 }"#;
485
486 #[test]
487 fn parses_real_shape() {
488 let r: UsageResponse = serde_json::from_str(REAL).unwrap();
489 let s = r.into_snapshot(None).unwrap();
490 assert_eq!(s.plan, "ChatGPT Plus");
491 assert_eq!(s.session.as_ref().unwrap().utilization_pct, 1);
492 assert_eq!(s.weekly.as_ref().unwrap().utilization_pct, 0);
493 assert_eq!(
494 s.session.as_ref().unwrap().window_duration,
495 chrono::Duration::hours(5)
496 );
497 assert_eq!(
498 s.weekly.as_ref().unwrap().window_duration,
499 chrono::Duration::days(7)
500 );
501 assert!(s.session.as_ref().unwrap().resets_at.is_some());
502 assert!(s.code_review.is_none());
503 assert!(s.credits.is_none());
504 assert!(matches!(s.source, OpenAiSource::CodexOauth));
505 }
506
507 #[test]
508 fn missing_rate_limit_reports_no_windows() {
509 let r: UsageResponse = serde_json::from_str(r#"{"plan_type":"pro"}"#).unwrap();
510 let s = r.into_snapshot(None).unwrap();
511 assert_eq!(s.plan, "ChatGPT Pro");
512 assert!(s.session.is_none());
513 assert!(s.weekly.is_none());
514 }
515
516 #[test]
517 fn weekly_only_primary_window_is_not_mislabeled_as_session() {
518 let body = r#"{
521 "plan_type":"prolite",
522 "rate_limit":{
523 "primary_window":{
524 "used_percent":66,
525 "limit_window_seconds":604800,
526 "reset_at":1785261834
527 },
528 "secondary_window":null
529 }
530 }"#;
531 let response: UsageResponse = serde_json::from_str(body).unwrap();
532 let snapshot = response.into_snapshot(None).unwrap();
533 assert!(snapshot.session.is_none());
534 assert_eq!(snapshot.weekly.unwrap().utilization_pct, 66);
535 }
536
537 #[test]
538 fn duration_classification_survives_reordered_wire_windows() {
539 let body = r#"{"rate_limit":{
540 "primary_window":{"used_percent":41,"limit_window_seconds":604800},
541 "secondary_window":{"used_percent":7,"limit_window_seconds":18000}
542 }}"#;
543 let response: UsageResponse = serde_json::from_str(body).unwrap();
544 let snapshot = response.into_snapshot(None).unwrap();
545 assert_eq!(snapshot.session.unwrap().utilization_pct, 7);
546 assert_eq!(snapshot.weekly.unwrap().utilization_pct, 41);
547 }
548
549 #[test]
550 fn duplicate_semantic_windows_are_schema_drift() {
551 let body = r#"{"rate_limit":{
552 "primary_window":{"used_percent":41,"limit_window_seconds":604800},
553 "secondary_window":{"used_percent":7,"limit_window_seconds":604800}
554 }}"#;
555 let response: UsageResponse = serde_json::from_str(body).unwrap();
556 let error = response.into_snapshot(None).unwrap_err().to_string();
557 assert!(error.contains("duplicate OpenAI 7d window"));
558 assert!(error.contains("limit_window_seconds=604800"));
559 }
560
561 #[test]
562 fn unknown_duration_falls_back_to_wire_position() {
563 let body = r#"{"rate_limit":{
567 "primary_window":{"used_percent":10,"limit_window_seconds":3600},
568 "secondary_window":{"used_percent":20,"limit_window_seconds":3600}
569 }}"#;
570 let response: UsageResponse = serde_json::from_str(body).unwrap();
571 let snapshot = response.into_snapshot(None).unwrap();
572 assert_eq!(snapshot.session.unwrap().utilization_pct, 10);
573 assert_eq!(snapshot.weekly.unwrap().utilization_pct, 20);
574 }
575
576 #[test]
577 fn credits_block_parses_with_message_ranges() {
578 let body = r#"{
579 "plan_type":"plus",
580 "credits":{"balance":"$2.50","has_credits":true,"unlimited":false,
581 "approx_local_messages":[100,200],"approx_cloud_messages":[40,60]}
582 }"#;
583 let r: UsageResponse = serde_json::from_str(body).unwrap();
584 let s = r.into_snapshot(None).unwrap();
585 let c = s.credits.unwrap();
586 assert_eq!(c.balance, "$2.50");
587 assert!(c.has_credits);
588 assert_eq!(c.approx_local_messages, Some((100, 200)));
589 assert_eq!(c.approx_cloud_messages, Some((40, 60)));
590 }
591
592 #[test]
596 fn reset_credit_count_stands_on_its_own_without_the_detail_call() {
597 let body = r#"{"plan_type":"plus","rate_limit_reset_credits":{"available_count":2}}"#;
598 let s: UsageResponse = serde_json::from_str(body).unwrap();
599 let s = s.into_snapshot(None).unwrap();
600 assert_eq!(s.reset_credits.as_ref().unwrap().available, 2);
601 assert!(s.reset_credits.as_ref().unwrap().credits.is_empty());
602 assert!(!s.reset_credits.as_ref().unwrap().is_empty());
603 }
604
605 #[test]
608 fn only_an_available_credit_contributes_an_expiry() {
609 let body = r#"{
610 "rate_limit_reset_credits":{
611 "available_count":1,
612 "credits":[
613 {"id":"c1","status":"redeemed","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-07-01T00:00:00Z"},
614 {"id":"c2","status":"available","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-07-17T00:00:00Z"},
615 {"id":"c3","status":"available","expires_at":null}
616 ]
617 }
618 }"#;
619 let s: UsageResponse = serde_json::from_str(body).unwrap();
620 let s = s.into_snapshot(None).unwrap();
621 assert_eq!(s.reset_credits.as_ref().unwrap().available, 1);
622 assert_eq!(s.reset_credits.as_ref().unwrap().credits.len(), 2);
623 assert_eq!(
624 s.reset_credits.as_ref().unwrap().credits[0]
625 .title
626 .as_deref(),
627 Some("Full reset (Weekly + 5 hr)")
628 );
629 assert_eq!(
630 s.reset_credits.as_ref().unwrap().next_expiry(),
631 Some("2026-07-17T00:00:00Z".parse::<DateTime<Utc>>().unwrap())
632 );
633 }
634
635 #[test]
639 fn an_absent_reset_block_is_no_credits_rather_than_an_error() {
640 let s: UsageResponse = serde_json::from_str(r#"{"plan_type":"plus"}"#).unwrap();
641 assert!(s.into_snapshot(None).unwrap().reset_credits.is_none());
642 }
643
644 #[test]
648 fn a_stated_zero_reset_block_stays_distinct_from_an_absent_one() {
649 let s: UsageResponse = serde_json::from_str(
650 r#"{"plan_type":"pro","rate_limit_reset_credits":{"available_count":0,"credits":[]}}"#,
651 )
652 .unwrap();
653 assert_eq!(
654 s.into_snapshot(None).unwrap().reset_credits,
655 Some(ResetCredits::default())
656 );
657 }
658
659 #[test]
660 fn balance_as_number_formats_to_dollars() {
661 let body = r#"{"credits":{"balance":1.5,"has_credits":true,"unlimited":false}}"#;
662 let r: UsageResponse = serde_json::from_str(body).unwrap();
663 let s = r.into_snapshot(None).unwrap();
664 assert_eq!(s.credits.unwrap().balance, "$1.50");
665 }
666
667 #[test]
668 fn balance_as_numeric_string_formats_to_dollars_too() {
669 let balance = |raw: &str| {
670 let body = format!(
671 r#"{{"credits":{{"balance":"{raw}","has_credits":false,"unlimited":false}}}}"#
672 );
673 let r: UsageResponse = serde_json::from_str(&body).unwrap();
674 r.into_snapshot(None).unwrap().credits.unwrap().balance
675 };
676 for (raw, want) in [("0", "$0.00"), (" 2.5 ", "$2.50"), ("-1", "-$1.00")] {
677 assert_eq!(balance(raw), want, "balance {raw:?}");
678 }
679 for raw in ["$2.50", "NaN", "inf", "1,000.50", "n/a"] {
682 assert_eq!(balance(raw), raw, "balance {raw:?}");
683 }
684 }
685
686 #[test]
687 fn benign_percent_overshoot_clamps_to_hundred() {
688 let body =
689 r#"{"rate_limit":{"primary_window":{"used_percent":100.6,"limit_window_seconds":1}}}"#;
690 let r: UsageResponse = serde_json::from_str(body).unwrap();
691 let s = r.into_snapshot(None).unwrap();
692 assert_eq!(s.session.unwrap().utilization_pct, 100);
693 }
694
695 #[test]
696 fn out_of_range_percent_is_schema_drift() {
697 for used_percent in ["-1", "101.5", "250"] {
698 let body = format!(
699 r#"{{"rate_limit":{{"primary_window":{{"used_percent":{used_percent},"limit_window_seconds":1}}}}}}"#
700 );
701 assert!(
702 serde_json::from_str::<UsageResponse>(&body).is_err(),
703 "{used_percent} must not become a clamped usage value"
704 );
705 }
706 }
707
708 #[test]
709 fn plan_hint_used_when_response_omits_plan_type() {
710 let r: UsageResponse = serde_json::from_str("{}").unwrap();
711 let s = r.into_snapshot(Some("team")).unwrap();
712 assert_eq!(s.plan, "ChatGPT Team");
713 }
714
715 #[test]
716 fn window_counters_accept_fractional_percent_and_integral_number_forms() {
717 let w: Window =
718 serde_json::from_str(r#"{"used_percent":7.4,"limit_window_seconds":18000.0}"#).unwrap();
719 assert_eq!(w.used_percent, 7.4);
720 assert_eq!(w.limit_window_seconds, 18000);
721
722 let w: Window =
723 serde_json::from_str(r#"{"used_percent":"42.7","limit_window_seconds":"604800.0"}"#)
724 .unwrap();
725 assert_eq!(w.used_percent, 42.7);
726 assert_eq!(w.limit_window_seconds, 604800);
727
728 let r: UsageResponse = serde_json::from_str(
729 r#"{"rate_limit":{"primary_window":{"used_percent":42.7,"limit_window_seconds":18000}}}"#,
730 )
731 .unwrap();
732 assert_eq!(
733 r.into_snapshot(None)
734 .unwrap()
735 .session
736 .unwrap()
737 .utilization_pct,
738 43
739 );
740 }
741
742 #[test]
743 fn fractional_integer_counters_are_schema_drift() {
744 for value in ["18000.9", r#""604800.5""#] {
745 let body = format!(r#"{{"used_percent":7,"limit_window_seconds":{value}}}"#);
746 assert!(serde_json::from_str::<Window>(&body).is_err(), "{value}");
747 }
748 }
749
750 #[test]
751 fn null_counter_is_schema_drift() {
752 let body = r#"{"used_percent":null,"limit_window_seconds":1}"#;
755 assert!(serde_json::from_str::<Window>(body).is_err());
756 let body = r#"{"used_percent":1,"limit_window_seconds":null}"#;
757 assert!(serde_json::from_str::<Window>(body).is_err());
758 }
759
760 #[test]
761 fn non_numeric_counter_shapes_are_schema_drift() {
762 for bad in [
763 r#""many""#,
764 r#"{"value":1}"#,
765 "[1]",
766 "true",
767 r#""NaN""#,
770 r#""inf""#,
771 "1e300",
772 "-1e300",
773 r#""1e300""#,
774 ] {
775 let body = format!(r#"{{"used_percent":{bad},"limit_window_seconds":1}}"#);
776 assert!(
777 serde_json::from_str::<Window>(&body).is_err(),
778 "used_percent {bad} must not deserialize"
779 );
780 }
781 }
782
783 #[test]
784 fn drifted_counter_fails_whole_usage_response() {
785 let body = r#"{"plan_type":"plus","rate_limit":{
788 "primary_window":{"used_percent":"n/a","limit_window_seconds":18000}
789 }}"#;
790 assert!(serde_json::from_str::<UsageResponse>(body).is_err());
791 }
792
793 #[test]
794 fn a_present_window_requires_both_counters() {
795 for body in [
796 r#"{"used_percent":1}"#,
797 r#"{"limit_window_seconds":18000}"#,
798 "{}",
799 ] {
800 assert!(serde_json::from_str::<Window>(body).is_err(), "{body}");
801 }
802 }
803
804 #[test]
805 fn malformed_optional_counters_are_not_treated_as_absent() {
806 for field in ["reset_at", "reset_after_seconds"] {
807 for bad in ["true", r#""tomorrow""#, "1.5", "{}"] {
808 let body =
809 format!(r#"{{"used_percent":1,"limit_window_seconds":18000,"{field}":{bad}}}"#);
810 assert!(serde_json::from_str::<Window>(&body).is_err(), "{body}");
811 }
812 }
813 }
814
815 #[test]
816 fn credits_reject_invalid_present_values_without_inventing_zero() {
817 for balance in ["true", "{}", "[]"] {
818 let body = format!(
819 r#"{{"credits":{{"balance":{balance},"has_credits":true,"unlimited":false}}}}"#
820 );
821 assert!(serde_json::from_str::<UsageResponse>(&body).is_err());
822 }
823 assert!(
824 serde_json::from_str::<UsageResponse>(r#"{"credits":{"balance":"$1.00"}}"#).is_err(),
825 "a present credits block must not default its status flags"
826 );
827
828 let response: UsageResponse = serde_json::from_str(
829 r#"{"credits":{"balance":null,"has_credits":false,"unlimited":true}}"#,
830 )
831 .unwrap();
832 assert_eq!(
833 response
834 .into_snapshot(None)
835 .unwrap()
836 .credits
837 .unwrap()
838 .balance,
839 ""
840 );
841 }
842
843 #[test]
844 fn oversized_window_seconds_degrades_instead_of_panicking() {
845 let body = r#"{"rate_limit":{"primary_window":{
849 "used_percent":1,"limit_window_seconds":9223372036854775807,
850 "reset_after_seconds":9223372036854775807
851 }}}"#;
852 let r: UsageResponse = serde_json::from_str(body).unwrap();
853 let s = r.into_snapshot(None).unwrap();
854 let session = s.session.unwrap();
855 assert_eq!(session.window_duration, chrono::Duration::hours(5));
856 assert!(session.resets_at.is_none());
857 }
858
859 #[test]
860 fn missing_reset_at_falls_back_to_after_seconds() {
861 let body = r#"{"rate_limit":{"primary_window":{
862 "used_percent":50,"limit_window_seconds":1000,"reset_after_seconds":500
863 }}}"#;
864 let r: UsageResponse = serde_json::from_str(body).unwrap();
865 let s = r.into_snapshot(None).unwrap();
866 let now = chrono::Utc::now();
868 let reset = s.session.unwrap().resets_at.unwrap();
869 let delta = reset.signed_duration_since(now).num_seconds();
870 assert!((400..=600).contains(&delta), "got delta={delta}");
871 }
872 #[test]
877 fn named_limits_and_unavailable_models_are_read_from_the_live_shape() {
878 let response: UsageResponse = serde_json::from_str(
879 r#"{
880 "plan_type": "pro",
881 "rate_limit": {
882 "allowed": true, "limit_reached": false,
883 "primary_window": {"used_percent": 5, "limit_window_seconds": 604800,
884 "reset_after_seconds": 400000, "reset_at": 1789000000},
885 "secondary_window": null
886 },
887 "code_review_rate_limit": null,
888 "additional_rate_limits": [
889 {"limit_name": "GPT-5.3-Codex-Spark", "metered_feature": "codex_bengalfox",
890 "rate_limit": {
891 "primary_window": {"used_percent": 12, "limit_window_seconds": 18000,
892 "reset_at": 1788000000},
893 "secondary_window": {"used_percent": 34, "limit_window_seconds": 604800,
894 "reset_at": 1789500000}},
895 "normal_model_slug": null},
896 {"limit_name": "gpt-reserve", "metered_feature": "base_model_inference",
897 "rate_limit": {
898 "primary_window": {"used_percent": 71, "limit_window_seconds": 604800,
899 "reset_at": 1789500000},
900 "secondary_window": null}}
901 ],
902 "model_usage": {
903 "gpt-6-astra": {"available": false, "available_at": "2026-09-05T22:00:00Z",
904 "credits_would_enable": false},
905 "gpt-5.3-codex": {"available": true, "available_at": null}
906 }
907 }"#,
908 )
909 .expect("the live response shape parses");
910
911 let snap = response.into_snapshot(None).unwrap();
912
913 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 5);
916
917 assert_eq!(snap.additional_limits.len(), 2);
918 let spark = &snap.additional_limits[0];
919 assert_eq!(spark.name, "GPT-5.3-Codex-Spark");
920 assert_eq!(spark.session.as_ref().unwrap().utilization_pct, 12);
921 assert_eq!(spark.weekly.as_ref().unwrap().utilization_pct, 34);
922 let reserve = &snap.additional_limits[1];
925 assert_eq!(reserve.name, "gpt-reserve");
926 assert!(reserve.session.is_none());
927 assert_eq!(reserve.weekly.as_ref().unwrap().utilization_pct, 71);
928
929 assert_eq!(snap.unavailable_models.len(), 1);
931 assert_eq!(snap.unavailable_models[0].model, "gpt-6-astra");
932 assert!(snap.unavailable_models[0].available_at.is_some());
933 }
934
935 #[test]
938 fn an_account_without_extra_limits_reports_none_rather_than_empty_rows() {
939 let response: UsageResponse = serde_json::from_str(
940 r#"{"plan_type": "plus",
941 "rate_limit": {"primary_window": {"used_percent": 3,
942 "limit_window_seconds": 604800}}}"#,
943 )
944 .unwrap();
945 let snap = response.into_snapshot(None).unwrap();
946
947 assert!(snap.additional_limits.is_empty());
948 assert!(snap.unavailable_models.is_empty());
949 }
950
951 #[test]
955 fn nameless_or_windowless_limits_are_dropped() {
956 let response: UsageResponse = serde_json::from_str(
957 r#"{"additional_rate_limits": [
958 {"limit_name": null, "metered_feature": "base_model_inference",
959 "rate_limit": {"primary_window": {"used_percent": 9,
960 "limit_window_seconds": 604800}}},
961 {"limit_name": "no windows", "rate_limit": {"primary_window": null,
962 "secondary_window": null}},
963 {"limit_name": " ", "rate_limit": {"primary_window":
964 {"used_percent": 1, "limit_window_seconds": 18000}}}
965 ]}"#,
966 )
967 .unwrap();
968 let snap = response.into_snapshot(None).unwrap();
969
970 assert_eq!(snap.additional_limits.len(), 1);
971 assert_eq!(snap.additional_limits[0].name, "base_model_inference");
972 }
973
974 #[test]
977 fn a_model_without_an_availability_flag_is_not_reported_as_down() {
978 let response: UsageResponse =
979 serde_json::from_str(r#"{"model_usage": {"gpt-6-astra": {"available_at": null}}}"#)
980 .unwrap();
981 assert!(
982 response
983 .into_snapshot(None)
984 .unwrap()
985 .unavailable_models
986 .is_empty()
987 );
988 }
989
990 #[test]
1000 fn the_reported_all_null_payload_parses() {
1001 let response: UsageResponse = serde_json::from_str(
1002 r#"{"plan_type":"plus",
1003 "rate_limit":{"primary_window":{"used_percent":5,
1004 "limit_window_seconds":604800}},
1005 "code_review_rate_limit":null,
1006 "additional_rate_limits":null,
1007 "model_usage":null,
1008 "rate_limit_reset_credits":{"available_count":0,"credits":null}}"#,
1009 )
1010 .expect("the reported shape must parse");
1011
1012 let snap = response.into_snapshot(None).unwrap();
1013 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 5);
1014 assert!(snap.additional_limits.is_empty());
1015 assert!(snap.unavailable_models.is_empty());
1016 }
1017
1018 #[test]
1022 fn a_mistyped_collection_is_still_schema_drift() {
1023 for bad in [
1024 r#"{"additional_rate_limits": "none"}"#,
1025 r#"{"additional_rate_limits": 0}"#,
1026 r#"{"model_usage": []}"#,
1027 r#"{"model_usage": "none"}"#,
1028 ] {
1029 assert!(
1030 serde_json::from_str::<UsageResponse>(bad).is_err(),
1031 "{bad} should not be read as empty"
1032 );
1033 }
1034 }
1035
1036 #[test]
1037 fn null_collections_parse_as_empty_rather_than_schema_drift() {
1038 let response: UsageResponse = serde_json::from_str(
1039 r#"{
1040 "plan_type": "plus",
1041 "rate_limit": {
1042 "primary_window": {"used_percent": 0, "limit_window_seconds": 18000,
1043 "reset_after_seconds": 18000, "reset_at": 1788646037},
1044 "secondary_window": {"used_percent": 64, "limit_window_seconds": 604800,
1045 "reset_after_seconds": 152957, "reset_at": 1788780993}
1046 },
1047 "code_review_rate_limit": null,
1048 "additional_rate_limits": null,
1049 "model_usage": null,
1050 "rate_limit_reset_credits": {"available_count": 3, "credits": null}
1051 }"#,
1052 )
1053 .expect("null collections must parse");
1054 let snap = response.into_snapshot(None).unwrap();
1055 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 0);
1056 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 64);
1057 assert!(snap.additional_limits.is_empty());
1058 assert!(snap.unavailable_models.is_empty());
1059 assert_eq!(snap.reset_credits.as_ref().unwrap().available, 3);
1060 assert!(snap.reset_credits.as_ref().unwrap().credits.is_empty());
1061 }
1062}