1use serde::Deserialize;
21
22use crate::error::{AppError, Result as AppResult};
23use crate::usage::{OpenAiCredits, OpenAiSnapshot, OpenAiSource, UsageWindow};
24
25#[derive(Debug, Default, Clone, Deserialize)]
26#[serde(default)]
27pub struct UsageResponse {
28 pub plan_type: Option<String>,
29 pub rate_limit: Option<RateLimit>,
30 pub code_review_rate_limit: Option<RateLimit>,
31 pub credits: Option<CreditsBlock>,
32}
33
34#[derive(Debug, Default, Clone, Deserialize)]
35#[serde(default)]
36pub struct RateLimit {
37 pub primary_window: Option<Window>,
38 pub secondary_window: Option<Window>,
39}
40
41#[derive(Debug, Clone, Deserialize)]
42pub struct Window {
43 #[serde(deserialize_with = "de_percent_number_or_string")]
44 pub used_percent: f64,
45 #[serde(deserialize_with = "de_i64_number_or_string")]
46 pub limit_window_seconds: i64,
47 #[serde(default, deserialize_with = "de_opt_int_or_float")]
49 pub reset_at: Option<i64>,
50 #[serde(default, deserialize_with = "de_opt_int_or_float")]
52 pub reset_after_seconds: Option<i64>,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56pub struct CreditsBlock {
57 #[serde(default, deserialize_with = "de_opt_money_string")]
58 pub balance: Option<String>,
59 pub has_credits: bool,
60 pub unlimited: bool,
61 #[serde(default)]
62 pub approx_local_messages: Option<Vec<i64>>,
63 #[serde(default)]
64 pub approx_cloud_messages: Option<Vec<i64>>,
65}
66
67fn numeric_value<E: serde::de::Error>(v: serde_json::Value) -> Result<f64, E> {
72 let value = match v {
73 serde_json::Value::Number(n) => n
74 .as_f64()
75 .ok_or_else(|| E::custom("number is not representable as f64"))?,
76 serde_json::Value::String(s) => s
77 .parse::<f64>()
78 .map_err(|_| E::custom(format!("expected numeric string, got {s:?}")))?,
79 other => {
80 return Err(E::custom(format!(
81 "expected number or numeric string, got {other:?}"
82 )));
83 }
84 };
85 if value.is_finite() {
86 Ok(value)
87 } else {
88 Err(E::custom("number is not finite"))
89 }
90}
91
92fn de_percent_number_or_string<'de, D>(d: D) -> Result<f64, D::Error>
93where
94 D: serde::Deserializer<'de>,
95{
96 let v = serde_json::Value::deserialize(d)?;
97 let value = numeric_value::<D::Error>(v)?;
98 if (0.0..=101.0).contains(&value) {
99 Ok(value)
100 } else {
101 Err(serde::de::Error::custom(format!(
102 "percentage {value} outside 0..=100"
103 )))
104 }
105}
106
107fn de_i64_number_or_string<'de, D>(d: D) -> Result<i64, D::Error>
108where
109 D: serde::Deserializer<'de>,
110{
111 i64_value(serde_json::Value::deserialize(d)?)
112}
113
114fn i64_value<E: serde::de::Error>(v: serde_json::Value) -> Result<i64, E> {
115 match &v {
116 serde_json::Value::Number(n) => {
117 if let Some(i) = n.as_i64() {
118 return Ok(i);
119 }
120 }
121 serde_json::Value::String(s) => {
122 if let Ok(i) = s.parse::<i64>() {
123 return Ok(i);
124 }
125 }
126 _ => {}
127 }
128 exact_i64(numeric_value::<E>(v)?).ok_or_else(|| E::custom("expected an integer in i64 range"))
129}
130
131fn exact_i64(f: f64) -> Option<i64> {
137 const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
138 if f.is_finite() && f.trunc() == f && f.abs() <= MAX_EXACT_F64_INT {
139 Some(f as i64)
140 } else {
141 None
142 }
143}
144
145fn de_opt_int_or_float<'de, D>(d: D) -> Result<Option<i64>, D::Error>
146where
147 D: serde::Deserializer<'de>,
148{
149 let v = serde_json::Value::deserialize(d)?;
150 if v.is_null() {
151 Ok(None)
152 } else {
153 i64_value::<D::Error>(v).map(Some)
154 }
155}
156
157fn de_opt_money_string<'de, D>(d: D) -> Result<Option<String>, D::Error>
160where
161 D: serde::Deserializer<'de>,
162{
163 let v = serde_json::Value::deserialize(d)?;
164 match v {
165 serde_json::Value::Null => Ok(None),
166 serde_json::Value::String(s) => Ok(Some(s)),
167 serde_json::Value::Number(n) => match n.as_f64() {
168 Some(value) if value.is_finite() => Ok(Some(crate::format::usd(value))),
169 _ => Err(serde::de::Error::custom(
170 "credit balance is not a finite number",
171 )),
172 },
173 other => Err(serde::de::Error::custom(format!(
174 "expected credit balance string, number, or null; got {other:?}"
175 ))),
176 }
177}
178
179impl UsageResponse {
180 pub fn into_snapshot(self, plan_hint: Option<&str>) -> AppResult<OpenAiSnapshot> {
181 let plan_type = self.plan_type.as_deref().or(plan_hint).unwrap_or("Unknown");
182 let plan = format!("ChatGPT {}", crate::format::capitalize(plan_type));
183
184 let (session, weekly) = classify_rate_limit(self.rate_limit.unwrap_or_default())?;
185 let code_review = self
186 .code_review_rate_limit
187 .and_then(|c| c.primary_window)
188 .map(|w| to_window(&w, chrono::Duration::days(7)));
189
190 let credits = self.credits.map(|c| OpenAiCredits {
191 balance: c.balance.unwrap_or_default(),
192 has_credits: c.has_credits,
193 unlimited: c.unlimited,
194 approx_local_messages: range_from_vec(c.approx_local_messages),
195 approx_cloud_messages: range_from_vec(c.approx_cloud_messages),
196 });
197
198 Ok(OpenAiSnapshot {
199 plan,
200 session,
201 weekly,
202 code_review,
203 credits,
204 source: OpenAiSource::CodexOauth,
205 })
206 }
207}
208
209#[derive(Clone, Copy, Debug)]
210enum WindowKind {
211 Session,
212 Weekly,
213}
214
215pub(crate) const SESSION_WINDOW_SECS: u64 = 18_000;
217pub(crate) const WEEKLY_WINDOW_SECS: u64 = 604_800;
219
220fn classify_rate_limit(
221 rate_limit: RateLimit,
222) -> AppResult<(Option<UsageWindow>, Option<UsageWindow>)> {
223 let mut session = None;
224 let mut weekly = None;
225 insert_window(
226 rate_limit.primary_window,
227 WindowKind::Session,
228 &mut session,
229 &mut weekly,
230 )?;
231 insert_window(
232 rate_limit.secondary_window,
233 WindowKind::Weekly,
234 &mut session,
235 &mut weekly,
236 )?;
237 Ok((session, weekly))
238}
239
240fn insert_window(
241 wire_window: Option<Window>,
242 fallback_kind: WindowKind,
243 session: &mut Option<UsageWindow>,
244 weekly: &mut Option<UsageWindow>,
245) -> AppResult<()> {
246 let Some(wire_window) = wire_window else {
247 return Ok(());
248 };
249 let kind = window_kind(&wire_window).unwrap_or(fallback_kind);
250 let default_duration = kind.default_duration();
251 let target = semantic_window_target(kind, session, weekly);
252 if target.is_some() {
253 return Err(duplicate_window_error(
254 kind,
255 wire_window.limit_window_seconds,
256 ));
257 }
258 *target = Some(to_window(&wire_window, default_duration));
259 Ok(())
260}
261
262fn semantic_window_target<'a>(
263 kind: WindowKind,
264 session: &'a mut Option<UsageWindow>,
265 weekly: &'a mut Option<UsageWindow>,
266) -> &'a mut Option<UsageWindow> {
267 match kind {
268 WindowKind::Session => session,
269 WindowKind::Weekly => weekly,
270 }
271}
272
273fn window_kind(window: &Window) -> Option<WindowKind> {
274 match window.limit_window_seconds {
277 s if s == SESSION_WINDOW_SECS as i64 => Some(WindowKind::Session),
278 s if s == WEEKLY_WINDOW_SECS as i64 => Some(WindowKind::Weekly),
279 _ => None,
280 }
281}
282
283fn duplicate_window_error(kind: WindowKind, seconds: i64) -> AppError {
284 let label = match kind {
285 WindowKind::Session => "5h",
286 WindowKind::Weekly => "7d",
287 };
288 AppError::Schema(format!(
289 "duplicate OpenAI {label} window with limit_window_seconds={seconds}; expected at most one 5h and one 7d window"
290 ))
291}
292
293impl WindowKind {
294 fn default_duration(self) -> chrono::Duration {
295 match self {
296 Self::Session => chrono::Duration::seconds(SESSION_WINDOW_SECS as i64),
297 Self::Weekly => chrono::Duration::seconds(WEEKLY_WINDOW_SECS as i64),
298 }
299 }
300}
301
302fn to_window(w: &Window, default_dur: chrono::Duration) -> UsageWindow {
303 let dur = match chrono::Duration::try_seconds(w.limit_window_seconds) {
306 Some(d) if w.limit_window_seconds > 0 => d,
307 _ => default_dur,
308 };
309 let resets_at = match w.reset_at {
310 Some(secs) => chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0),
311 None => w
312 .reset_after_seconds
313 .and_then(chrono::Duration::try_seconds)
314 .and_then(|d| chrono::Utc::now().checked_add_signed(d)),
315 };
316 UsageWindow {
317 utilization_pct: (w.used_percent.round() as i32).clamp(0, 100),
318 resets_at,
319 window_duration: dur,
320 }
321}
322
323fn range_from_vec(v: Option<Vec<i64>>) -> Option<(i64, i64)> {
324 let v = v?;
325 if v.len() >= 2 {
326 Some((v[0], v[1]))
327 } else if v.len() == 1 {
328 Some((v[0], v[0]))
329 } else {
330 None
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 const REAL: &str = r#"{
339 "user_id":"u","account_id":"a","email":"e",
340 "plan_type":"plus",
341 "rate_limit":{"allowed":true,"limit_reached":false,
342 "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_after_seconds":18000,"reset_at":1779597324},
343 "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_after_seconds":604800,"reset_at":1780184124}
344 }
345 }"#;
346
347 #[test]
348 fn parses_real_shape() {
349 let r: UsageResponse = serde_json::from_str(REAL).unwrap();
350 let s = r.into_snapshot(None).unwrap();
351 assert_eq!(s.plan, "ChatGPT Plus");
352 assert_eq!(s.session.as_ref().unwrap().utilization_pct, 1);
353 assert_eq!(s.weekly.as_ref().unwrap().utilization_pct, 0);
354 assert_eq!(
355 s.session.as_ref().unwrap().window_duration,
356 chrono::Duration::hours(5)
357 );
358 assert_eq!(
359 s.weekly.as_ref().unwrap().window_duration,
360 chrono::Duration::days(7)
361 );
362 assert!(s.session.as_ref().unwrap().resets_at.is_some());
363 assert!(s.code_review.is_none());
364 assert!(s.credits.is_none());
365 assert!(matches!(s.source, OpenAiSource::CodexOauth));
366 }
367
368 #[test]
369 fn missing_rate_limit_reports_no_windows() {
370 let r: UsageResponse = serde_json::from_str(r#"{"plan_type":"pro"}"#).unwrap();
371 let s = r.into_snapshot(None).unwrap();
372 assert_eq!(s.plan, "ChatGPT Pro");
373 assert!(s.session.is_none());
374 assert!(s.weekly.is_none());
375 }
376
377 #[test]
378 fn weekly_only_primary_window_is_not_mislabeled_as_session() {
379 let body = r#"{
382 "plan_type":"prolite",
383 "rate_limit":{
384 "primary_window":{
385 "used_percent":66,
386 "limit_window_seconds":604800,
387 "reset_at":1785261834
388 },
389 "secondary_window":null
390 }
391 }"#;
392 let response: UsageResponse = serde_json::from_str(body).unwrap();
393 let snapshot = response.into_snapshot(None).unwrap();
394 assert!(snapshot.session.is_none());
395 assert_eq!(snapshot.weekly.unwrap().utilization_pct, 66);
396 }
397
398 #[test]
399 fn duration_classification_survives_reordered_wire_windows() {
400 let body = r#"{"rate_limit":{
401 "primary_window":{"used_percent":41,"limit_window_seconds":604800},
402 "secondary_window":{"used_percent":7,"limit_window_seconds":18000}
403 }}"#;
404 let response: UsageResponse = serde_json::from_str(body).unwrap();
405 let snapshot = response.into_snapshot(None).unwrap();
406 assert_eq!(snapshot.session.unwrap().utilization_pct, 7);
407 assert_eq!(snapshot.weekly.unwrap().utilization_pct, 41);
408 }
409
410 #[test]
411 fn duplicate_semantic_windows_are_schema_drift() {
412 let body = r#"{"rate_limit":{
413 "primary_window":{"used_percent":41,"limit_window_seconds":604800},
414 "secondary_window":{"used_percent":7,"limit_window_seconds":604800}
415 }}"#;
416 let response: UsageResponse = serde_json::from_str(body).unwrap();
417 let error = response.into_snapshot(None).unwrap_err().to_string();
418 assert!(error.contains("duplicate OpenAI 7d window"));
419 assert!(error.contains("limit_window_seconds=604800"));
420 }
421
422 #[test]
423 fn unknown_duration_falls_back_to_wire_position() {
424 let body = r#"{"rate_limit":{
428 "primary_window":{"used_percent":10,"limit_window_seconds":3600},
429 "secondary_window":{"used_percent":20,"limit_window_seconds":3600}
430 }}"#;
431 let response: UsageResponse = serde_json::from_str(body).unwrap();
432 let snapshot = response.into_snapshot(None).unwrap();
433 assert_eq!(snapshot.session.unwrap().utilization_pct, 10);
434 assert_eq!(snapshot.weekly.unwrap().utilization_pct, 20);
435 }
436
437 #[test]
438 fn credits_block_parses_with_message_ranges() {
439 let body = r#"{
440 "plan_type":"plus",
441 "credits":{"balance":"$2.50","has_credits":true,"unlimited":false,
442 "approx_local_messages":[100,200],"approx_cloud_messages":[40,60]}
443 }"#;
444 let r: UsageResponse = serde_json::from_str(body).unwrap();
445 let s = r.into_snapshot(None).unwrap();
446 let c = s.credits.unwrap();
447 assert_eq!(c.balance, "$2.50");
448 assert!(c.has_credits);
449 assert_eq!(c.approx_local_messages, Some((100, 200)));
450 assert_eq!(c.approx_cloud_messages, Some((40, 60)));
451 }
452
453 #[test]
454 fn balance_as_number_formats_to_dollars() {
455 let body = r#"{"credits":{"balance":1.5,"has_credits":true,"unlimited":false}}"#;
456 let r: UsageResponse = serde_json::from_str(body).unwrap();
457 let s = r.into_snapshot(None).unwrap();
458 assert_eq!(s.credits.unwrap().balance, "$1.50");
459 }
460
461 #[test]
462 fn benign_percent_overshoot_clamps_to_hundred() {
463 let body =
464 r#"{"rate_limit":{"primary_window":{"used_percent":100.6,"limit_window_seconds":1}}}"#;
465 let r: UsageResponse = serde_json::from_str(body).unwrap();
466 let s = r.into_snapshot(None).unwrap();
467 assert_eq!(s.session.unwrap().utilization_pct, 100);
468 }
469
470 #[test]
471 fn out_of_range_percent_is_schema_drift() {
472 for used_percent in ["-1", "101.5", "250"] {
473 let body = format!(
474 r#"{{"rate_limit":{{"primary_window":{{"used_percent":{used_percent},"limit_window_seconds":1}}}}}}"#
475 );
476 assert!(
477 serde_json::from_str::<UsageResponse>(&body).is_err(),
478 "{used_percent} must not become a clamped usage value"
479 );
480 }
481 }
482
483 #[test]
484 fn plan_hint_used_when_response_omits_plan_type() {
485 let r: UsageResponse = serde_json::from_str("{}").unwrap();
486 let s = r.into_snapshot(Some("team")).unwrap();
487 assert_eq!(s.plan, "ChatGPT Team");
488 }
489
490 #[test]
491 fn window_counters_accept_fractional_percent_and_integral_number_forms() {
492 let w: Window =
493 serde_json::from_str(r#"{"used_percent":7.4,"limit_window_seconds":18000.0}"#).unwrap();
494 assert_eq!(w.used_percent, 7.4);
495 assert_eq!(w.limit_window_seconds, 18000);
496
497 let w: Window =
498 serde_json::from_str(r#"{"used_percent":"42.7","limit_window_seconds":"604800.0"}"#)
499 .unwrap();
500 assert_eq!(w.used_percent, 42.7);
501 assert_eq!(w.limit_window_seconds, 604800);
502
503 let r: UsageResponse = serde_json::from_str(
504 r#"{"rate_limit":{"primary_window":{"used_percent":42.7,"limit_window_seconds":18000}}}"#,
505 )
506 .unwrap();
507 assert_eq!(
508 r.into_snapshot(None)
509 .unwrap()
510 .session
511 .unwrap()
512 .utilization_pct,
513 43
514 );
515 }
516
517 #[test]
518 fn fractional_integer_counters_are_schema_drift() {
519 for value in ["18000.9", r#""604800.5""#] {
520 let body = format!(r#"{{"used_percent":7,"limit_window_seconds":{value}}}"#);
521 assert!(serde_json::from_str::<Window>(&body).is_err(), "{value}");
522 }
523 }
524
525 #[test]
526 fn null_counter_is_schema_drift() {
527 let body = r#"{"used_percent":null,"limit_window_seconds":1}"#;
530 assert!(serde_json::from_str::<Window>(body).is_err());
531 let body = r#"{"used_percent":1,"limit_window_seconds":null}"#;
532 assert!(serde_json::from_str::<Window>(body).is_err());
533 }
534
535 #[test]
536 fn non_numeric_counter_shapes_are_schema_drift() {
537 for bad in [
538 r#""many""#,
539 r#"{"value":1}"#,
540 "[1]",
541 "true",
542 r#""NaN""#,
545 r#""inf""#,
546 "1e300",
547 "-1e300",
548 r#""1e300""#,
549 ] {
550 let body = format!(r#"{{"used_percent":{bad},"limit_window_seconds":1}}"#);
551 assert!(
552 serde_json::from_str::<Window>(&body).is_err(),
553 "used_percent {bad} must not deserialize"
554 );
555 }
556 }
557
558 #[test]
559 fn drifted_counter_fails_whole_usage_response() {
560 let body = r#"{"plan_type":"plus","rate_limit":{
563 "primary_window":{"used_percent":"n/a","limit_window_seconds":18000}
564 }}"#;
565 assert!(serde_json::from_str::<UsageResponse>(body).is_err());
566 }
567
568 #[test]
569 fn a_present_window_requires_both_counters() {
570 for body in [
571 r#"{"used_percent":1}"#,
572 r#"{"limit_window_seconds":18000}"#,
573 "{}",
574 ] {
575 assert!(serde_json::from_str::<Window>(body).is_err(), "{body}");
576 }
577 }
578
579 #[test]
580 fn malformed_optional_counters_are_not_treated_as_absent() {
581 for field in ["reset_at", "reset_after_seconds"] {
582 for bad in ["true", r#""tomorrow""#, "1.5", "{}"] {
583 let body =
584 format!(r#"{{"used_percent":1,"limit_window_seconds":18000,"{field}":{bad}}}"#);
585 assert!(serde_json::from_str::<Window>(&body).is_err(), "{body}");
586 }
587 }
588 }
589
590 #[test]
591 fn credits_reject_invalid_present_values_without_inventing_zero() {
592 for balance in ["true", "{}", "[]"] {
593 let body = format!(
594 r#"{{"credits":{{"balance":{balance},"has_credits":true,"unlimited":false}}}}"#
595 );
596 assert!(serde_json::from_str::<UsageResponse>(&body).is_err());
597 }
598 assert!(
599 serde_json::from_str::<UsageResponse>(r#"{"credits":{"balance":"$1.00"}}"#).is_err(),
600 "a present credits block must not default its status flags"
601 );
602
603 let response: UsageResponse = serde_json::from_str(
604 r#"{"credits":{"balance":null,"has_credits":false,"unlimited":true}}"#,
605 )
606 .unwrap();
607 assert_eq!(
608 response
609 .into_snapshot(None)
610 .unwrap()
611 .credits
612 .unwrap()
613 .balance,
614 ""
615 );
616 }
617
618 #[test]
619 fn oversized_window_seconds_degrades_instead_of_panicking() {
620 let body = r#"{"rate_limit":{"primary_window":{
624 "used_percent":1,"limit_window_seconds":9223372036854775807,
625 "reset_after_seconds":9223372036854775807
626 }}}"#;
627 let r: UsageResponse = serde_json::from_str(body).unwrap();
628 let s = r.into_snapshot(None).unwrap();
629 let session = s.session.unwrap();
630 assert_eq!(session.window_duration, chrono::Duration::hours(5));
631 assert!(session.resets_at.is_none());
632 }
633
634 #[test]
635 fn missing_reset_at_falls_back_to_after_seconds() {
636 let body = r#"{"rate_limit":{"primary_window":{
637 "used_percent":50,"limit_window_seconds":1000,"reset_after_seconds":500
638 }}}"#;
639 let r: UsageResponse = serde_json::from_str(body).unwrap();
640 let s = r.into_snapshot(None).unwrap();
641 let now = chrono::Utc::now();
643 let reset = s.session.unwrap().resets_at.unwrap();
644 let delta = reset.signed_duration_since(now).num_seconds();
645 assert!((400..=600).contains(&delta), "got delta={delta}");
646 }
647}