1use std::collections::HashMap;
24
25use jiff::Timestamp;
26use serde::{Deserialize, Serialize};
27
28use super::jsonl::TokenCounts;
29use crate::input::Percent;
30
31#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
41#[non_exhaustive]
42pub struct UsageApiResponse {
43 #[serde(default)]
44 pub five_hour: Option<UsageBucket>,
45
46 #[serde(default)]
47 pub seven_day: Option<UsageBucket>,
48
49 #[serde(default)]
50 pub seven_day_opus: Option<UsageBucket>,
51
52 #[serde(default)]
53 pub seven_day_sonnet: Option<UsageBucket>,
54
55 #[serde(default)]
56 pub seven_day_oauth_apps: Option<UsageBucket>,
57
58 #[serde(default)]
59 pub extra_usage: Option<ExtraUsage>,
60
61 #[serde(flatten)]
65 pub unknown_buckets: HashMap<String, serde_json::Value>,
66}
67
68pub const KNOWN_BUCKETS: &[&str] = &[
75 "five_hour",
76 "seven_day",
77 "seven_day_opus",
78 "seven_day_sonnet",
79 "seven_day_oauth_apps",
80 "extra_usage",
81];
82
83pub const RESEARCH_DOCUMENTED_BUCKETS: &[&str] = &[
94 "iguana_necktie",
95 "omelette_promotional",
96 "seven_day_cowork",
97 "seven_day_omelette",
98 "tangelo",
99];
100
101#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)]
108pub struct UsageBucket {
109 #[serde(deserialize_with = "deserialize_clamped_percent")]
113 pub utilization: Percent,
114
115 #[serde(default)]
116 pub resets_at: Option<Timestamp>,
117}
118
119#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
123#[non_exhaustive]
124pub struct ExtraUsage {
125 #[serde(default)]
126 pub is_enabled: Option<bool>,
127
128 #[serde(default, deserialize_with = "deserialize_optional_clamped_percent")]
129 pub utilization: Option<Percent>,
130
131 #[serde(default)]
132 pub monthly_limit: Option<f64>,
133
134 #[serde(default)]
135 pub used_credits: Option<f64>,
136
137 #[serde(default)]
142 pub currency: Option<String>,
143}
144
145#[derive(Debug, Clone, PartialEq)]
155#[non_exhaustive]
156pub enum UsageData {
157 Endpoint(EndpointUsage),
158 Jsonl(JsonlUsage),
159}
160
161#[derive(Debug, Clone, PartialEq)]
165#[non_exhaustive]
166pub struct EndpointUsage {
167 pub five_hour: Option<UsageBucket>,
168 pub seven_day: Option<UsageBucket>,
169 pub seven_day_opus: Option<UsageBucket>,
170 pub seven_day_sonnet: Option<UsageBucket>,
171 pub seven_day_oauth_apps: Option<UsageBucket>,
172 pub extra_usage: Option<ExtraUsage>,
173 pub unknown_buckets: HashMap<String, serde_json::Value>,
174}
175
176#[derive(Debug, Clone, PartialEq)]
183pub struct JsonlUsage {
184 pub(crate) five_hour: Option<FiveHourWindow>,
185 pub(crate) seven_day: SevenDayWindow,
186}
187
188impl JsonlUsage {
189 #[must_use]
190 pub(crate) fn new(five_hour: Option<FiveHourWindow>, seven_day: SevenDayWindow) -> Self {
191 Self {
192 five_hour,
193 seven_day,
194 }
195 }
196}
197
198#[derive(Debug, Clone, PartialEq)]
212pub struct FiveHourWindow {
213 pub(crate) tokens: TokenCounts,
214 pub(crate) start: Timestamp,
215}
216
217impl FiveHourWindow {
218 #[must_use]
219 pub(crate) fn new(tokens: TokenCounts, start: Timestamp) -> Self {
220 Self { tokens, start }
221 }
222
223 #[must_use]
228 pub(crate) fn ends_at(&self) -> Timestamp {
229 self.start + jiff::SignedDuration::from_hours(5)
230 }
231}
232
233#[derive(Debug, Clone, PartialEq)]
238pub struct SevenDayWindow {
239 pub(crate) tokens: TokenCounts,
240}
241
242impl SevenDayWindow {
243 #[must_use]
244 pub(crate) fn new(tokens: TokenCounts) -> Self {
245 Self { tokens }
246 }
247}
248
249impl UsageApiResponse {
250 #[must_use]
254 pub fn into_endpoint_usage(self) -> EndpointUsage {
255 EndpointUsage {
256 five_hour: self.five_hour,
257 seven_day: self.seven_day,
258 seven_day_opus: self.seven_day_opus,
259 seven_day_sonnet: self.seven_day_sonnet,
260 seven_day_oauth_apps: self.seven_day_oauth_apps,
261 extra_usage: self.extra_usage,
262 unknown_buckets: self.unknown_buckets,
263 }
264 }
265}
266
267fn deserialize_clamped_percent<'de, D>(de: D) -> Result<Percent, D::Error>
270where
271 D: serde::Deserializer<'de>,
272{
273 let raw = f64::deserialize(de)?;
274 if raw.is_nan() {
275 return Err(serde::de::Error::custom("utilization is NaN"));
276 }
277 let clamped = raw.clamp(0.0, 100.0);
278 Percent::from_f64(clamped).ok_or_else(|| {
279 serde::de::Error::custom(format!("utilization {raw} failed to clamp into [0, 100]"))
280 })
281}
282
283fn deserialize_optional_clamped_percent<'de, D>(de: D) -> Result<Option<Percent>, D::Error>
284where
285 D: serde::Deserializer<'de>,
286{
287 let raw: Option<f64> = Option::deserialize(de)?;
288 match raw {
289 None => Ok(None),
290 Some(v) if v.is_nan() => Err(serde::de::Error::custom("utilization is NaN")),
291 Some(v) => {
292 let clamped = v.clamp(0.0, 100.0);
293 Percent::from_f64(clamped).map(Some).ok_or_else(|| {
294 serde::de::Error::custom(format!("utilization {v} failed to clamp into [0, 100]"))
295 })
296 }
297 }
298}
299
300#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
313 fn known_buckets_matches_usage_api_response_fields() {
314 let response = UsageApiResponse {
315 five_hour: Some(UsageBucket {
316 utilization: Percent::new(0.0).expect("0 percent"),
317 resets_at: None,
318 }),
319 seven_day: Some(UsageBucket {
320 utilization: Percent::new(0.0).expect("0 percent"),
321 resets_at: None,
322 }),
323 seven_day_opus: Some(UsageBucket {
324 utilization: Percent::new(0.0).expect("0 percent"),
325 resets_at: None,
326 }),
327 seven_day_sonnet: Some(UsageBucket {
328 utilization: Percent::new(0.0).expect("0 percent"),
329 resets_at: None,
330 }),
331 seven_day_oauth_apps: Some(UsageBucket {
332 utilization: Percent::new(0.0).expect("0 percent"),
333 resets_at: None,
334 }),
335 extra_usage: Some(ExtraUsage {
336 is_enabled: Some(false),
337 utilization: None,
338 monthly_limit: None,
339 used_credits: None,
340 currency: None,
341 }),
342 unknown_buckets: HashMap::new(),
343 };
344 let value = serde_json::to_value(&response).expect("serialize");
345 let mut keys: Vec<String> = value
346 .as_object()
347 .expect("response is an object")
348 .keys()
349 .cloned()
350 .collect();
351 keys.sort();
352 let mut expected: Vec<String> = KNOWN_BUCKETS.iter().map(|s| (*s).to_string()).collect();
353 expected.sort();
354 assert_eq!(
355 keys, expected,
356 "KNOWN_BUCKETS drifted from UsageApiResponse; update both lists",
357 );
358 }
359
360 const LIVE_CAPTURE: &str = r#"{
365 "five_hour": {
366 "utilization": 22.0,
367 "resets_at": "2026-04-19T05:00:00.112536+00:00"
368 },
369 "seven_day": {
370 "utilization": 33.0,
371 "resets_at": "2026-04-23T19:00:01.112554+00:00"
372 },
373 "seven_day_oauth_apps": null,
374 "seven_day_opus": null,
375 "seven_day_sonnet": {
376 "utilization": 0.0,
377 "resets_at": "2026-04-24T16:00:00.112562+00:00"
378 },
379 "seven_day_cowork": null,
380 "seven_day_omelette": { "utilization": 0.0, "resets_at": null },
381 "iguana_necktie": null,
382 "omelette_promotional": null,
383 "extra_usage": {
384 "is_enabled": false,
385 "monthly_limit": null,
386 "used_credits": null,
387 "utilization": null,
388 "currency": null
389 }
390 }"#;
391
392 #[test]
393 fn parses_live_capture_losslessly() {
394 let resp: UsageApiResponse = serde_json::from_str(LIVE_CAPTURE).expect("parse");
395
396 assert_eq!(resp.five_hour.unwrap().utilization.value(), 22.0);
397 assert_eq!(resp.seven_day.unwrap().utilization.value(), 33.0);
398 assert_eq!(resp.seven_day_sonnet.unwrap().utilization.value(), 0.0);
399 assert!(resp.seven_day_opus.is_none());
400 assert!(resp.seven_day_oauth_apps.is_none());
401
402 let extra = resp.extra_usage.unwrap();
403 assert_eq!(extra.is_enabled, Some(false));
404 assert!(extra.monthly_limit.is_none());
405 assert!(extra.currency.is_none());
406
407 assert_eq!(resp.unknown_buckets.len(), 4);
409 for key in [
410 "seven_day_cowork",
411 "seven_day_omelette",
412 "iguana_necktie",
413 "omelette_promotional",
414 ] {
415 assert!(
416 resp.unknown_buckets.contains_key(key),
417 "expected {key} in unknown_buckets",
418 );
419 }
420 }
421
422 #[test]
423 fn parses_empty_response() {
424 let resp: UsageApiResponse = serde_json::from_str("{}").expect("parse");
425 assert!(resp.five_hour.is_none());
426 assert!(resp.seven_day.is_none());
427 assert!(resp.extra_usage.is_none());
428 assert!(resp.unknown_buckets.is_empty());
429 }
430
431 #[test]
432 fn injected_codename_lands_in_unknown_buckets() {
433 let json = r#"{
434 "five_hour": { "utilization": 10.0, "resets_at": "2026-04-19T05:00:00Z" },
435 "quokka_experimental": { "utilization": 99.0, "resets_at": null }
436 }"#;
437 let resp: UsageApiResponse = serde_json::from_str(json).expect("parse");
438 assert!(resp.five_hour.is_some());
439 assert!(resp.unknown_buckets.contains_key("quokka_experimental"));
440 }
441
442 #[test]
443 fn bucket_resets_at_accepts_null() {
444 let json = r#"{ "utilization": 0.0, "resets_at": null }"#;
445 let bucket: UsageBucket = serde_json::from_str(json).expect("parse");
446 assert_eq!(bucket.utilization.value(), 0.0);
447 assert!(bucket.resets_at.is_none());
448 }
449
450 #[test]
451 fn utilization_clamps_above_one_hundred() {
452 let json = r#"{ "utilization": 150.5, "resets_at": "2026-04-19T05:00:00Z" }"#;
453 let bucket: UsageBucket = serde_json::from_str(json).expect("parse");
454 assert_eq!(bucket.utilization.value(), 100.0);
455 }
456
457 #[test]
458 fn utilization_clamps_below_zero() {
459 let json = r#"{ "utilization": -5.0, "resets_at": "2026-04-19T05:00:00Z" }"#;
460 let bucket: UsageBucket = serde_json::from_str(json).expect("parse");
461 assert_eq!(bucket.utilization.value(), 0.0);
462 }
463
464 #[test]
465 fn utilization_rejects_non_number() {
466 let json = r#"{ "utilization": "hello", "resets_at": null }"#;
467 assert!(serde_json::from_str::<UsageBucket>(json).is_err());
468 }
469
470 #[test]
471 fn extra_usage_null_utilization_parses_as_none() {
472 let json = r#"{
473 "is_enabled": true,
474 "utilization": null,
475 "monthly_limit": 100.0,
476 "used_credits": null,
477 "currency": null
478 }"#;
479 let extra: ExtraUsage = serde_json::from_str(json).expect("parse");
480 assert_eq!(extra.is_enabled, Some(true));
481 assert!(extra.utilization.is_none());
482 assert_eq!(extra.monthly_limit, Some(100.0));
483 }
484
485 #[test]
486 fn extra_usage_utilization_clamps() {
487 let json = r#"{ "utilization": 250.0 }"#;
488 let extra: ExtraUsage = serde_json::from_str(json).expect("parse");
489 assert_eq!(extra.utilization.unwrap().value(), 100.0);
490 }
491
492 #[test]
493 fn into_endpoint_usage_preserves_unknown_buckets() {
494 let resp: UsageApiResponse = serde_json::from_str(LIVE_CAPTURE).expect("parse");
498 assert_eq!(resp.unknown_buckets.len(), 4);
499
500 let endpoint = resp.into_endpoint_usage();
501 assert!(endpoint.five_hour.is_some());
502 assert!(endpoint.seven_day.is_some());
503 assert!(endpoint.extra_usage.is_some());
504 assert_eq!(endpoint.unknown_buckets.len(), 4);
505 }
506
507 #[test]
508 fn jsonl_usage_smart_ctor_stores_windows() {
509 let seven = SevenDayWindow::new(TokenCounts::default());
510 let jsonl = JsonlUsage::new(None, seven.clone());
511 assert!(jsonl.five_hour.is_none());
512 assert_eq!(jsonl.seven_day, seven);
513 }
514}