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)]
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)]
61 pub utilization: f64,
62 #[serde(default)]
63 pub resets_at: Option<String>,
64}
65
66#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
69pub struct ExtraUsageBlock {
70 #[serde(default)]
71 pub is_enabled: bool,
72 #[serde(default, deserialize_with = "de_int_or_float")]
73 pub monthly_limit: i64,
74 #[serde(default, deserialize_with = "de_int_or_float")]
75 pub used_credits: i64,
76}
77
78fn de_int_or_float<'de, D>(d: D) -> std::result::Result<i64, D::Error>
81where
82 D: serde::Deserializer<'de>,
83{
84 let v = serde_json::Value::deserialize(d)?;
85 match v {
86 serde_json::Value::Null => Ok(0),
87 serde_json::Value::Number(n) => {
88 if let Some(i) = n.as_i64() {
89 Ok(i)
90 } else if let Some(f) = n.as_f64() {
91 Ok(f as i64)
92 } else {
93 Err(serde::de::Error::custom("number out of i64 range"))
94 }
95 }
96 other => Err(serde::de::Error::custom(format!(
97 "expected number or null, got {other:?}"
98 ))),
99 }
100}
101
102impl UsageResponse {
103 pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
108 const SESSION: chrono::Duration = chrono::Duration::hours(5);
110 const WEEKLY: chrono::Duration = chrono::Duration::days(7);
111
112 fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
113 let Some(w) = w else {
114 return UsageWindow {
115 utilization_pct: 0,
116 resets_at: None,
117 window_duration: dur,
118 };
119 };
120 UsageWindow {
121 utilization_pct: w.utilization.round() as i32,
123 resets_at: w
124 .resets_at
125 .as_deref()
126 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
127 .map(|dt| dt.with_timezone(&chrono::Utc)),
128 window_duration: dur,
129 }
130 }
131
132 let session = to_window(self.five_hour, SESSION);
133 let weekly = to_window(self.seven_day, WEEKLY);
134 let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
135 let extra = self
136 .extra_usage
137 .filter(|e| e.is_enabled)
138 .map(|e| ExtraUsage {
139 limit: Cents(e.monthly_limit),
140 spent: Cents(e.used_credits),
141 });
142 let scoped = self
143 .limits
144 .into_iter()
145 .filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
146 .filter_map(|l| {
147 let label = l.scope?.model?.display_name?;
148 let window = to_window(
149 Some(Window {
150 utilization: l.percent.unwrap_or(0.0),
151 resets_at: l.resets_at,
152 }),
153 WEEKLY,
154 );
155 Some(ScopedWindow { label, window })
156 })
157 .collect();
158
159 AnthropicSnapshot {
160 plan: plan_label,
161 session,
162 weekly,
163 sonnet,
164 scoped,
165 extra,
166 }
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn parses_full_response() {
176 let raw = r#"{
177 "five_hour": {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
178 "seven_day": {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
179 "seven_day_sonnet": {"utilization": 4.2, "resets_at": "2026-05-30T12:00:00Z"},
180 "extra_usage": {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
181 }"#;
182 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
183 let snap = resp.into_snapshot("Max 5x".into());
184 assert_eq!(snap.session.utilization_pct, 43); assert_eq!(snap.weekly.utilization_pct, 27);
186 assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
187 assert_eq!(snap.extra.unwrap().limit.0, 5000);
188 assert_eq!(snap.extra.unwrap().spent.0, 250);
189 assert!(snap.session.resets_at.is_some());
190 }
191
192 #[test]
193 fn parses_weekly_scoped_limits() {
194 let raw = r#"{
197 "five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
198 "seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
199 "limits": [
200 {"kind": "session", "group": "session", "percent": 10,
201 "severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
202 "scope": null, "is_active": false},
203 {"kind": "weekly_all", "group": "weekly", "percent": 55,
204 "severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
205 "scope": null, "is_active": false},
206 {"kind": "weekly_scoped", "group": "weekly", "percent": 84,
207 "severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
208 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
209 "is_active": true}
210 ]
211 }"#;
212 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
213 let snap = resp.into_snapshot("Pro".into());
214 assert_eq!(snap.scoped.len(), 1);
215 assert_eq!(snap.scoped[0].label, "Fable");
216 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
217 assert!(snap.scoped[0].window.resets_at.is_some());
218 assert_eq!(snap.weekly.utilization_pct, 55);
220 }
221
222 #[test]
223 fn missing_limits_array_yields_empty_scoped() {
224 let raw = r#"{
225 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
226 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
227 }"#;
228 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
229 let snap = resp.into_snapshot("Pro".into());
230 assert!(snap.scoped.is_empty());
231 }
232
233 #[test]
234 fn missing_sonnet_and_extra_are_none() {
235 let raw = r#"{
236 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
237 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
238 }"#;
239 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
240 let snap = resp.into_snapshot("Pro".into());
241 assert!(snap.sonnet.is_none());
242 assert!(snap.extra.is_none());
243 }
244
245 #[test]
246 fn disabled_extra_usage_becomes_none() {
247 let raw = r#"{
248 "five_hour": {"utilization": 0},
249 "seven_day": {"utilization": 0},
250 "extra_usage": {"is_enabled": false, "monthly_limit": 5000, "used_credits": 0}
251 }"#;
252 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
253 let snap = resp.into_snapshot("Pro".into());
254 assert!(snap.extra.is_none());
255 }
256
257 #[test]
258 fn empty_object_yields_neutral_snapshot() {
259 let resp: UsageResponse = serde_json::from_str("{}").unwrap();
260 let snap = resp.into_snapshot("Unknown".into());
261 assert_eq!(snap.session.utilization_pct, 0);
262 assert_eq!(snap.weekly.utilization_pct, 0);
263 assert!(snap.session.resets_at.is_none());
264 }
265
266 #[test]
267 fn unparseable_reset_becomes_none() {
268 let raw = r#"{
269 "five_hour": {"utilization": 50, "resets_at": "not a date"},
270 "seven_day": {"utilization": 0}
271 }"#;
272 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
273 let snap = resp.into_snapshot("Pro".into());
274 assert!(snap.session.resets_at.is_none());
275 assert_eq!(snap.session.utilization_pct, 50);
276 }
277}