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)]
70pub struct ExtraUsageBlock {
71 #[serde(default)]
72 pub is_enabled: bool,
73 #[serde(default, deserialize_with = "de_opt_cents")]
74 pub monthly_limit: Option<i64>,
75 #[serde(default, deserialize_with = "de_opt_cents")]
76 pub used_credits: Option<i64>,
77}
78
79fn de_opt_cents<'de, D>(d: D) -> std::result::Result<Option<i64>, D::Error>
80where
81 D: serde::Deserializer<'de>,
82{
83 let v = serde_json::Value::deserialize(d)?;
84 match v {
85 serde_json::Value::Null => Ok(None),
86 serde_json::Value::Number(n) => {
87 if let Some(i) = n.as_i64() {
88 if i >= 0 {
89 Ok(Some(i))
90 } else {
91 Err(serde::de::Error::custom("cents cannot be negative"))
92 }
93 } else if let Some(f) = n.as_f64() {
94 const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
95 if f.is_finite() && f.fract() == 0.0 && (0.0..=MAX_EXACT_F64_INT).contains(&f) {
96 Ok(Some(f as i64))
97 } else {
98 Err(serde::de::Error::custom(
99 "cents must be a non-negative integer in range",
100 ))
101 }
102 } else {
103 Err(serde::de::Error::custom("number out of i64 range"))
104 }
105 }
106 other => Err(serde::de::Error::custom(format!(
107 "expected number or null, got {other:?}"
108 ))),
109 }
110}
111
112const PCT_SLACK: f64 = 1.0;
116
117fn checked_percent<E: serde::de::Error>(v: f64) -> std::result::Result<f64, E> {
128 if !v.is_finite() {
129 return Err(E::custom(format!("percentage {v} is not finite")));
130 }
131 if !(0.0..=100.0 + PCT_SLACK).contains(&v) {
132 return Err(E::custom(format!("percentage {v} outside 0..=100")));
133 }
134 Ok(v)
135}
136
137fn de_percent<'de, D>(d: D) -> std::result::Result<f64, D::Error>
138where
139 D: serde::Deserializer<'de>,
140{
141 checked_percent(f64::deserialize(d)?)
142}
143
144fn de_percent_opt<'de, D>(d: D) -> std::result::Result<Option<f64>, D::Error>
145where
146 D: serde::Deserializer<'de>,
147{
148 Option::<f64>::deserialize(d)?
149 .map(checked_percent)
150 .transpose()
151}
152
153impl UsageResponse {
154 pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
159 const SESSION: chrono::Duration = chrono::Duration::hours(5);
161 const WEEKLY: chrono::Duration = chrono::Duration::days(7);
162
163 fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
164 let Some(w) = w else {
165 return UsageWindow {
166 utilization_pct: 0,
167 resets_at: None,
168 window_duration: dur,
169 };
170 };
171 UsageWindow {
172 utilization_pct: (w.utilization.round() as i32).clamp(0, 100),
176 resets_at: w
177 .resets_at
178 .as_deref()
179 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
180 .map(|dt| dt.with_timezone(&chrono::Utc)),
181 window_duration: dur,
182 }
183 }
184
185 let session = to_window(self.five_hour, SESSION);
186 let weekly = to_window(self.seven_day, WEEKLY);
187 let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
188 let extra = self.extra_usage.filter(|e| e.is_enabled).and_then(|e| {
189 Some(ExtraUsage {
190 limit: Cents(e.monthly_limit?),
191 spent: Cents(e.used_credits?),
192 })
193 });
194 let scoped = self
195 .limits
196 .into_iter()
197 .filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
198 .filter_map(|l| {
199 let label = l.scope?.model?.display_name?;
200 let utilization = l.percent?;
205 let window = to_window(
206 Some(Window {
207 utilization,
208 resets_at: l.resets_at,
209 }),
210 WEEKLY,
211 );
212 Some(ScopedWindow { label, window })
213 })
214 .collect();
215
216 AnthropicSnapshot {
217 plan: plan_label,
218 session,
219 weekly,
220 sonnet,
221 scoped,
222 extra,
223 }
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn parses_full_response() {
233 let raw = r#"{
234 "five_hour": {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
235 "seven_day": {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
236 "seven_day_sonnet": {"utilization": 4.2, "resets_at": "2026-05-30T12:00:00Z"},
237 "extra_usage": {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
238 }"#;
239 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
240 let snap = resp.into_snapshot("Max 5x".into());
241 assert_eq!(snap.session.utilization_pct, 43); assert_eq!(snap.weekly.utilization_pct, 27);
243 assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
244 assert_eq!(snap.extra.unwrap().limit.0, 5000);
245 assert_eq!(snap.extra.unwrap().spent.0, 250);
246 assert!(snap.session.resets_at.is_some());
247 }
248
249 #[test]
250 fn parses_weekly_scoped_limits() {
251 let raw = r#"{
254 "five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
255 "seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
256 "limits": [
257 {"kind": "session", "group": "session", "percent": 10,
258 "severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
259 "scope": null, "is_active": false},
260 {"kind": "weekly_all", "group": "weekly", "percent": 55,
261 "severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
262 "scope": null, "is_active": false},
263 {"kind": "weekly_scoped", "group": "weekly", "percent": 84,
264 "severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
265 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
266 "is_active": true}
267 ]
268 }"#;
269 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
270 let snap = resp.into_snapshot("Pro".into());
271 assert_eq!(snap.scoped.len(), 1);
272 assert_eq!(snap.scoped[0].label, "Fable");
273 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
274 assert!(snap.scoped[0].window.resets_at.is_some());
275 assert_eq!(snap.weekly.utilization_pct, 55);
277 }
278
279 #[test]
280 fn missing_limits_array_yields_empty_scoped() {
281 let raw = r#"{
282 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
283 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
284 }"#;
285 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
286 let snap = resp.into_snapshot("Pro".into());
287 assert!(snap.scoped.is_empty());
288 }
289
290 #[test]
291 fn missing_sonnet_and_extra_are_none() {
292 let raw = r#"{
293 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
294 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
295 }"#;
296 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
297 let snap = resp.into_snapshot("Pro".into());
298 assert!(snap.sonnet.is_none());
299 assert!(snap.extra.is_none());
300 }
301
302 #[test]
303 fn disabled_extra_usage_becomes_none() {
304 let raw = r#"{
305 "five_hour": {"utilization": 0},
306 "seven_day": {"utilization": 0},
307 "extra_usage": {"is_enabled": false, "monthly_limit": 5000, "used_credits": 0}
308 }"#;
309 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
310 let snap = resp.into_snapshot("Pro".into());
311 assert!(snap.extra.is_none());
312 }
313
314 #[test]
315 fn enabled_incomplete_extra_usage_does_not_invent_zero_money() {
316 for raw in [
317 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000}}"#,
318 r#"{"extra_usage":{"is_enabled":true,"used_credits":250}}"#,
319 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":null,"used_credits":250}}"#,
320 ] {
321 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
322 assert!(resp.into_snapshot("Pro".into()).extra.is_none(), "{raw}");
323 }
324 }
325
326 #[test]
327 fn malformed_cent_values_are_schema_drift() {
328 for value in ["-1", "1.5", "1e300", "true", r#""lots""#] {
329 let raw = format!(
330 r#"{{"extra_usage":{{"is_enabled":true,"monthly_limit":{value},"used_credits":0}}}}"#
331 );
332 assert!(
333 serde_json::from_str::<UsageResponse>(&raw).is_err(),
334 "{raw}"
335 );
336 }
337
338 let resp: UsageResponse = serde_json::from_str(
339 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000.0,"used_credits":250.0}}"#,
340 )
341 .unwrap();
342 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
343 assert_eq!(extra.limit.0, 5000);
344 assert_eq!(extra.spent.0, 250);
345 }
346
347 #[test]
348 fn empty_object_yields_neutral_snapshot() {
349 let resp: UsageResponse = serde_json::from_str("{}").unwrap();
350 let snap = resp.into_snapshot("Unknown".into());
351 assert_eq!(snap.session.utilization_pct, 0);
352 assert_eq!(snap.weekly.utilization_pct, 0);
353 assert!(snap.session.resets_at.is_none());
354 }
355
356 #[test]
357 fn benign_overshoot_saturates_to_hundred() {
358 let raw = r#"{
361 "five_hour": {"utilization": 100.4},
362 "seven_day": {"utilization": 100.6}
363 }"#;
364 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
365 let snap = resp.into_snapshot("Pro".into());
366 assert_eq!(snap.session.utilization_pct, 100);
367 assert_eq!(snap.weekly.utilization_pct, 100); }
369
370 #[test]
371 fn out_of_range_utilization_is_rejected_not_clamped() {
372 for raw in [
373 r#"{"five_hour": {"utilization": 500}}"#,
374 r#"{"five_hour": {"utilization": -1}}"#,
375 r#"{"seven_day": {"utilization": 101.5}}"#,
376 ] {
377 let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
378 assert!(err.to_string().contains("outside 0..=100"), "{raw}: {err}");
379 }
380 }
381
382 #[test]
383 fn out_of_range_scoped_percent_is_rejected() {
384 let raw = r#"{
386 "limits": [{"kind": "weekly_scoped", "percent": 420,
387 "scope": {"model": {"display_name": "Fable"}}}]
388 }"#;
389 let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
390 assert!(err.to_string().contains("outside 0..=100"), "{err}");
391 }
392
393 #[test]
394 fn non_finite_percentage_is_rejected() {
395 for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
398 assert!(checked_percent::<serde_json::Error>(v).is_err(), "{v}");
399 }
400 }
401
402 #[test]
403 fn scoped_limit_without_a_percentage_is_dropped_not_zeroed() {
404 let raw = r#"{
408 "limits": [{"kind": "weekly_scoped", "percent": null,
409 "scope": {"model": {"display_name": "Fable"}}}]
410 }"#;
411 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
412 let snap = resp.into_snapshot("Pro".into());
413 assert!(
414 snap.scoped.is_empty(),
415 "a scoped limit with no percentage must not render a fabricated bar"
416 );
417
418 let ok = r#"{
420 "limits": [{"kind": "weekly_scoped", "percent": 84,
421 "scope": {"model": {"display_name": "Fable"}}}]
422 }"#;
423 let resp: UsageResponse = serde_json::from_str(ok).unwrap();
424 let snap = resp.into_snapshot("Pro".into());
425 assert_eq!(snap.scoped[0].label, "Fable");
426 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
427 }
428
429 #[test]
430 fn unparseable_reset_becomes_none() {
431 let raw = r#"{
432 "five_hour": {"utilization": 50, "resets_at": "not a date"},
433 "seven_day": {"utilization": 0}
434 }"#;
435 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
436 let snap = resp.into_snapshot("Pro".into());
437 assert!(snap.session.resets_at.is_none());
438 assert_eq!(snap.session.utilization_pct, 50);
439 }
440}