1use serde::Deserialize;
21
22use crate::usage::{OpenAiCredits, OpenAiSnapshot, OpenAiSource, UsageWindow};
23
24#[derive(Debug, Default, Clone, Deserialize)]
25#[serde(default)]
26pub struct UsageResponse {
27 pub plan_type: Option<String>,
28 pub rate_limit: Option<RateLimit>,
29 pub code_review_rate_limit: Option<RateLimit>,
30 pub credits: Option<CreditsBlock>,
31}
32
33#[derive(Debug, Default, Clone, Deserialize)]
34#[serde(default)]
35pub struct RateLimit {
36 pub primary_window: Option<Window>,
37 pub secondary_window: Option<Window>,
38}
39
40#[derive(Debug, Clone, Deserialize)]
41pub struct Window {
42 #[serde(deserialize_with = "de_percent_number_or_string")]
43 pub used_percent: f64,
44 #[serde(deserialize_with = "de_i64_number_or_string")]
45 pub limit_window_seconds: i64,
46 #[serde(default, deserialize_with = "de_opt_int_or_float")]
48 pub reset_at: Option<i64>,
49 #[serde(default, deserialize_with = "de_opt_int_or_float")]
51 pub reset_after_seconds: Option<i64>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
55pub struct CreditsBlock {
56 #[serde(default, deserialize_with = "de_opt_money_string")]
57 pub balance: Option<String>,
58 pub has_credits: bool,
59 pub unlimited: bool,
60 #[serde(default)]
61 pub approx_local_messages: Option<Vec<i64>>,
62 #[serde(default)]
63 pub approx_cloud_messages: Option<Vec<i64>>,
64}
65
66fn numeric_value<E: serde::de::Error>(v: serde_json::Value) -> Result<f64, E> {
71 let value = match v {
72 serde_json::Value::Number(n) => n
73 .as_f64()
74 .ok_or_else(|| E::custom("number is not representable as f64"))?,
75 serde_json::Value::String(s) => s
76 .parse::<f64>()
77 .map_err(|_| E::custom(format!("expected numeric string, got {s:?}")))?,
78 other => {
79 return Err(E::custom(format!(
80 "expected number or numeric string, got {other:?}"
81 )));
82 }
83 };
84 if value.is_finite() {
85 Ok(value)
86 } else {
87 Err(E::custom("number is not finite"))
88 }
89}
90
91fn de_percent_number_or_string<'de, D>(d: D) -> Result<f64, D::Error>
92where
93 D: serde::Deserializer<'de>,
94{
95 let v = serde_json::Value::deserialize(d)?;
96 let value = numeric_value::<D::Error>(v)?;
97 if (0.0..=101.0).contains(&value) {
98 Ok(value)
99 } else {
100 Err(serde::de::Error::custom(format!(
101 "percentage {value} outside 0..=100"
102 )))
103 }
104}
105
106fn de_i64_number_or_string<'de, D>(d: D) -> Result<i64, D::Error>
107where
108 D: serde::Deserializer<'de>,
109{
110 i64_value(serde_json::Value::deserialize(d)?)
111}
112
113fn i64_value<E: serde::de::Error>(v: serde_json::Value) -> Result<i64, E> {
114 match &v {
115 serde_json::Value::Number(n) => {
116 if let Some(i) = n.as_i64() {
117 return Ok(i);
118 }
119 }
120 serde_json::Value::String(s) => {
121 if let Ok(i) = s.parse::<i64>() {
122 return Ok(i);
123 }
124 }
125 _ => {}
126 }
127 exact_i64(numeric_value::<E>(v)?).ok_or_else(|| E::custom("expected an integer in i64 range"))
128}
129
130fn exact_i64(f: f64) -> Option<i64> {
136 const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
137 if f.is_finite() && f.trunc() == f && f.abs() <= MAX_EXACT_F64_INT {
138 Some(f as i64)
139 } else {
140 None
141 }
142}
143
144fn de_opt_int_or_float<'de, D>(d: D) -> Result<Option<i64>, D::Error>
145where
146 D: serde::Deserializer<'de>,
147{
148 let v = serde_json::Value::deserialize(d)?;
149 if v.is_null() {
150 Ok(None)
151 } else {
152 i64_value::<D::Error>(v).map(Some)
153 }
154}
155
156fn de_opt_money_string<'de, D>(d: D) -> Result<Option<String>, D::Error>
159where
160 D: serde::Deserializer<'de>,
161{
162 let v = serde_json::Value::deserialize(d)?;
163 match v {
164 serde_json::Value::Null => Ok(None),
165 serde_json::Value::String(s) => Ok(Some(s)),
166 serde_json::Value::Number(n) => match n.as_f64() {
167 Some(value) if value.is_finite() => Ok(Some(format!("${value:.2}"))),
168 _ => Err(serde::de::Error::custom(
169 "credit balance is not a finite number",
170 )),
171 },
172 other => Err(serde::de::Error::custom(format!(
173 "expected credit balance string, number, or null; got {other:?}"
174 ))),
175 }
176}
177
178impl UsageResponse {
179 pub fn into_snapshot(self, plan_hint: Option<&str>) -> OpenAiSnapshot {
180 let plan_type = self.plan_type.as_deref().or(plan_hint).unwrap_or("Unknown");
181 let plan = format!("ChatGPT {}", capitalize(plan_type));
182
183 let rl = self.rate_limit.unwrap_or_default();
184 let session = window_or_default(rl.primary_window, chrono::Duration::hours(5));
185 let weekly = window_or_default(rl.secondary_window, chrono::Duration::days(7));
186 let code_review = self
187 .code_review_rate_limit
188 .and_then(|c| c.primary_window)
189 .map(|w| to_window(&w, chrono::Duration::days(7)));
190
191 let credits = self.credits.map(|c| OpenAiCredits {
192 balance: c.balance.unwrap_or_default(),
193 has_credits: c.has_credits,
194 unlimited: c.unlimited,
195 approx_local_messages: range_from_vec(c.approx_local_messages),
196 approx_cloud_messages: range_from_vec(c.approx_cloud_messages),
197 });
198
199 OpenAiSnapshot {
200 plan,
201 session,
202 weekly,
203 code_review,
204 credits,
205 source: OpenAiSource::CodexOauth,
206 }
207 }
208}
209
210fn window_or_default(w: Option<Window>, default_dur: chrono::Duration) -> UsageWindow {
211 let Some(w) = w else {
212 return UsageWindow {
213 utilization_pct: 0,
214 resets_at: None,
215 window_duration: default_dur,
216 };
217 };
218 to_window(&w, default_dur)
219}
220
221fn to_window(w: &Window, default_dur: chrono::Duration) -> UsageWindow {
222 let dur = match chrono::Duration::try_seconds(w.limit_window_seconds) {
225 Some(d) if w.limit_window_seconds > 0 => d,
226 _ => default_dur,
227 };
228 let resets_at = match w.reset_at {
229 Some(secs) => chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0),
230 None => w
231 .reset_after_seconds
232 .and_then(chrono::Duration::try_seconds)
233 .and_then(|d| chrono::Utc::now().checked_add_signed(d)),
234 };
235 UsageWindow {
236 utilization_pct: (w.used_percent.round() as i32).clamp(0, 100),
237 resets_at,
238 window_duration: dur,
239 }
240}
241
242fn range_from_vec(v: Option<Vec<i64>>) -> Option<(i64, i64)> {
243 let v = v?;
244 if v.len() >= 2 {
245 Some((v[0], v[1]))
246 } else if v.len() == 1 {
247 Some((v[0], v[0]))
248 } else {
249 None
250 }
251}
252
253fn capitalize(s: &str) -> String {
254 let mut chars = s.chars();
255 match chars.next() {
256 Some(c) => {
257 let mut out = String::with_capacity(s.len());
258 for u in c.to_uppercase() {
259 out.push(u);
260 }
261 out.push_str(chars.as_str());
262 out
263 }
264 None => String::new(),
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 const REAL: &str = r#"{
273 "user_id":"u","account_id":"a","email":"e",
274 "plan_type":"plus",
275 "rate_limit":{"allowed":true,"limit_reached":false,
276 "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_after_seconds":18000,"reset_at":1779597324},
277 "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_after_seconds":604800,"reset_at":1780184124}
278 }
279 }"#;
280
281 #[test]
282 fn parses_real_shape() {
283 let r: UsageResponse = serde_json::from_str(REAL).unwrap();
284 let s = r.into_snapshot(None);
285 assert_eq!(s.plan, "ChatGPT Plus");
286 assert_eq!(s.session.utilization_pct, 1);
287 assert_eq!(s.weekly.utilization_pct, 0);
288 assert_eq!(s.session.window_duration, chrono::Duration::hours(5));
289 assert_eq!(s.weekly.window_duration, chrono::Duration::days(7));
290 assert!(s.session.resets_at.is_some());
291 assert!(s.code_review.is_none());
292 assert!(s.credits.is_none());
293 assert!(matches!(s.source, OpenAiSource::CodexOauth));
294 }
295
296 #[test]
297 fn missing_rate_limit_yields_neutral() {
298 let r: UsageResponse = serde_json::from_str(r#"{"plan_type":"pro"}"#).unwrap();
299 let s = r.into_snapshot(None);
300 assert_eq!(s.plan, "ChatGPT Pro");
301 assert_eq!(s.session.utilization_pct, 0);
302 assert_eq!(s.weekly.utilization_pct, 0);
303 }
304
305 #[test]
306 fn credits_block_parses_with_message_ranges() {
307 let body = r#"{
308 "plan_type":"plus",
309 "credits":{"balance":"$2.50","has_credits":true,"unlimited":false,
310 "approx_local_messages":[100,200],"approx_cloud_messages":[40,60]}
311 }"#;
312 let r: UsageResponse = serde_json::from_str(body).unwrap();
313 let s = r.into_snapshot(None);
314 let c = s.credits.unwrap();
315 assert_eq!(c.balance, "$2.50");
316 assert!(c.has_credits);
317 assert_eq!(c.approx_local_messages, Some((100, 200)));
318 assert_eq!(c.approx_cloud_messages, Some((40, 60)));
319 }
320
321 #[test]
322 fn balance_as_number_formats_to_dollars() {
323 let body = r#"{"credits":{"balance":1.5,"has_credits":true,"unlimited":false}}"#;
324 let r: UsageResponse = serde_json::from_str(body).unwrap();
325 let s = r.into_snapshot(None);
326 assert_eq!(s.credits.unwrap().balance, "$1.50");
327 }
328
329 #[test]
330 fn benign_percent_overshoot_clamps_to_hundred() {
331 let body =
332 r#"{"rate_limit":{"primary_window":{"used_percent":100.6,"limit_window_seconds":1}}}"#;
333 let r: UsageResponse = serde_json::from_str(body).unwrap();
334 let s = r.into_snapshot(None);
335 assert_eq!(s.session.utilization_pct, 100);
336 }
337
338 #[test]
339 fn out_of_range_percent_is_schema_drift() {
340 for used_percent in ["-1", "101.5", "250"] {
341 let body = format!(
342 r#"{{"rate_limit":{{"primary_window":{{"used_percent":{used_percent},"limit_window_seconds":1}}}}}}"#
343 );
344 assert!(
345 serde_json::from_str::<UsageResponse>(&body).is_err(),
346 "{used_percent} must not become a clamped usage value"
347 );
348 }
349 }
350
351 #[test]
352 fn plan_hint_used_when_response_omits_plan_type() {
353 let r: UsageResponse = serde_json::from_str("{}").unwrap();
354 let s = r.into_snapshot(Some("team"));
355 assert_eq!(s.plan, "ChatGPT Team");
356 }
357
358 #[test]
359 fn window_counters_accept_fractional_percent_and_integral_number_forms() {
360 let w: Window =
361 serde_json::from_str(r#"{"used_percent":7.4,"limit_window_seconds":18000.0}"#).unwrap();
362 assert_eq!(w.used_percent, 7.4);
363 assert_eq!(w.limit_window_seconds, 18000);
364
365 let w: Window =
366 serde_json::from_str(r#"{"used_percent":"42.7","limit_window_seconds":"604800.0"}"#)
367 .unwrap();
368 assert_eq!(w.used_percent, 42.7);
369 assert_eq!(w.limit_window_seconds, 604800);
370
371 let r: UsageResponse = serde_json::from_str(
372 r#"{"rate_limit":{"primary_window":{"used_percent":42.7,"limit_window_seconds":18000}}}"#,
373 )
374 .unwrap();
375 assert_eq!(r.into_snapshot(None).session.utilization_pct, 43);
376 }
377
378 #[test]
379 fn fractional_integer_counters_are_schema_drift() {
380 for value in ["18000.9", r#""604800.5""#] {
381 let body = format!(r#"{{"used_percent":7,"limit_window_seconds":{value}}}"#);
382 assert!(serde_json::from_str::<Window>(&body).is_err(), "{value}");
383 }
384 }
385
386 #[test]
387 fn null_counter_is_schema_drift() {
388 let body = r#"{"used_percent":null,"limit_window_seconds":1}"#;
391 assert!(serde_json::from_str::<Window>(body).is_err());
392 let body = r#"{"used_percent":1,"limit_window_seconds":null}"#;
393 assert!(serde_json::from_str::<Window>(body).is_err());
394 }
395
396 #[test]
397 fn non_numeric_counter_shapes_are_schema_drift() {
398 for bad in [
399 r#""many""#,
400 r#"{"value":1}"#,
401 "[1]",
402 "true",
403 r#""NaN""#,
406 r#""inf""#,
407 "1e300",
408 "-1e300",
409 r#""1e300""#,
410 ] {
411 let body = format!(r#"{{"used_percent":{bad},"limit_window_seconds":1}}"#);
412 assert!(
413 serde_json::from_str::<Window>(&body).is_err(),
414 "used_percent {bad} must not deserialize"
415 );
416 }
417 }
418
419 #[test]
420 fn drifted_counter_fails_whole_usage_response() {
421 let body = r#"{"plan_type":"plus","rate_limit":{
424 "primary_window":{"used_percent":"n/a","limit_window_seconds":18000}
425 }}"#;
426 assert!(serde_json::from_str::<UsageResponse>(body).is_err());
427 }
428
429 #[test]
430 fn a_present_window_requires_both_counters() {
431 for body in [
432 r#"{"used_percent":1}"#,
433 r#"{"limit_window_seconds":18000}"#,
434 "{}",
435 ] {
436 assert!(serde_json::from_str::<Window>(body).is_err(), "{body}");
437 }
438 }
439
440 #[test]
441 fn malformed_optional_counters_are_not_treated_as_absent() {
442 for field in ["reset_at", "reset_after_seconds"] {
443 for bad in ["true", r#""tomorrow""#, "1.5", "{}"] {
444 let body =
445 format!(r#"{{"used_percent":1,"limit_window_seconds":18000,"{field}":{bad}}}"#);
446 assert!(serde_json::from_str::<Window>(&body).is_err(), "{body}");
447 }
448 }
449 }
450
451 #[test]
452 fn credits_reject_invalid_present_values_without_inventing_zero() {
453 for balance in ["true", "{}", "[]"] {
454 let body = format!(
455 r#"{{"credits":{{"balance":{balance},"has_credits":true,"unlimited":false}}}}"#
456 );
457 assert!(serde_json::from_str::<UsageResponse>(&body).is_err());
458 }
459 assert!(
460 serde_json::from_str::<UsageResponse>(r#"{"credits":{"balance":"$1.00"}}"#).is_err(),
461 "a present credits block must not default its status flags"
462 );
463
464 let response: UsageResponse = serde_json::from_str(
465 r#"{"credits":{"balance":null,"has_credits":false,"unlimited":true}}"#,
466 )
467 .unwrap();
468 assert_eq!(response.into_snapshot(None).credits.unwrap().balance, "");
469 }
470
471 #[test]
472 fn oversized_window_seconds_degrades_instead_of_panicking() {
473 let body = r#"{"rate_limit":{"primary_window":{
477 "used_percent":1,"limit_window_seconds":9223372036854775807,
478 "reset_after_seconds":9223372036854775807
479 }}}"#;
480 let r: UsageResponse = serde_json::from_str(body).unwrap();
481 let s = r.into_snapshot(None);
482 assert_eq!(s.session.window_duration, chrono::Duration::hours(5));
483 assert!(s.session.resets_at.is_none());
484 }
485
486 #[test]
487 fn missing_reset_at_falls_back_to_after_seconds() {
488 let body = r#"{"rate_limit":{"primary_window":{
489 "used_percent":50,"limit_window_seconds":1000,"reset_after_seconds":500
490 }}}"#;
491 let r: UsageResponse = serde_json::from_str(body).unwrap();
492 let s = r.into_snapshot(None);
493 let now = chrono::Utc::now();
495 let reset = s.session.resets_at.unwrap();
496 let delta = reset.signed_duration_since(now).num_seconds();
497 assert!((400..=600).contains(&delta), "got delta={delta}");
498 }
499}