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(format!("${value:.2}"))),
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 {}", 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
334fn capitalize(s: &str) -> String {
335 let mut chars = s.chars();
336 match chars.next() {
337 Some(c) => {
338 let mut out = String::with_capacity(s.len());
339 for u in c.to_uppercase() {
340 out.push(u);
341 }
342 out.push_str(chars.as_str());
343 out
344 }
345 None => String::new(),
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 const REAL: &str = r#"{
354 "user_id":"u","account_id":"a","email":"e",
355 "plan_type":"plus",
356 "rate_limit":{"allowed":true,"limit_reached":false,
357 "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_after_seconds":18000,"reset_at":1779597324},
358 "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_after_seconds":604800,"reset_at":1780184124}
359 }
360 }"#;
361
362 #[test]
363 fn parses_real_shape() {
364 let r: UsageResponse = serde_json::from_str(REAL).unwrap();
365 let s = r.into_snapshot(None).unwrap();
366 assert_eq!(s.plan, "ChatGPT Plus");
367 assert_eq!(s.session.as_ref().unwrap().utilization_pct, 1);
368 assert_eq!(s.weekly.as_ref().unwrap().utilization_pct, 0);
369 assert_eq!(
370 s.session.as_ref().unwrap().window_duration,
371 chrono::Duration::hours(5)
372 );
373 assert_eq!(
374 s.weekly.as_ref().unwrap().window_duration,
375 chrono::Duration::days(7)
376 );
377 assert!(s.session.as_ref().unwrap().resets_at.is_some());
378 assert!(s.code_review.is_none());
379 assert!(s.credits.is_none());
380 assert!(matches!(s.source, OpenAiSource::CodexOauth));
381 }
382
383 #[test]
384 fn missing_rate_limit_reports_no_windows() {
385 let r: UsageResponse = serde_json::from_str(r#"{"plan_type":"pro"}"#).unwrap();
386 let s = r.into_snapshot(None).unwrap();
387 assert_eq!(s.plan, "ChatGPT Pro");
388 assert!(s.session.is_none());
389 assert!(s.weekly.is_none());
390 }
391
392 #[test]
393 fn weekly_only_primary_window_is_not_mislabeled_as_session() {
394 let body = r#"{
397 "plan_type":"prolite",
398 "rate_limit":{
399 "primary_window":{
400 "used_percent":66,
401 "limit_window_seconds":604800,
402 "reset_at":1785261834
403 },
404 "secondary_window":null
405 }
406 }"#;
407 let response: UsageResponse = serde_json::from_str(body).unwrap();
408 let snapshot = response.into_snapshot(None).unwrap();
409 assert!(snapshot.session.is_none());
410 assert_eq!(snapshot.weekly.unwrap().utilization_pct, 66);
411 }
412
413 #[test]
414 fn duration_classification_survives_reordered_wire_windows() {
415 let body = r#"{"rate_limit":{
416 "primary_window":{"used_percent":41,"limit_window_seconds":604800},
417 "secondary_window":{"used_percent":7,"limit_window_seconds":18000}
418 }}"#;
419 let response: UsageResponse = serde_json::from_str(body).unwrap();
420 let snapshot = response.into_snapshot(None).unwrap();
421 assert_eq!(snapshot.session.unwrap().utilization_pct, 7);
422 assert_eq!(snapshot.weekly.unwrap().utilization_pct, 41);
423 }
424
425 #[test]
426 fn duplicate_semantic_windows_are_schema_drift() {
427 let body = r#"{"rate_limit":{
428 "primary_window":{"used_percent":41,"limit_window_seconds":604800},
429 "secondary_window":{"used_percent":7,"limit_window_seconds":604800}
430 }}"#;
431 let response: UsageResponse = serde_json::from_str(body).unwrap();
432 let error = response.into_snapshot(None).unwrap_err().to_string();
433 assert!(error.contains("duplicate OpenAI 7d window"));
434 assert!(error.contains("limit_window_seconds=604800"));
435 }
436
437 #[test]
438 fn unknown_duration_falls_back_to_wire_position() {
439 let body = r#"{"rate_limit":{
443 "primary_window":{"used_percent":10,"limit_window_seconds":3600},
444 "secondary_window":{"used_percent":20,"limit_window_seconds":3600}
445 }}"#;
446 let response: UsageResponse = serde_json::from_str(body).unwrap();
447 let snapshot = response.into_snapshot(None).unwrap();
448 assert_eq!(snapshot.session.unwrap().utilization_pct, 10);
449 assert_eq!(snapshot.weekly.unwrap().utilization_pct, 20);
450 }
451
452 #[test]
453 fn credits_block_parses_with_message_ranges() {
454 let body = r#"{
455 "plan_type":"plus",
456 "credits":{"balance":"$2.50","has_credits":true,"unlimited":false,
457 "approx_local_messages":[100,200],"approx_cloud_messages":[40,60]}
458 }"#;
459 let r: UsageResponse = serde_json::from_str(body).unwrap();
460 let s = r.into_snapshot(None).unwrap();
461 let c = s.credits.unwrap();
462 assert_eq!(c.balance, "$2.50");
463 assert!(c.has_credits);
464 assert_eq!(c.approx_local_messages, Some((100, 200)));
465 assert_eq!(c.approx_cloud_messages, Some((40, 60)));
466 }
467
468 #[test]
469 fn balance_as_number_formats_to_dollars() {
470 let body = r#"{"credits":{"balance":1.5,"has_credits":true,"unlimited":false}}"#;
471 let r: UsageResponse = serde_json::from_str(body).unwrap();
472 let s = r.into_snapshot(None).unwrap();
473 assert_eq!(s.credits.unwrap().balance, "$1.50");
474 }
475
476 #[test]
477 fn benign_percent_overshoot_clamps_to_hundred() {
478 let body =
479 r#"{"rate_limit":{"primary_window":{"used_percent":100.6,"limit_window_seconds":1}}}"#;
480 let r: UsageResponse = serde_json::from_str(body).unwrap();
481 let s = r.into_snapshot(None).unwrap();
482 assert_eq!(s.session.unwrap().utilization_pct, 100);
483 }
484
485 #[test]
486 fn out_of_range_percent_is_schema_drift() {
487 for used_percent in ["-1", "101.5", "250"] {
488 let body = format!(
489 r#"{{"rate_limit":{{"primary_window":{{"used_percent":{used_percent},"limit_window_seconds":1}}}}}}"#
490 );
491 assert!(
492 serde_json::from_str::<UsageResponse>(&body).is_err(),
493 "{used_percent} must not become a clamped usage value"
494 );
495 }
496 }
497
498 #[test]
499 fn plan_hint_used_when_response_omits_plan_type() {
500 let r: UsageResponse = serde_json::from_str("{}").unwrap();
501 let s = r.into_snapshot(Some("team")).unwrap();
502 assert_eq!(s.plan, "ChatGPT Team");
503 }
504
505 #[test]
506 fn window_counters_accept_fractional_percent_and_integral_number_forms() {
507 let w: Window =
508 serde_json::from_str(r#"{"used_percent":7.4,"limit_window_seconds":18000.0}"#).unwrap();
509 assert_eq!(w.used_percent, 7.4);
510 assert_eq!(w.limit_window_seconds, 18000);
511
512 let w: Window =
513 serde_json::from_str(r#"{"used_percent":"42.7","limit_window_seconds":"604800.0"}"#)
514 .unwrap();
515 assert_eq!(w.used_percent, 42.7);
516 assert_eq!(w.limit_window_seconds, 604800);
517
518 let r: UsageResponse = serde_json::from_str(
519 r#"{"rate_limit":{"primary_window":{"used_percent":42.7,"limit_window_seconds":18000}}}"#,
520 )
521 .unwrap();
522 assert_eq!(
523 r.into_snapshot(None)
524 .unwrap()
525 .session
526 .unwrap()
527 .utilization_pct,
528 43
529 );
530 }
531
532 #[test]
533 fn fractional_integer_counters_are_schema_drift() {
534 for value in ["18000.9", r#""604800.5""#] {
535 let body = format!(r#"{{"used_percent":7,"limit_window_seconds":{value}}}"#);
536 assert!(serde_json::from_str::<Window>(&body).is_err(), "{value}");
537 }
538 }
539
540 #[test]
541 fn null_counter_is_schema_drift() {
542 let body = r#"{"used_percent":null,"limit_window_seconds":1}"#;
545 assert!(serde_json::from_str::<Window>(body).is_err());
546 let body = r#"{"used_percent":1,"limit_window_seconds":null}"#;
547 assert!(serde_json::from_str::<Window>(body).is_err());
548 }
549
550 #[test]
551 fn non_numeric_counter_shapes_are_schema_drift() {
552 for bad in [
553 r#""many""#,
554 r#"{"value":1}"#,
555 "[1]",
556 "true",
557 r#""NaN""#,
560 r#""inf""#,
561 "1e300",
562 "-1e300",
563 r#""1e300""#,
564 ] {
565 let body = format!(r#"{{"used_percent":{bad},"limit_window_seconds":1}}"#);
566 assert!(
567 serde_json::from_str::<Window>(&body).is_err(),
568 "used_percent {bad} must not deserialize"
569 );
570 }
571 }
572
573 #[test]
574 fn drifted_counter_fails_whole_usage_response() {
575 let body = r#"{"plan_type":"plus","rate_limit":{
578 "primary_window":{"used_percent":"n/a","limit_window_seconds":18000}
579 }}"#;
580 assert!(serde_json::from_str::<UsageResponse>(body).is_err());
581 }
582
583 #[test]
584 fn a_present_window_requires_both_counters() {
585 for body in [
586 r#"{"used_percent":1}"#,
587 r#"{"limit_window_seconds":18000}"#,
588 "{}",
589 ] {
590 assert!(serde_json::from_str::<Window>(body).is_err(), "{body}");
591 }
592 }
593
594 #[test]
595 fn malformed_optional_counters_are_not_treated_as_absent() {
596 for field in ["reset_at", "reset_after_seconds"] {
597 for bad in ["true", r#""tomorrow""#, "1.5", "{}"] {
598 let body =
599 format!(r#"{{"used_percent":1,"limit_window_seconds":18000,"{field}":{bad}}}"#);
600 assert!(serde_json::from_str::<Window>(&body).is_err(), "{body}");
601 }
602 }
603 }
604
605 #[test]
606 fn credits_reject_invalid_present_values_without_inventing_zero() {
607 for balance in ["true", "{}", "[]"] {
608 let body = format!(
609 r#"{{"credits":{{"balance":{balance},"has_credits":true,"unlimited":false}}}}"#
610 );
611 assert!(serde_json::from_str::<UsageResponse>(&body).is_err());
612 }
613 assert!(
614 serde_json::from_str::<UsageResponse>(r#"{"credits":{"balance":"$1.00"}}"#).is_err(),
615 "a present credits block must not default its status flags"
616 );
617
618 let response: UsageResponse = serde_json::from_str(
619 r#"{"credits":{"balance":null,"has_credits":false,"unlimited":true}}"#,
620 )
621 .unwrap();
622 assert_eq!(
623 response
624 .into_snapshot(None)
625 .unwrap()
626 .credits
627 .unwrap()
628 .balance,
629 ""
630 );
631 }
632
633 #[test]
634 fn oversized_window_seconds_degrades_instead_of_panicking() {
635 let body = r#"{"rate_limit":{"primary_window":{
639 "used_percent":1,"limit_window_seconds":9223372036854775807,
640 "reset_after_seconds":9223372036854775807
641 }}}"#;
642 let r: UsageResponse = serde_json::from_str(body).unwrap();
643 let s = r.into_snapshot(None).unwrap();
644 let session = s.session.unwrap();
645 assert_eq!(session.window_duration, chrono::Duration::hours(5));
646 assert!(session.resets_at.is_none());
647 }
648
649 #[test]
650 fn missing_reset_at_falls_back_to_after_seconds() {
651 let body = r#"{"rate_limit":{"primary_window":{
652 "used_percent":50,"limit_window_seconds":1000,"reset_after_seconds":500
653 }}}"#;
654 let r: UsageResponse = serde_json::from_str(body).unwrap();
655 let s = r.into_snapshot(None).unwrap();
656 let now = chrono::Utc::now();
658 let reset = s.session.unwrap().resets_at.unwrap();
659 let delta = reset.signed_duration_since(now).num_seconds();
660 assert!((400..=600).contains(&delta), "got delta={delta}");
661 }
662}