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 pub additional_rate_limits: Vec<AdditionalRateLimit>,
45 pub model_usage: BTreeMap<String, ModelUsage>,
49}
50
51#[derive(Debug, Default, Clone, Deserialize, Serialize)]
52#[serde(default)]
53pub struct AdditionalRateLimit {
54 pub limit_name: Option<String>,
55 pub metered_feature: Option<String>,
56 pub rate_limit: Option<RateLimit>,
57}
58
59#[derive(Debug, Default, Clone, Deserialize, Serialize)]
60#[serde(default)]
61pub struct ModelUsage {
62 pub available: Option<bool>,
64 pub available_at: Option<DateTime<Utc>>,
65}
66
67#[derive(Debug, Default, Clone, Deserialize, Serialize)]
68#[serde(default)]
69pub struct RateLimit {
70 pub primary_window: Option<Window>,
71 pub secondary_window: Option<Window>,
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct Window {
76 #[serde(deserialize_with = "de_percent_number_or_string")]
77 pub used_percent: f64,
78 #[serde(deserialize_with = "de_i64_number_or_string")]
79 pub limit_window_seconds: i64,
80 #[serde(default, deserialize_with = "de_opt_int_or_float")]
82 pub reset_at: Option<i64>,
83 #[serde(default, deserialize_with = "de_opt_int_or_float")]
85 pub reset_after_seconds: Option<i64>,
86}
87
88#[derive(Debug, Clone, Deserialize, Serialize)]
89pub struct CreditsBlock {
90 #[serde(default, deserialize_with = "de_opt_money_string")]
91 pub balance: Option<String>,
92 pub has_credits: bool,
93 pub unlimited: bool,
94 #[serde(default)]
95 pub approx_local_messages: Option<Vec<i64>>,
96 #[serde(default)]
97 pub approx_cloud_messages: Option<Vec<i64>>,
98}
99
100#[derive(Debug, Default, Clone, Deserialize, Serialize)]
106#[serde(default)]
107pub struct ResetCreditsBlock {
108 pub available_count: u32,
109 pub credits: Vec<ResetCredit>,
110}
111
112#[derive(Debug, Default, Clone, Deserialize, Serialize)]
115#[serde(default)]
116pub struct ResetCredit {
117 pub status: String,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub title: Option<String>,
122 pub expires_at: Option<DateTime<Utc>>,
123}
124
125fn numeric_value<E: serde::de::Error>(v: serde_json::Value) -> Result<f64, E> {
130 let value = match v {
131 serde_json::Value::Number(n) => n
132 .as_f64()
133 .ok_or_else(|| E::custom("number is not representable as f64"))?,
134 serde_json::Value::String(s) => s
135 .parse::<f64>()
136 .map_err(|_| E::custom(format!("expected numeric string, got {s:?}")))?,
137 other => {
138 return Err(E::custom(format!(
139 "expected number or numeric string, got {other:?}"
140 )));
141 }
142 };
143 if value.is_finite() {
144 Ok(value)
145 } else {
146 Err(E::custom("number is not finite"))
147 }
148}
149
150fn de_percent_number_or_string<'de, D>(d: D) -> Result<f64, D::Error>
151where
152 D: serde::Deserializer<'de>,
153{
154 let v = serde_json::Value::deserialize(d)?;
155 let value = numeric_value::<D::Error>(v)?;
156 if (0.0..=101.0).contains(&value) {
157 Ok(value)
158 } else {
159 Err(serde::de::Error::custom(format!(
160 "percentage {value} outside 0..=100"
161 )))
162 }
163}
164
165fn de_i64_number_or_string<'de, D>(d: D) -> Result<i64, D::Error>
166where
167 D: serde::Deserializer<'de>,
168{
169 i64_value(serde_json::Value::deserialize(d)?)
170}
171
172fn i64_value<E: serde::de::Error>(v: serde_json::Value) -> Result<i64, E> {
173 match &v {
174 serde_json::Value::Number(n) => {
175 if let Some(i) = n.as_i64() {
176 return Ok(i);
177 }
178 }
179 serde_json::Value::String(s) => {
180 if let Ok(i) = s.parse::<i64>() {
181 return Ok(i);
182 }
183 }
184 _ => {}
185 }
186 exact_i64(numeric_value::<E>(v)?).ok_or_else(|| E::custom("expected an integer in i64 range"))
187}
188
189fn exact_i64(f: f64) -> Option<i64> {
195 const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
196 if f.is_finite() && f.trunc() == f && f.abs() <= MAX_EXACT_F64_INT {
197 Some(f as i64)
198 } else {
199 None
200 }
201}
202
203fn de_opt_int_or_float<'de, D>(d: D) -> Result<Option<i64>, D::Error>
204where
205 D: serde::Deserializer<'de>,
206{
207 let v = serde_json::Value::deserialize(d)?;
208 if v.is_null() {
209 Ok(None)
210 } else {
211 i64_value::<D::Error>(v).map(Some)
212 }
213}
214
215fn de_opt_money_string<'de, D>(d: D) -> Result<Option<String>, D::Error>
218where
219 D: serde::Deserializer<'de>,
220{
221 let v = serde_json::Value::deserialize(d)?;
222 match v {
223 serde_json::Value::Null => Ok(None),
224 serde_json::Value::String(s) => Ok(Some(s)),
225 serde_json::Value::Number(n) => match n.as_f64() {
226 Some(value) if value.is_finite() => Ok(Some(crate::format::usd(value))),
227 _ => Err(serde::de::Error::custom(
228 "credit balance is not a finite number",
229 )),
230 },
231 other => Err(serde::de::Error::custom(format!(
232 "expected credit balance string, number, or null; got {other:?}"
233 ))),
234 }
235}
236
237const MAX_RESET_TITLE_CHARS: usize = 80;
238
239fn checked_reset_title(value: Option<String>) -> Option<String> {
240 let value = value
241 .map(|s| s.trim().to_string())
242 .filter(|s| !s.is_empty())?;
243 if value.chars().count() > MAX_RESET_TITLE_CHARS || value.chars().any(char::is_control) {
244 None
245 } else {
246 Some(value)
247 }
248}
249
250impl UsageResponse {
251 pub fn into_snapshot(self, plan_hint: Option<&str>) -> AppResult<OpenAiSnapshot> {
252 let plan_type = self.plan_type.as_deref().or(plan_hint).unwrap_or("Unknown");
253 let plan = format!("ChatGPT {}", crate::format::capitalize(plan_type));
254
255 let (session, weekly) = classify_rate_limit(self.rate_limit.unwrap_or_default())?;
256 let code_review = self
257 .code_review_rate_limit
258 .and_then(|c| c.primary_window)
259 .map(|w| to_window(&w, chrono::Duration::days(7)));
260
261 let credits = self.credits.map(|c| OpenAiCredits {
262 balance: c.balance.unwrap_or_default(),
263 has_credits: c.has_credits,
264 unlimited: c.unlimited,
265 approx_local_messages: range_from_vec(c.approx_local_messages),
266 approx_cloud_messages: range_from_vec(c.approx_cloud_messages),
267 });
268 let reset_credits = self
269 .rate_limit_reset_credits
270 .map(|credits| ResetCredits {
271 available: credits.available_count,
272 credits: credits
273 .credits
274 .into_iter()
275 .filter(|credit| credit.status == "available")
276 .map(|credit| BankedReset {
277 title: checked_reset_title(credit.title),
278 expires_at: credit.expires_at,
279 })
280 .collect(),
281 })
282 .unwrap_or_default();
283
284 let additional_limits = self
285 .additional_rate_limits
286 .into_iter()
287 .filter_map(named_limit)
288 .collect();
289 let unavailable_models = self
292 .model_usage
293 .into_iter()
294 .filter(|(_, usage)| usage.available == Some(false))
295 .map(|(model, usage)| OpenAiUnavailableModel {
296 model,
297 available_at: usage.available_at,
298 })
299 .collect();
300
301 Ok(OpenAiSnapshot {
302 plan,
303 session,
304 weekly,
305 code_review,
306 additional_limits,
307 unavailable_models,
308 credits,
309 reset_credits,
310 source: OpenAiSource::CodexOauth,
311 })
312 }
313}
314
315#[derive(Clone, Copy, Debug)]
316enum WindowKind {
317 Session,
318 Weekly,
319}
320
321pub(crate) const SESSION_WINDOW_SECS: u64 = 18_000;
323pub(crate) const WEEKLY_WINDOW_SECS: u64 = 604_800;
325
326fn named_limit(entry: AdditionalRateLimit) -> Option<OpenAiNamedLimit> {
330 let name = entry
331 .limit_name
332 .or(entry.metered_feature)
333 .filter(|name| !name.trim().is_empty())?;
334 let (session, weekly) = classify_rate_limit(entry.rate_limit?).ok()?;
335 if session.is_none() && weekly.is_none() {
336 return None;
337 }
338 Some(OpenAiNamedLimit {
339 name: crate::display::sanitize_untrusted_field(&name),
340 session,
341 weekly,
342 })
343}
344
345fn classify_rate_limit(
346 rate_limit: RateLimit,
347) -> AppResult<(Option<UsageWindow>, Option<UsageWindow>)> {
348 let mut session = None;
349 let mut weekly = None;
350 insert_window(
351 rate_limit.primary_window,
352 WindowKind::Session,
353 &mut session,
354 &mut weekly,
355 )?;
356 insert_window(
357 rate_limit.secondary_window,
358 WindowKind::Weekly,
359 &mut session,
360 &mut weekly,
361 )?;
362 Ok((session, weekly))
363}
364
365fn insert_window(
366 wire_window: Option<Window>,
367 fallback_kind: WindowKind,
368 session: &mut Option<UsageWindow>,
369 weekly: &mut Option<UsageWindow>,
370) -> AppResult<()> {
371 let Some(wire_window) = wire_window else {
372 return Ok(());
373 };
374 let kind = window_kind(&wire_window).unwrap_or(fallback_kind);
375 let default_duration = kind.default_duration();
376 let target = semantic_window_target(kind, session, weekly);
377 if target.is_some() {
378 return Err(duplicate_window_error(
379 kind,
380 wire_window.limit_window_seconds,
381 ));
382 }
383 *target = Some(to_window(&wire_window, default_duration));
384 Ok(())
385}
386
387fn semantic_window_target<'a>(
388 kind: WindowKind,
389 session: &'a mut Option<UsageWindow>,
390 weekly: &'a mut Option<UsageWindow>,
391) -> &'a mut Option<UsageWindow> {
392 match kind {
393 WindowKind::Session => session,
394 WindowKind::Weekly => weekly,
395 }
396}
397
398fn window_kind(window: &Window) -> Option<WindowKind> {
399 match window.limit_window_seconds {
402 s if s == SESSION_WINDOW_SECS as i64 => Some(WindowKind::Session),
403 s if s == WEEKLY_WINDOW_SECS as i64 => Some(WindowKind::Weekly),
404 _ => None,
405 }
406}
407
408fn duplicate_window_error(kind: WindowKind, seconds: i64) -> AppError {
409 let label = match kind {
410 WindowKind::Session => "5h",
411 WindowKind::Weekly => "7d",
412 };
413 AppError::Schema(format!(
414 "duplicate OpenAI {label} window with limit_window_seconds={seconds}; expected at most one 5h and one 7d window"
415 ))
416}
417
418impl WindowKind {
419 fn default_duration(self) -> chrono::Duration {
420 match self {
421 Self::Session => chrono::Duration::seconds(SESSION_WINDOW_SECS as i64),
422 Self::Weekly => chrono::Duration::seconds(WEEKLY_WINDOW_SECS as i64),
423 }
424 }
425}
426
427fn to_window(w: &Window, default_dur: chrono::Duration) -> UsageWindow {
428 let dur = match chrono::Duration::try_seconds(w.limit_window_seconds) {
431 Some(d) if w.limit_window_seconds > 0 => d,
432 _ => default_dur,
433 };
434 let resets_at = match w.reset_at {
435 Some(secs) => chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0),
436 None => w
437 .reset_after_seconds
438 .and_then(chrono::Duration::try_seconds)
439 .and_then(|d| chrono::Utc::now().checked_add_signed(d)),
440 };
441 UsageWindow {
442 utilization_pct: (w.used_percent.round() as i32).clamp(0, 100),
443 resets_at,
444 window_duration: dur,
445 }
446}
447
448fn range_from_vec(v: Option<Vec<i64>>) -> Option<(i64, i64)> {
449 let v = v?;
450 if v.len() >= 2 {
451 Some((v[0], v[1]))
452 } else if v.len() == 1 {
453 Some((v[0], v[0]))
454 } else {
455 None
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 const REAL: &str = r#"{
464 "user_id":"u","account_id":"a","email":"e",
465 "plan_type":"plus",
466 "rate_limit":{"allowed":true,"limit_reached":false,
467 "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_after_seconds":18000,"reset_at":1779597324},
468 "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_after_seconds":604800,"reset_at":1780184124}
469 }
470 }"#;
471
472 #[test]
473 fn parses_real_shape() {
474 let r: UsageResponse = serde_json::from_str(REAL).unwrap();
475 let s = r.into_snapshot(None).unwrap();
476 assert_eq!(s.plan, "ChatGPT Plus");
477 assert_eq!(s.session.as_ref().unwrap().utilization_pct, 1);
478 assert_eq!(s.weekly.as_ref().unwrap().utilization_pct, 0);
479 assert_eq!(
480 s.session.as_ref().unwrap().window_duration,
481 chrono::Duration::hours(5)
482 );
483 assert_eq!(
484 s.weekly.as_ref().unwrap().window_duration,
485 chrono::Duration::days(7)
486 );
487 assert!(s.session.as_ref().unwrap().resets_at.is_some());
488 assert!(s.code_review.is_none());
489 assert!(s.credits.is_none());
490 assert!(matches!(s.source, OpenAiSource::CodexOauth));
491 }
492
493 #[test]
494 fn missing_rate_limit_reports_no_windows() {
495 let r: UsageResponse = serde_json::from_str(r#"{"plan_type":"pro"}"#).unwrap();
496 let s = r.into_snapshot(None).unwrap();
497 assert_eq!(s.plan, "ChatGPT Pro");
498 assert!(s.session.is_none());
499 assert!(s.weekly.is_none());
500 }
501
502 #[test]
503 fn weekly_only_primary_window_is_not_mislabeled_as_session() {
504 let body = r#"{
507 "plan_type":"prolite",
508 "rate_limit":{
509 "primary_window":{
510 "used_percent":66,
511 "limit_window_seconds":604800,
512 "reset_at":1785261834
513 },
514 "secondary_window":null
515 }
516 }"#;
517 let response: UsageResponse = serde_json::from_str(body).unwrap();
518 let snapshot = response.into_snapshot(None).unwrap();
519 assert!(snapshot.session.is_none());
520 assert_eq!(snapshot.weekly.unwrap().utilization_pct, 66);
521 }
522
523 #[test]
524 fn duration_classification_survives_reordered_wire_windows() {
525 let body = r#"{"rate_limit":{
526 "primary_window":{"used_percent":41,"limit_window_seconds":604800},
527 "secondary_window":{"used_percent":7,"limit_window_seconds":18000}
528 }}"#;
529 let response: UsageResponse = serde_json::from_str(body).unwrap();
530 let snapshot = response.into_snapshot(None).unwrap();
531 assert_eq!(snapshot.session.unwrap().utilization_pct, 7);
532 assert_eq!(snapshot.weekly.unwrap().utilization_pct, 41);
533 }
534
535 #[test]
536 fn duplicate_semantic_windows_are_schema_drift() {
537 let body = r#"{"rate_limit":{
538 "primary_window":{"used_percent":41,"limit_window_seconds":604800},
539 "secondary_window":{"used_percent":7,"limit_window_seconds":604800}
540 }}"#;
541 let response: UsageResponse = serde_json::from_str(body).unwrap();
542 let error = response.into_snapshot(None).unwrap_err().to_string();
543 assert!(error.contains("duplicate OpenAI 7d window"));
544 assert!(error.contains("limit_window_seconds=604800"));
545 }
546
547 #[test]
548 fn unknown_duration_falls_back_to_wire_position() {
549 let body = r#"{"rate_limit":{
553 "primary_window":{"used_percent":10,"limit_window_seconds":3600},
554 "secondary_window":{"used_percent":20,"limit_window_seconds":3600}
555 }}"#;
556 let response: UsageResponse = serde_json::from_str(body).unwrap();
557 let snapshot = response.into_snapshot(None).unwrap();
558 assert_eq!(snapshot.session.unwrap().utilization_pct, 10);
559 assert_eq!(snapshot.weekly.unwrap().utilization_pct, 20);
560 }
561
562 #[test]
563 fn credits_block_parses_with_message_ranges() {
564 let body = r#"{
565 "plan_type":"plus",
566 "credits":{"balance":"$2.50","has_credits":true,"unlimited":false,
567 "approx_local_messages":[100,200],"approx_cloud_messages":[40,60]}
568 }"#;
569 let r: UsageResponse = serde_json::from_str(body).unwrap();
570 let s = r.into_snapshot(None).unwrap();
571 let c = s.credits.unwrap();
572 assert_eq!(c.balance, "$2.50");
573 assert!(c.has_credits);
574 assert_eq!(c.approx_local_messages, Some((100, 200)));
575 assert_eq!(c.approx_cloud_messages, Some((40, 60)));
576 }
577
578 #[test]
582 fn reset_credit_count_stands_on_its_own_without_the_detail_call() {
583 let body = r#"{"plan_type":"plus","rate_limit_reset_credits":{"available_count":2}}"#;
584 let s: UsageResponse = serde_json::from_str(body).unwrap();
585 let s = s.into_snapshot(None).unwrap();
586 assert_eq!(s.reset_credits.available, 2);
587 assert!(s.reset_credits.credits.is_empty());
588 assert!(!s.reset_credits.is_empty());
589 }
590
591 #[test]
594 fn only_an_available_credit_contributes_an_expiry() {
595 let body = r#"{
596 "rate_limit_reset_credits":{
597 "available_count":1,
598 "credits":[
599 {"id":"c1","status":"redeemed","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-07-01T00:00:00Z"},
600 {"id":"c2","status":"available","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-07-17T00:00:00Z"},
601 {"id":"c3","status":"available","expires_at":null}
602 ]
603 }
604 }"#;
605 let s: UsageResponse = serde_json::from_str(body).unwrap();
606 let s = s.into_snapshot(None).unwrap();
607 assert_eq!(s.reset_credits.available, 1);
608 assert_eq!(s.reset_credits.credits.len(), 2);
609 assert_eq!(
610 s.reset_credits.credits[0].title.as_deref(),
611 Some("Full reset (Weekly + 5 hr)")
612 );
613 assert_eq!(
614 s.reset_credits.next_expiry(),
615 Some("2026-07-17T00:00:00Z".parse::<DateTime<Utc>>().unwrap())
616 );
617 }
618
619 #[test]
623 fn an_absent_reset_block_is_no_credits_rather_than_an_error() {
624 let s: UsageResponse = serde_json::from_str(r#"{"plan_type":"plus"}"#).unwrap();
625 assert!(s.into_snapshot(None).unwrap().reset_credits.is_empty());
626 }
627
628 #[test]
629 fn balance_as_number_formats_to_dollars() {
630 let body = r#"{"credits":{"balance":1.5,"has_credits":true,"unlimited":false}}"#;
631 let r: UsageResponse = serde_json::from_str(body).unwrap();
632 let s = r.into_snapshot(None).unwrap();
633 assert_eq!(s.credits.unwrap().balance, "$1.50");
634 }
635
636 #[test]
637 fn benign_percent_overshoot_clamps_to_hundred() {
638 let body =
639 r#"{"rate_limit":{"primary_window":{"used_percent":100.6,"limit_window_seconds":1}}}"#;
640 let r: UsageResponse = serde_json::from_str(body).unwrap();
641 let s = r.into_snapshot(None).unwrap();
642 assert_eq!(s.session.unwrap().utilization_pct, 100);
643 }
644
645 #[test]
646 fn out_of_range_percent_is_schema_drift() {
647 for used_percent in ["-1", "101.5", "250"] {
648 let body = format!(
649 r#"{{"rate_limit":{{"primary_window":{{"used_percent":{used_percent},"limit_window_seconds":1}}}}}}"#
650 );
651 assert!(
652 serde_json::from_str::<UsageResponse>(&body).is_err(),
653 "{used_percent} must not become a clamped usage value"
654 );
655 }
656 }
657
658 #[test]
659 fn plan_hint_used_when_response_omits_plan_type() {
660 let r: UsageResponse = serde_json::from_str("{}").unwrap();
661 let s = r.into_snapshot(Some("team")).unwrap();
662 assert_eq!(s.plan, "ChatGPT Team");
663 }
664
665 #[test]
666 fn window_counters_accept_fractional_percent_and_integral_number_forms() {
667 let w: Window =
668 serde_json::from_str(r#"{"used_percent":7.4,"limit_window_seconds":18000.0}"#).unwrap();
669 assert_eq!(w.used_percent, 7.4);
670 assert_eq!(w.limit_window_seconds, 18000);
671
672 let w: Window =
673 serde_json::from_str(r#"{"used_percent":"42.7","limit_window_seconds":"604800.0"}"#)
674 .unwrap();
675 assert_eq!(w.used_percent, 42.7);
676 assert_eq!(w.limit_window_seconds, 604800);
677
678 let r: UsageResponse = serde_json::from_str(
679 r#"{"rate_limit":{"primary_window":{"used_percent":42.7,"limit_window_seconds":18000}}}"#,
680 )
681 .unwrap();
682 assert_eq!(
683 r.into_snapshot(None)
684 .unwrap()
685 .session
686 .unwrap()
687 .utilization_pct,
688 43
689 );
690 }
691
692 #[test]
693 fn fractional_integer_counters_are_schema_drift() {
694 for value in ["18000.9", r#""604800.5""#] {
695 let body = format!(r#"{{"used_percent":7,"limit_window_seconds":{value}}}"#);
696 assert!(serde_json::from_str::<Window>(&body).is_err(), "{value}");
697 }
698 }
699
700 #[test]
701 fn null_counter_is_schema_drift() {
702 let body = r#"{"used_percent":null,"limit_window_seconds":1}"#;
705 assert!(serde_json::from_str::<Window>(body).is_err());
706 let body = r#"{"used_percent":1,"limit_window_seconds":null}"#;
707 assert!(serde_json::from_str::<Window>(body).is_err());
708 }
709
710 #[test]
711 fn non_numeric_counter_shapes_are_schema_drift() {
712 for bad in [
713 r#""many""#,
714 r#"{"value":1}"#,
715 "[1]",
716 "true",
717 r#""NaN""#,
720 r#""inf""#,
721 "1e300",
722 "-1e300",
723 r#""1e300""#,
724 ] {
725 let body = format!(r#"{{"used_percent":{bad},"limit_window_seconds":1}}"#);
726 assert!(
727 serde_json::from_str::<Window>(&body).is_err(),
728 "used_percent {bad} must not deserialize"
729 );
730 }
731 }
732
733 #[test]
734 fn drifted_counter_fails_whole_usage_response() {
735 let body = r#"{"plan_type":"plus","rate_limit":{
738 "primary_window":{"used_percent":"n/a","limit_window_seconds":18000}
739 }}"#;
740 assert!(serde_json::from_str::<UsageResponse>(body).is_err());
741 }
742
743 #[test]
744 fn a_present_window_requires_both_counters() {
745 for body in [
746 r#"{"used_percent":1}"#,
747 r#"{"limit_window_seconds":18000}"#,
748 "{}",
749 ] {
750 assert!(serde_json::from_str::<Window>(body).is_err(), "{body}");
751 }
752 }
753
754 #[test]
755 fn malformed_optional_counters_are_not_treated_as_absent() {
756 for field in ["reset_at", "reset_after_seconds"] {
757 for bad in ["true", r#""tomorrow""#, "1.5", "{}"] {
758 let body =
759 format!(r#"{{"used_percent":1,"limit_window_seconds":18000,"{field}":{bad}}}"#);
760 assert!(serde_json::from_str::<Window>(&body).is_err(), "{body}");
761 }
762 }
763 }
764
765 #[test]
766 fn credits_reject_invalid_present_values_without_inventing_zero() {
767 for balance in ["true", "{}", "[]"] {
768 let body = format!(
769 r#"{{"credits":{{"balance":{balance},"has_credits":true,"unlimited":false}}}}"#
770 );
771 assert!(serde_json::from_str::<UsageResponse>(&body).is_err());
772 }
773 assert!(
774 serde_json::from_str::<UsageResponse>(r#"{"credits":{"balance":"$1.00"}}"#).is_err(),
775 "a present credits block must not default its status flags"
776 );
777
778 let response: UsageResponse = serde_json::from_str(
779 r#"{"credits":{"balance":null,"has_credits":false,"unlimited":true}}"#,
780 )
781 .unwrap();
782 assert_eq!(
783 response
784 .into_snapshot(None)
785 .unwrap()
786 .credits
787 .unwrap()
788 .balance,
789 ""
790 );
791 }
792
793 #[test]
794 fn oversized_window_seconds_degrades_instead_of_panicking() {
795 let body = r#"{"rate_limit":{"primary_window":{
799 "used_percent":1,"limit_window_seconds":9223372036854775807,
800 "reset_after_seconds":9223372036854775807
801 }}}"#;
802 let r: UsageResponse = serde_json::from_str(body).unwrap();
803 let s = r.into_snapshot(None).unwrap();
804 let session = s.session.unwrap();
805 assert_eq!(session.window_duration, chrono::Duration::hours(5));
806 assert!(session.resets_at.is_none());
807 }
808
809 #[test]
810 fn missing_reset_at_falls_back_to_after_seconds() {
811 let body = r#"{"rate_limit":{"primary_window":{
812 "used_percent":50,"limit_window_seconds":1000,"reset_after_seconds":500
813 }}}"#;
814 let r: UsageResponse = serde_json::from_str(body).unwrap();
815 let s = r.into_snapshot(None).unwrap();
816 let now = chrono::Utc::now();
818 let reset = s.session.unwrap().resets_at.unwrap();
819 let delta = reset.signed_duration_since(now).num_seconds();
820 assert!((400..=600).contains(&delta), "got delta={delta}");
821 }
822 #[test]
827 fn named_limits_and_unavailable_models_are_read_from_the_live_shape() {
828 let response: UsageResponse = serde_json::from_str(
829 r#"{
830 "plan_type": "pro",
831 "rate_limit": {
832 "allowed": true, "limit_reached": false,
833 "primary_window": {"used_percent": 5, "limit_window_seconds": 604800,
834 "reset_after_seconds": 400000, "reset_at": 1789000000},
835 "secondary_window": null
836 },
837 "code_review_rate_limit": null,
838 "additional_rate_limits": [
839 {"limit_name": "GPT-5.3-Codex-Spark", "metered_feature": "codex_bengalfox",
840 "rate_limit": {
841 "primary_window": {"used_percent": 12, "limit_window_seconds": 18000,
842 "reset_at": 1788000000},
843 "secondary_window": {"used_percent": 34, "limit_window_seconds": 604800,
844 "reset_at": 1789500000}},
845 "normal_model_slug": null},
846 {"limit_name": "gpt-reserve", "metered_feature": "base_model_inference",
847 "rate_limit": {
848 "primary_window": {"used_percent": 71, "limit_window_seconds": 604800,
849 "reset_at": 1789500000},
850 "secondary_window": null}}
851 ],
852 "model_usage": {
853 "gpt-6-astra": {"available": false, "available_at": "2026-09-05T22:00:00Z",
854 "credits_would_enable": false},
855 "gpt-5.3-codex": {"available": true, "available_at": null}
856 }
857 }"#,
858 )
859 .expect("the live response shape parses");
860
861 let snap = response.into_snapshot(None).unwrap();
862
863 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 5);
866
867 assert_eq!(snap.additional_limits.len(), 2);
868 let spark = &snap.additional_limits[0];
869 assert_eq!(spark.name, "GPT-5.3-Codex-Spark");
870 assert_eq!(spark.session.as_ref().unwrap().utilization_pct, 12);
871 assert_eq!(spark.weekly.as_ref().unwrap().utilization_pct, 34);
872 let reserve = &snap.additional_limits[1];
875 assert_eq!(reserve.name, "gpt-reserve");
876 assert!(reserve.session.is_none());
877 assert_eq!(reserve.weekly.as_ref().unwrap().utilization_pct, 71);
878
879 assert_eq!(snap.unavailable_models.len(), 1);
881 assert_eq!(snap.unavailable_models[0].model, "gpt-6-astra");
882 assert!(snap.unavailable_models[0].available_at.is_some());
883 }
884
885 #[test]
888 fn an_account_without_extra_limits_reports_none_rather_than_empty_rows() {
889 let response: UsageResponse = serde_json::from_str(
890 r#"{"plan_type": "plus",
891 "rate_limit": {"primary_window": {"used_percent": 3,
892 "limit_window_seconds": 604800}}}"#,
893 )
894 .unwrap();
895 let snap = response.into_snapshot(None).unwrap();
896
897 assert!(snap.additional_limits.is_empty());
898 assert!(snap.unavailable_models.is_empty());
899 }
900
901 #[test]
905 fn nameless_or_windowless_limits_are_dropped() {
906 let response: UsageResponse = serde_json::from_str(
907 r#"{"additional_rate_limits": [
908 {"limit_name": null, "metered_feature": "base_model_inference",
909 "rate_limit": {"primary_window": {"used_percent": 9,
910 "limit_window_seconds": 604800}}},
911 {"limit_name": "no windows", "rate_limit": {"primary_window": null,
912 "secondary_window": null}},
913 {"limit_name": " ", "rate_limit": {"primary_window":
914 {"used_percent": 1, "limit_window_seconds": 18000}}}
915 ]}"#,
916 )
917 .unwrap();
918 let snap = response.into_snapshot(None).unwrap();
919
920 assert_eq!(snap.additional_limits.len(), 1);
921 assert_eq!(snap.additional_limits[0].name, "base_model_inference");
922 }
923
924 #[test]
927 fn a_model_without_an_availability_flag_is_not_reported_as_down() {
928 let response: UsageResponse =
929 serde_json::from_str(r#"{"model_usage": {"gpt-6-astra": {"available_at": null}}}"#)
930 .unwrap();
931 assert!(
932 response
933 .into_snapshot(None)
934 .unwrap()
935 .unavailable_models
936 .is_empty()
937 );
938 }
939}