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