1use serde::{Deserialize, Serialize};
9
10use crate::usage::{AnthropicSnapshot, Cents, ExtraUsage, ScopedWindow, UsageWindow};
11
12#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
14pub struct UsageResponse {
15 #[serde(default)]
16 pub five_hour: Option<Window>,
17 #[serde(default)]
18 pub seven_day: Option<Window>,
19 #[serde(default)]
20 pub seven_day_sonnet: Option<Window>,
21 #[serde(default)]
22 pub extra_usage: Option<ExtraUsageBlock>,
23 #[serde(default)]
27 pub limits: Vec<LimitEntry>,
28}
29
30#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
34pub struct LimitEntry {
35 #[serde(default)]
36 pub kind: Option<String>,
37 #[serde(default, deserialize_with = "de_percent_opt")]
38 pub percent: Option<f64>,
39 #[serde(default)]
40 pub resets_at: Option<String>,
41 #[serde(default)]
42 pub scope: Option<LimitScope>,
43}
44
45#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
46pub struct LimitScope {
47 #[serde(default)]
48 pub model: Option<LimitModel>,
49}
50
51#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
52pub struct LimitModel {
53 #[serde(default)]
54 pub display_name: Option<String>,
55}
56
57#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
59pub struct Window {
60 #[serde(default, deserialize_with = "de_percent")]
61 pub utilization: f64,
62 #[serde(default)]
63 pub resets_at: Option<String>,
64}
65
66#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
71pub struct ExtraUsageBlock {
72 #[serde(default)]
73 pub is_enabled: bool,
74 #[serde(default, deserialize_with = "de_opt_cents")]
75 pub monthly_limit: Option<i64>,
76 #[serde(default, deserialize_with = "de_opt_cents")]
77 pub used_credits: Option<i64>,
78 #[serde(default, deserialize_with = "de_opt_currency")]
81 pub currency: Option<String>,
82 #[serde(default, deserialize_with = "de_opt_decimal_places")]
85 pub decimal_places: Option<u32>,
86}
87
88fn de_opt_decimal_places<'de, D>(d: D) -> std::result::Result<Option<u32>, D::Error>
97where
98 D: serde::Deserializer<'de>,
99{
100 let n = match serde_json::Value::deserialize(d)? {
101 serde_json::Value::Null => return Ok(None),
102 serde_json::Value::Number(n) => n
103 .as_i64()
104 .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)),
105 _ => None,
106 };
107 match n {
108 Some(n) if (0..=6).contains(&n) => Ok(Some(n as u32)),
109 _ => Err(serde::de::Error::custom(
110 "decimal_places must be an integer in 0..=6",
111 )),
112 }
113}
114
115fn de_opt_currency<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
120where
121 D: serde::Deserializer<'de>,
122{
123 match Option::<String>::deserialize(d)? {
124 None => Ok(None),
125 Some(s) if s.len() == 3 && s.bytes().all(|b| b.is_ascii_uppercase()) => Ok(Some(s)),
126 Some(s) => Err(serde::de::Error::custom(format!(
127 "currency {s:?} is not an ISO 4217 alpha code"
128 ))),
129 }
130}
131
132fn de_opt_cents<'de, D>(d: D) -> std::result::Result<Option<i64>, D::Error>
133where
134 D: serde::Deserializer<'de>,
135{
136 let v = serde_json::Value::deserialize(d)?;
137 match v {
138 serde_json::Value::Null => Ok(None),
139 serde_json::Value::Number(n) => {
140 if let Some(i) = n.as_i64() {
141 if i >= 0 {
142 Ok(Some(i))
143 } else {
144 Err(serde::de::Error::custom("cents cannot be negative"))
145 }
146 } else if let Some(f) = n.as_f64() {
147 const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
148 if f.is_finite() && f.fract() == 0.0 && (0.0..=MAX_EXACT_F64_INT).contains(&f) {
149 Ok(Some(f as i64))
150 } else {
151 Err(serde::de::Error::custom(
152 "cents must be a non-negative integer in range",
153 ))
154 }
155 } else {
156 Err(serde::de::Error::custom("number out of i64 range"))
157 }
158 }
159 other => Err(serde::de::Error::custom(format!(
160 "expected number or null, got {other:?}"
161 ))),
162 }
163}
164
165const PCT_SLACK: f64 = 1.0;
169
170fn checked_percent<E: serde::de::Error>(v: f64) -> std::result::Result<f64, E> {
181 if !v.is_finite() {
182 return Err(E::custom(format!("percentage {v} is not finite")));
183 }
184 if !(0.0..=100.0 + PCT_SLACK).contains(&v) {
185 return Err(E::custom(format!("percentage {v} outside 0..=100")));
186 }
187 Ok(v)
188}
189
190fn de_percent<'de, D>(d: D) -> std::result::Result<f64, D::Error>
191where
192 D: serde::Deserializer<'de>,
193{
194 checked_percent(f64::deserialize(d)?)
195}
196
197fn de_percent_opt<'de, D>(d: D) -> std::result::Result<Option<f64>, D::Error>
198where
199 D: serde::Deserializer<'de>,
200{
201 Option::<f64>::deserialize(d)?
202 .map(checked_percent)
203 .transpose()
204}
205
206impl UsageResponse {
207 pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
212 const SESSION: chrono::Duration = chrono::Duration::hours(5);
214 const WEEKLY: chrono::Duration = chrono::Duration::days(7);
215
216 fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
217 let Some(w) = w else {
218 return UsageWindow {
219 utilization_pct: 0,
220 resets_at: None,
221 window_duration: dur,
222 };
223 };
224 UsageWindow {
225 utilization_pct: (w.utilization.round() as i32).clamp(0, 100),
229 resets_at: w
230 .resets_at
231 .as_deref()
232 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
233 .map(|dt| dt.with_timezone(&chrono::Utc)),
234 window_duration: dur,
235 }
236 }
237
238 let session = to_window(self.five_hour, SESSION);
239 let weekly = to_window(self.seven_day, WEEKLY);
240 let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
241 let extra = self.extra_usage.filter(|e| e.is_enabled).and_then(|e| {
242 Some(ExtraUsage {
243 limit: e.monthly_limit.map(Cents),
249 spent: Cents(e.used_credits?),
250 decimal_places: e.decimal_places,
254 currency: e.currency,
255 })
256 });
257 let scoped = self
258 .limits
259 .into_iter()
260 .filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
261 .filter_map(|l| {
262 let label = l.scope?.model?.display_name?;
263 let utilization = l.percent?;
268 let window = to_window(
269 Some(Window {
270 utilization,
271 resets_at: l.resets_at,
272 }),
273 WEEKLY,
274 );
275 Some(ScopedWindow { label, window })
276 })
277 .collect();
278
279 AnthropicSnapshot {
280 plan: plan_label,
281 session,
282 weekly,
283 sonnet,
284 scoped,
285 extra,
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn parses_full_response() {
296 let raw = r#"{
297 "five_hour": {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
298 "seven_day": {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
299 "seven_day_sonnet": {"utilization": 4.2, "resets_at": "2026-05-30T12:00:00Z"},
300 "extra_usage": {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
301 }"#;
302 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
303 let snap = resp.into_snapshot("Max 5x".into());
304 assert_eq!(snap.session.utilization_pct, 43); assert_eq!(snap.weekly.utilization_pct, 27);
306 assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
307 let extra = snap.extra.as_ref().unwrap();
308 assert_eq!(extra.limit, Some(Cents(5000)));
309 assert_eq!(extra.spent.0, 250);
310 assert!(snap.session.resets_at.is_some());
311 }
312
313 #[test]
314 fn parses_weekly_scoped_limits() {
315 let raw = r#"{
318 "five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
319 "seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
320 "limits": [
321 {"kind": "session", "group": "session", "percent": 10,
322 "severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
323 "scope": null, "is_active": false},
324 {"kind": "weekly_all", "group": "weekly", "percent": 55,
325 "severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
326 "scope": null, "is_active": false},
327 {"kind": "weekly_scoped", "group": "weekly", "percent": 84,
328 "severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
329 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
330 "is_active": true}
331 ]
332 }"#;
333 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
334 let snap = resp.into_snapshot("Pro".into());
335 assert_eq!(snap.scoped.len(), 1);
336 assert_eq!(snap.scoped[0].label, "Fable");
337 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
338 assert!(snap.scoped[0].window.resets_at.is_some());
339 assert_eq!(snap.weekly.utilization_pct, 55);
341 }
342
343 #[test]
344 fn missing_limits_array_yields_empty_scoped() {
345 let raw = r#"{
346 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
347 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
348 }"#;
349 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
350 let snap = resp.into_snapshot("Pro".into());
351 assert!(snap.scoped.is_empty());
352 }
353
354 #[test]
355 fn missing_sonnet_and_extra_are_none() {
356 let raw = r#"{
357 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
358 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
359 }"#;
360 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
361 let snap = resp.into_snapshot("Pro".into());
362 assert!(snap.sonnet.is_none());
363 assert!(snap.extra.is_none());
364 }
365
366 #[test]
367 fn disabled_extra_usage_becomes_none() {
368 let raw = r#"{
369 "five_hour": {"utilization": 0},
370 "seven_day": {"utilization": 0},
371 "extra_usage": {"is_enabled": false, "monthly_limit": 5000, "used_credits": 0}
372 }"#;
373 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
374 let snap = resp.into_snapshot("Pro".into());
375 assert!(snap.extra.is_none());
376 }
377
378 #[test]
379 fn enabled_extra_usage_without_spend_is_dropped() {
380 for raw in [
384 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000}}"#,
385 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000,"used_credits":null}}"#,
386 ] {
387 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
388 assert!(resp.into_snapshot("Pro".into()).extra.is_none(), "{raw}");
389 }
390 }
391
392 #[test]
393 fn uncapped_plan_keeps_real_spend_visible() {
394 let resp: UsageResponse = serde_json::from_str(
399 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":null,
400 "used_credits":14157.0,"currency":"BRL","decimal_places":2,
401 "disabled_reason":null}}"#,
402 )
403 .unwrap();
404 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
405 assert_eq!(extra.limit, None);
406 assert_eq!(extra.spent.0, 14157);
407 assert_eq!(extra.percent(), 0);
409 assert_eq!(extra.currency.as_deref(), Some("BRL"));
412 assert_eq!(extra.decimal_places, Some(2));
413 assert_eq!(extra.fmt_spent(), "R$141.57");
414
415 let resp: UsageResponse =
421 serde_json::from_str(r#"{"extra_usage":{"is_enabled":true,"used_credits":250}}"#)
422 .unwrap();
423 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
424 assert_eq!(extra.limit, None);
425 assert_eq!(extra.spent.0, 250);
426 }
427
428 #[test]
429 fn implausible_decimal_places_is_schema_drift() {
430 for value in ["7", "-1", "100", "2.5"] {
433 let raw = format!(
434 r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"decimal_places":{value}}}}}"#
435 );
436 assert!(
437 serde_json::from_str::<UsageResponse>(&raw).is_err(),
438 "{raw}"
439 );
440 }
441 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":2.0}}"#;
447 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
448 assert_eq!(
449 resp.into_snapshot("Pro".into())
450 .extra
451 .unwrap()
452 .decimal_places,
453 Some(2)
454 );
455 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":null}}"#;
458 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
459 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
460 assert_eq!(extra.decimal_places, None);
461 assert_eq!(extra.fmt_spent(), "$2.50");
462
463 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW"}}"#;
467 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
468 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
469 assert_eq!(extra.decimal_places, None);
470 assert_eq!(extra.fmt_spent(), "500 minor units KRW");
471
472 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD"}}"#;
473 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
474 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
475 assert_eq!(extra.decimal_places, None);
476 assert_eq!(extra.fmt_spent(), "1234 minor units KWD");
477
478 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW","decimal_places":0}}"#;
480 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
481 assert_eq!(
482 resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
483 "500 KRW"
484 );
485 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD","decimal_places":3}}"#;
486 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
487 assert_eq!(
488 resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
489 "1.234 KWD"
490 );
491 }
492
493 #[test]
494 fn currency_must_be_an_iso_alpha_code() {
495 for value in [r#""brl""#, r#""""#, r#""R$""#, r#""USD;;0""#, r#""<b>""#] {
499 let raw = format!(
500 r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"currency":{value}}}}}"#
501 );
502 assert!(
503 serde_json::from_str::<UsageResponse>(&raw).is_err(),
504 "{raw}"
505 );
506 }
507 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"currency":null}}"#;
509 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
510 assert_eq!(
511 resp.into_snapshot("Pro".into()).extra.unwrap().currency,
512 None
513 );
514 }
515
516 #[test]
517 fn malformed_cent_values_are_schema_drift() {
518 for value in ["-1", "1.5", "1e300", "true", r#""lots""#] {
519 let raw = format!(
520 r#"{{"extra_usage":{{"is_enabled":true,"monthly_limit":{value},"used_credits":0}}}}"#
521 );
522 assert!(
523 serde_json::from_str::<UsageResponse>(&raw).is_err(),
524 "{raw}"
525 );
526 }
527
528 let resp: UsageResponse = serde_json::from_str(
529 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000.0,"used_credits":250.0}}"#,
530 )
531 .unwrap();
532 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
533 assert_eq!(extra.limit, Some(Cents(5000)));
534 assert_eq!(extra.spent.0, 250);
535 }
536
537 #[test]
538 fn empty_object_yields_neutral_snapshot() {
539 let resp: UsageResponse = serde_json::from_str("{}").unwrap();
540 let snap = resp.into_snapshot("Unknown".into());
541 assert_eq!(snap.session.utilization_pct, 0);
542 assert_eq!(snap.weekly.utilization_pct, 0);
543 assert!(snap.session.resets_at.is_none());
544 }
545
546 #[test]
547 fn benign_overshoot_saturates_to_hundred() {
548 let raw = r#"{
551 "five_hour": {"utilization": 100.4},
552 "seven_day": {"utilization": 100.6}
553 }"#;
554 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
555 let snap = resp.into_snapshot("Pro".into());
556 assert_eq!(snap.session.utilization_pct, 100);
557 assert_eq!(snap.weekly.utilization_pct, 100); }
559
560 #[test]
561 fn out_of_range_utilization_is_rejected_not_clamped() {
562 for raw in [
563 r#"{"five_hour": {"utilization": 500}}"#,
564 r#"{"five_hour": {"utilization": -1}}"#,
565 r#"{"seven_day": {"utilization": 101.5}}"#,
566 ] {
567 let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
568 assert!(err.to_string().contains("outside 0..=100"), "{raw}: {err}");
569 }
570 }
571
572 #[test]
573 fn out_of_range_scoped_percent_is_rejected() {
574 let raw = r#"{
576 "limits": [{"kind": "weekly_scoped", "percent": 420,
577 "scope": {"model": {"display_name": "Fable"}}}]
578 }"#;
579 let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
580 assert!(err.to_string().contains("outside 0..=100"), "{err}");
581 }
582
583 #[test]
584 fn non_finite_percentage_is_rejected() {
585 for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
588 assert!(checked_percent::<serde_json::Error>(v).is_err(), "{v}");
589 }
590 }
591
592 #[test]
593 fn scoped_limit_without_a_percentage_is_dropped_not_zeroed() {
594 let raw = r#"{
598 "limits": [{"kind": "weekly_scoped", "percent": null,
599 "scope": {"model": {"display_name": "Fable"}}}]
600 }"#;
601 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
602 let snap = resp.into_snapshot("Pro".into());
603 assert!(
604 snap.scoped.is_empty(),
605 "a scoped limit with no percentage must not render a fabricated bar"
606 );
607
608 let ok = r#"{
610 "limits": [{"kind": "weekly_scoped", "percent": 84,
611 "scope": {"model": {"display_name": "Fable"}}}]
612 }"#;
613 let resp: UsageResponse = serde_json::from_str(ok).unwrap();
614 let snap = resp.into_snapshot("Pro".into());
615 assert_eq!(snap.scoped[0].label, "Fable");
616 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
617 }
618
619 #[test]
620 fn unparseable_reset_becomes_none() {
621 let raw = r#"{
622 "five_hour": {"utilization": 50, "resets_at": "not a date"},
623 "seven_day": {"utilization": 0}
624 }"#;
625 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
626 let snap = resp.into_snapshot("Pro".into());
627 assert!(snap.session.resets_at.is_none());
628 assert_eq!(snap.session.utilization_pct, 50);
629 }
630}