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_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#[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_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
250fn 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
266fn 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 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
353pub(crate) const SESSION_WINDOW_SECS: u64 = 18_000;
355pub(crate) const WEEKLY_WINDOW_SECS: u64 = 604_800;
357
358fn 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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}