use std::collections::HashMap;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use super::jsonl::TokenCounts;
use crate::input::Percent;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[non_exhaustive]
pub struct UsageApiResponse {
#[serde(default)]
pub five_hour: Option<UsageBucket>,
#[serde(default)]
pub seven_day: Option<UsageBucket>,
#[serde(default)]
pub seven_day_opus: Option<UsageBucket>,
#[serde(default)]
pub seven_day_sonnet: Option<UsageBucket>,
#[serde(default)]
pub seven_day_oauth_apps: Option<UsageBucket>,
#[serde(default)]
pub extra_usage: Option<ExtraUsage>,
#[serde(flatten)]
pub unknown_buckets: HashMap<String, serde_json::Value>,
}
pub const KNOWN_BUCKETS: &[&str] = &[
"five_hour",
"seven_day",
"seven_day_opus",
"seven_day_sonnet",
"seven_day_oauth_apps",
"extra_usage",
];
pub const RESEARCH_DOCUMENTED_BUCKETS: &[&str] = &[
"iguana_necktie",
"omelette_promotional",
"seven_day_cowork",
"seven_day_omelette",
"tangelo",
];
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)]
pub struct UsageBucket {
#[serde(deserialize_with = "deserialize_clamped_percent")]
pub utilization: Percent,
#[serde(default)]
pub resets_at: Option<Timestamp>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[non_exhaustive]
pub struct ExtraUsage {
#[serde(default)]
pub is_enabled: Option<bool>,
#[serde(default, deserialize_with = "deserialize_optional_clamped_percent")]
pub utilization: Option<Percent>,
#[serde(default)]
pub monthly_limit: Option<f64>,
#[serde(default)]
pub used_credits: Option<f64>,
#[serde(default)]
pub currency: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum UsageData {
Endpoint(EndpointUsage),
Jsonl(JsonlUsage),
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct EndpointUsage {
pub five_hour: Option<UsageBucket>,
pub seven_day: Option<UsageBucket>,
pub seven_day_opus: Option<UsageBucket>,
pub seven_day_sonnet: Option<UsageBucket>,
pub seven_day_oauth_apps: Option<UsageBucket>,
pub extra_usage: Option<ExtraUsage>,
pub unknown_buckets: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct JsonlUsage {
pub(crate) five_hour: Option<FiveHourWindow>,
pub(crate) seven_day: SevenDayWindow,
}
impl JsonlUsage {
#[must_use]
pub(crate) fn new(five_hour: Option<FiveHourWindow>, seven_day: SevenDayWindow) -> Self {
Self {
five_hour,
seven_day,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FiveHourWindow {
pub(crate) tokens: TokenCounts,
pub(crate) start: Timestamp,
}
impl FiveHourWindow {
#[must_use]
pub(crate) fn new(tokens: TokenCounts, start: Timestamp) -> Self {
Self { tokens, start }
}
#[must_use]
pub(crate) fn ends_at(&self) -> Timestamp {
self.start + jiff::SignedDuration::from_hours(5)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SevenDayWindow {
pub(crate) tokens: TokenCounts,
}
impl SevenDayWindow {
#[must_use]
pub(crate) fn new(tokens: TokenCounts) -> Self {
Self { tokens }
}
}
impl UsageApiResponse {
#[must_use]
pub fn into_endpoint_usage(self) -> EndpointUsage {
EndpointUsage {
five_hour: self.five_hour,
seven_day: self.seven_day,
seven_day_opus: self.seven_day_opus,
seven_day_sonnet: self.seven_day_sonnet,
seven_day_oauth_apps: self.seven_day_oauth_apps,
extra_usage: self.extra_usage,
unknown_buckets: self.unknown_buckets,
}
}
}
fn deserialize_clamped_percent<'de, D>(de: D) -> Result<Percent, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = f64::deserialize(de)?;
if raw.is_nan() {
return Err(serde::de::Error::custom("utilization is NaN"));
}
let clamped = raw.clamp(0.0, 100.0);
Percent::from_f64(clamped).ok_or_else(|| {
serde::de::Error::custom(format!("utilization {raw} failed to clamp into [0, 100]"))
})
}
fn deserialize_optional_clamped_percent<'de, D>(de: D) -> Result<Option<Percent>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Option<f64> = Option::deserialize(de)?;
match raw {
None => Ok(None),
Some(v) if v.is_nan() => Err(serde::de::Error::custom("utilization is NaN")),
Some(v) => {
let clamped = v.clamp(0.0, 100.0);
Percent::from_f64(clamped).map(Some).ok_or_else(|| {
serde::de::Error::custom(format!("utilization {v} failed to clamp into [0, 100]"))
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_buckets_matches_usage_api_response_fields() {
let response = UsageApiResponse {
five_hour: Some(UsageBucket {
utilization: Percent::new(0.0).expect("0 percent"),
resets_at: None,
}),
seven_day: Some(UsageBucket {
utilization: Percent::new(0.0).expect("0 percent"),
resets_at: None,
}),
seven_day_opus: Some(UsageBucket {
utilization: Percent::new(0.0).expect("0 percent"),
resets_at: None,
}),
seven_day_sonnet: Some(UsageBucket {
utilization: Percent::new(0.0).expect("0 percent"),
resets_at: None,
}),
seven_day_oauth_apps: Some(UsageBucket {
utilization: Percent::new(0.0).expect("0 percent"),
resets_at: None,
}),
extra_usage: Some(ExtraUsage {
is_enabled: Some(false),
utilization: None,
monthly_limit: None,
used_credits: None,
currency: None,
}),
unknown_buckets: HashMap::new(),
};
let value = serde_json::to_value(&response).expect("serialize");
let mut keys: Vec<String> = value
.as_object()
.expect("response is an object")
.keys()
.cloned()
.collect();
keys.sort();
let mut expected: Vec<String> = KNOWN_BUCKETS.iter().map(|s| (*s).to_string()).collect();
expected.sort();
assert_eq!(
keys, expected,
"KNOWN_BUCKETS drifted from UsageApiResponse; update both lists",
);
}
const LIVE_CAPTURE: &str = r#"{
"five_hour": {
"utilization": 22.0,
"resets_at": "2026-04-19T05:00:00.112536+00:00"
},
"seven_day": {
"utilization": 33.0,
"resets_at": "2026-04-23T19:00:01.112554+00:00"
},
"seven_day_oauth_apps": null,
"seven_day_opus": null,
"seven_day_sonnet": {
"utilization": 0.0,
"resets_at": "2026-04-24T16:00:00.112562+00:00"
},
"seven_day_cowork": null,
"seven_day_omelette": { "utilization": 0.0, "resets_at": null },
"iguana_necktie": null,
"omelette_promotional": null,
"extra_usage": {
"is_enabled": false,
"monthly_limit": null,
"used_credits": null,
"utilization": null,
"currency": null
}
}"#;
#[test]
fn parses_live_capture_losslessly() {
let resp: UsageApiResponse = serde_json::from_str(LIVE_CAPTURE).expect("parse");
assert_eq!(resp.five_hour.unwrap().utilization.value(), 22.0);
assert_eq!(resp.seven_day.unwrap().utilization.value(), 33.0);
assert_eq!(resp.seven_day_sonnet.unwrap().utilization.value(), 0.0);
assert!(resp.seven_day_opus.is_none());
assert!(resp.seven_day_oauth_apps.is_none());
let extra = resp.extra_usage.unwrap();
assert_eq!(extra.is_enabled, Some(false));
assert!(extra.monthly_limit.is_none());
assert!(extra.currency.is_none());
assert_eq!(resp.unknown_buckets.len(), 4);
for key in [
"seven_day_cowork",
"seven_day_omelette",
"iguana_necktie",
"omelette_promotional",
] {
assert!(
resp.unknown_buckets.contains_key(key),
"expected {key} in unknown_buckets",
);
}
}
#[test]
fn parses_empty_response() {
let resp: UsageApiResponse = serde_json::from_str("{}").expect("parse");
assert!(resp.five_hour.is_none());
assert!(resp.seven_day.is_none());
assert!(resp.extra_usage.is_none());
assert!(resp.unknown_buckets.is_empty());
}
#[test]
fn injected_codename_lands_in_unknown_buckets() {
let json = r#"{
"five_hour": { "utilization": 10.0, "resets_at": "2026-04-19T05:00:00Z" },
"quokka_experimental": { "utilization": 99.0, "resets_at": null }
}"#;
let resp: UsageApiResponse = serde_json::from_str(json).expect("parse");
assert!(resp.five_hour.is_some());
assert!(resp.unknown_buckets.contains_key("quokka_experimental"));
}
#[test]
fn bucket_resets_at_accepts_null() {
let json = r#"{ "utilization": 0.0, "resets_at": null }"#;
let bucket: UsageBucket = serde_json::from_str(json).expect("parse");
assert_eq!(bucket.utilization.value(), 0.0);
assert!(bucket.resets_at.is_none());
}
#[test]
fn utilization_clamps_above_one_hundred() {
let json = r#"{ "utilization": 150.5, "resets_at": "2026-04-19T05:00:00Z" }"#;
let bucket: UsageBucket = serde_json::from_str(json).expect("parse");
assert_eq!(bucket.utilization.value(), 100.0);
}
#[test]
fn utilization_clamps_below_zero() {
let json = r#"{ "utilization": -5.0, "resets_at": "2026-04-19T05:00:00Z" }"#;
let bucket: UsageBucket = serde_json::from_str(json).expect("parse");
assert_eq!(bucket.utilization.value(), 0.0);
}
#[test]
fn utilization_rejects_non_number() {
let json = r#"{ "utilization": "hello", "resets_at": null }"#;
assert!(serde_json::from_str::<UsageBucket>(json).is_err());
}
#[test]
fn extra_usage_null_utilization_parses_as_none() {
let json = r#"{
"is_enabled": true,
"utilization": null,
"monthly_limit": 100.0,
"used_credits": null,
"currency": null
}"#;
let extra: ExtraUsage = serde_json::from_str(json).expect("parse");
assert_eq!(extra.is_enabled, Some(true));
assert!(extra.utilization.is_none());
assert_eq!(extra.monthly_limit, Some(100.0));
}
#[test]
fn extra_usage_utilization_clamps() {
let json = r#"{ "utilization": 250.0 }"#;
let extra: ExtraUsage = serde_json::from_str(json).expect("parse");
assert_eq!(extra.utilization.unwrap().value(), 100.0);
}
#[test]
fn into_endpoint_usage_preserves_unknown_buckets() {
let resp: UsageApiResponse = serde_json::from_str(LIVE_CAPTURE).expect("parse");
assert_eq!(resp.unknown_buckets.len(), 4);
let endpoint = resp.into_endpoint_usage();
assert!(endpoint.five_hour.is_some());
assert!(endpoint.seven_day.is_some());
assert!(endpoint.extra_usage.is_some());
assert_eq!(endpoint.unknown_buckets.len(), 4);
}
#[test]
fn jsonl_usage_smart_ctor_stores_windows() {
let seven = SevenDayWindow::new(TokenCounts::default());
let jsonl = JsonlUsage::new(None, seven.clone());
assert!(jsonl.five_hour.is_none());
assert_eq!(jsonl.seven_day, seven);
}
}