use serde::{Deserialize, Serialize};
use crate::usage::{
AnthropicSnapshot, Cents, CloudCredits, ExtraUsage, ExtraUsageStatus, ResetCredit,
ResetCredits, ScopedWindow, UsageCredits, UsageWindow, checked_reset_title,
};
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct UsageResponse {
#[serde(default)]
pub five_hour: Option<Window>,
#[serde(default)]
pub seven_day: Option<Window>,
#[serde(default)]
pub seven_day_sonnet: Option<Window>,
#[serde(default)]
pub extra_usage: Option<ExtraUsageBlock>,
#[serde(default)]
pub limits: Vec<LimitEntry>,
#[serde(default)]
pub cedar_ember: Option<ResetGrantsBlock>,
#[serde(default)]
pub iguana_necktie: Option<Box<serde_json::value::RawValue>>,
#[serde(default)]
pub spend: Option<Box<serde_json::value::RawValue>>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
pub struct ResetGrantsBlock {
#[serde(default)]
pub eligible: bool,
#[serde(default)]
pub grants: Vec<ResetGrant>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
pub struct ResetGrant {
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub resets_left: u32,
#[serde(default)]
pub ends_at: Option<String>,
#[serde(default)]
pub usable_now: bool,
#[serde(default)]
pub paused: bool,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
pub struct LimitEntry {
#[serde(default)]
pub kind: Option<String>,
#[serde(default, deserialize_with = "de_percent_opt")]
pub percent: Option<f64>,
#[serde(default)]
pub resets_at: Option<String>,
#[serde(default)]
pub scope: Option<LimitScope>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
pub struct LimitScope {
#[serde(default)]
pub model: Option<LimitModel>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
pub struct LimitModel {
#[serde(default)]
pub display_name: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
pub struct Window {
#[serde(default, deserialize_with = "de_percent")]
pub utilization: f64,
#[serde(default)]
pub resets_at: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
pub struct ExtraUsageBlock {
#[serde(default)]
pub is_enabled: bool,
#[serde(default, deserialize_with = "de_opt_cents")]
pub monthly_limit: Option<i64>,
#[serde(default, deserialize_with = "de_opt_cents")]
pub used_credits: Option<i64>,
#[serde(default, deserialize_with = "de_opt_currency")]
pub currency: Option<String>,
#[serde(default, deserialize_with = "de_opt_decimal_places")]
pub decimal_places: Option<u32>,
#[serde(default)]
pub user_disabled: bool,
#[serde(default)]
pub spend_limit_reached: bool,
#[serde(default)]
pub credits_ever_enabled: bool,
#[serde(default)]
pub disabled_reason: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
pub struct CloudCreditsBlock {
#[serde(default, deserialize_with = "de_opt_number")]
pub limit_dollars: Option<serde_json::Number>,
#[serde(default, deserialize_with = "de_opt_number")]
pub remaining_dollars: Option<serde_json::Number>,
#[serde(default, deserialize_with = "de_opt_plain_string")]
pub resets_at: Option<String>,
#[serde(default, deserialize_with = "de_opt_plain_string")]
pub locked_reason: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
pub struct SpendBlock {
#[serde(default)]
pub balance: Option<serde_json::Value>,
#[serde(default)]
pub auto_reload: Option<serde_json::Value>,
}
fn raw_object_block<T>(raw: &serde_json::value::RawValue) -> Option<T>
where
T: serde::de::DeserializeOwned,
{
let v = serde_json::from_str::<serde_json::Value>(raw.get()).ok()?;
match v {
serde_json::Value::Object(_) => serde_json::from_value::<T>(v).ok(),
_ => None,
}
}
fn de_opt_number<'de, D>(d: D) -> std::result::Result<Option<serde_json::Number>, D::Error>
where
D: serde::Deserializer<'de>,
{
match serde_json::Value::deserialize(d)? {
serde_json::Value::Null => Ok(None),
serde_json::Value::Number(n) => Ok(Some(n)),
_ => Ok(None),
}
}
fn dollars_to_cents(n: &serde_json::Number) -> Option<i64> {
let v = n.as_f64()?;
if !v.is_finite() || v < 0.0 || v > 1e13 {
return None;
}
if v == 0.0 {
return Some(0);
}
let text = n.to_string();
if text.contains(['e', 'E']) {
return Some((v * 100.0).round() as i64);
}
let (int_part, frac_part) = match text.split_once('.') {
Some((i, f)) => (i, f),
None => (text.as_str(), ""),
};
if int_part.is_empty() || int_part.bytes().any(|b| !b.is_ascii_digit()) {
return None;
}
let mut cents: i64 = 0;
for b in int_part.bytes() {
cents = cents.checked_mul(10)?.checked_add(i64::from(b - b'0'))?;
}
cents = cents.checked_mul(100)?;
let digits: Vec<u8> = frac_part.bytes().filter(|b| b.is_ascii_digit()).collect();
let first = i64::from(digits.first().copied().unwrap_or(b'0') - b'0');
let second = i64::from(digits.get(1).copied().unwrap_or(b'0') - b'0');
let half_up = i64::from(digits.get(2).is_some_and(|d| *d >= b'5'));
cents.checked_add(first * 10 + second + half_up)
}
fn de_opt_plain_string<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
match serde_json::Value::deserialize(d)? {
serde_json::Value::Null => Ok(None),
serde_json::Value::String(s) => Ok(Some(s)),
_ => Ok(None),
}
}
fn de_opt_decimal_places<'de, D>(d: D) -> std::result::Result<Option<u32>, D::Error>
where
D: serde::Deserializer<'de>,
{
let n = match serde_json::Value::deserialize(d)? {
serde_json::Value::Null => return Ok(None),
serde_json::Value::Number(n) => n
.as_i64()
.or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)),
_ => None,
};
match n {
Some(n) if (0..=6).contains(&n) => Ok(Some(n as u32)),
_ => Err(serde::de::Error::custom(
"decimal_places must be an integer in 0..=6",
)),
}
}
fn de_opt_currency<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
match Option::<String>::deserialize(d)? {
None => Ok(None),
Some(s) if s.len() == 3 && s.bytes().all(|b| b.is_ascii_uppercase()) => Ok(Some(s)),
Some(s) => Err(serde::de::Error::custom(format!(
"currency {s:?} is not an ISO 4217 alpha code"
))),
}
}
fn de_opt_cents<'de, D>(d: D) -> std::result::Result<Option<i64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = serde_json::Value::deserialize(d)?;
match v {
serde_json::Value::Null => Ok(None),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
if i >= 0 {
Ok(Some(i))
} else {
Err(serde::de::Error::custom("cents cannot be negative"))
}
} else if let Some(f) = n.as_f64() {
const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
if f.is_finite() && f.fract() == 0.0 && (0.0..=MAX_EXACT_F64_INT).contains(&f) {
Ok(Some(f as i64))
} else {
Err(serde::de::Error::custom(
"cents must be a non-negative integer in range",
))
}
} else {
Err(serde::de::Error::custom("number out of i64 range"))
}
}
other => Err(serde::de::Error::custom(format!(
"expected number or null, got {other:?}"
))),
}
}
const PCT_SLACK: f64 = 1.0;
fn checked_percent<E: serde::de::Error>(v: f64) -> std::result::Result<f64, E> {
if !v.is_finite() {
return Err(E::custom(format!("percentage {v} is not finite")));
}
if !(0.0..=100.0 + PCT_SLACK).contains(&v) {
return Err(E::custom(format!("percentage {v} outside 0..=100")));
}
Ok(v)
}
fn de_percent<'de, D>(d: D) -> std::result::Result<f64, D::Error>
where
D: serde::Deserializer<'de>,
{
checked_percent(f64::deserialize(d)?)
}
fn de_percent_opt<'de, D>(d: D) -> std::result::Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<f64>::deserialize(d)?
.map(checked_percent)
.transpose()
}
impl UsageResponse {
pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
const SESSION: chrono::Duration = chrono::Duration::hours(5);
const WEEKLY: chrono::Duration = chrono::Duration::days(7);
fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
let Some(w) = w else {
return UsageWindow {
utilization_pct: 0,
resets_at: None,
window_duration: dur,
};
};
UsageWindow {
utilization_pct: i32::from(crate::format::clamp_pct(w.utilization)),
resets_at: w
.resets_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc)),
window_duration: dur,
}
}
let session = to_window(self.five_hour, SESSION);
let weekly = to_window(self.seven_day, WEEKLY);
let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
let extra = self.extra_usage.and_then(|e| {
let status = if e.is_enabled {
ExtraUsageStatus::Active
} else if e.user_disabled {
return None;
} else {
match e.disabled_reason.as_deref() {
Some("out_of_credits") => ExtraUsageStatus::OutOfCredits,
Some("monthly_limit_reached" | "spend_limit_reached") => {
ExtraUsageStatus::MonthlyLimitReached
}
_ if e.spend_limit_reached => ExtraUsageStatus::MonthlyLimitReached,
_ if e.credits_ever_enabled && e.used_credits.unwrap_or_default() > 0 => {
ExtraUsageStatus::Unavailable
}
_ => return None,
}
};
Some(ExtraUsage {
limit: e.monthly_limit.map(Cents),
spent: Cents(e.used_credits?),
decimal_places: e.decimal_places,
currency: e.currency,
status,
})
});
let scoped = self
.limits
.into_iter()
.filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
.filter_map(|l| {
let label = l.scope?.model?.display_name?;
let utilization = l.percent?;
let window = to_window(
Some(Window {
utilization,
resets_at: l.resets_at,
}),
WEEKLY,
);
Some(ScopedWindow { label, window })
})
.collect();
let cloud_credits = self
.iguana_necktie
.as_ref()
.and_then(|raw| raw_object_block::<CloudCreditsBlock>(raw))
.and_then(cloud_credits_from);
let usage_credits = self
.spend
.as_ref()
.and_then(|raw| raw_object_block::<SpendBlock>(raw))
.and_then(usage_credits_from);
AnthropicSnapshot {
plan: plan_label,
session,
weekly,
sonnet,
scoped,
extra,
reset_credits: reset_credits(self.cedar_ember),
cloud_credits,
usage_credits,
}
}
}
fn cloud_credits_from(block: CloudCreditsBlock) -> Option<CloudCredits> {
let limit = dollars_to_cents(block.limit_dollars.as_ref()?)?;
let remaining = dollars_to_cents(block.remaining_dollars.as_ref()?)?;
if limit <= 0 || remaining < 0 {
return None;
}
Some(CloudCredits {
limit: Cents(limit),
remaining: Cents(remaining),
expires_at: block
.resets_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc)),
locked: block.locked_reason.is_some_and(|r| !r.is_empty()),
})
}
fn json_int(v: &serde_json::Value) -> Option<i64> {
let n = v.as_number()?;
if let Some(i) = n.as_i64() {
return Some(i);
}
if let Some(u) = n.as_u64() {
return i64::try_from(u).ok();
}
n.as_f64()
.filter(|f| f.is_finite() && f.fract() == 0.0 && f.abs() <= 2_f64.powi(53))
.map(|f| f as i64)
}
fn usage_credits_from(spend: SpendBlock) -> Option<UsageCredits> {
let balance_value = spend.balance?;
let balance = balance_value.as_object()?;
let currency = balance
.get("currency")
.and_then(|v| v.as_str())
.map(str::to_string);
let shaped = (|| {
let minor = balance.get("amount_minor").and_then(json_int)?;
let exponent = balance
.get("exponent")
.and_then(json_int)
.filter(|e| (0..=4).contains(e))?;
let currency = currency.as_ref()?;
Some((minor, exponent as u32, currency.clone()))
})()
.or_else(|| {
let amount = balance.get("amount").and_then(json_int)?;
let currency = currency.as_ref().filter(|c| *c == "USD")?;
Some((amount, 2, currency.clone()))
});
let (amount, decimal_places, currency) = shaped?;
let auto_reload = spend
.auto_reload
.as_ref()
.and_then(|v| v.get("enabled"))
.and_then(|v| v.as_bool())
.or_else(|| {
balance
.get("auto_reload_settings")
.and_then(|v| v.get("enabled"))
.and_then(|v| v.as_bool())
});
Some(UsageCredits {
balance: Cents(amount),
decimal_places,
currency: Some(currency),
auto_reload,
})
}
fn reset_credits(block: Option<ResetGrantsBlock>) -> ResetCredits {
let Some(block) = block.filter(|block| block.eligible) else {
return ResetCredits::default();
};
let usable = block
.grants
.into_iter()
.filter(|grant| grant.usable_now && !grant.paused && grant.resets_left > 0);
let mut credits = ResetCredits::default();
for grant in usable {
credits.available = credits.available.saturating_add(grant.resets_left);
credits.credits.push(ResetCredit {
title: checked_reset_title(grant.label),
expires_at: grant
.ends_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc)),
});
}
credits
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_response() {
let raw = r#"{
"five_hour": {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
"seven_day": {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
"seven_day_sonnet": {"utilization": 4.2, "resets_at": "2026-05-30T12:00:00Z"},
"extra_usage": {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let snap = resp.into_snapshot("Max 5x".into());
assert_eq!(snap.session.utilization_pct, 43); assert_eq!(snap.weekly.utilization_pct, 27);
assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
let extra = snap.extra.as_ref().unwrap();
assert_eq!(extra.limit, Some(Cents(5000)));
assert_eq!(extra.spent.0, 250);
assert!(snap.session.resets_at.is_some());
}
#[test]
fn parses_weekly_scoped_limits() {
let raw = r#"{
"five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
"seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
"limits": [
{"kind": "session", "group": "session", "percent": 10,
"severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
"scope": null, "is_active": false},
{"kind": "weekly_all", "group": "weekly", "percent": 55,
"severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
"scope": null, "is_active": false},
{"kind": "weekly_scoped", "group": "weekly", "percent": 84,
"severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
"scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
"is_active": true}
]
}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let snap = resp.into_snapshot("Pro".into());
assert_eq!(snap.scoped.len(), 1);
assert_eq!(snap.scoped[0].label, "Fable");
assert_eq!(snap.scoped[0].window.utilization_pct, 84);
assert!(snap.scoped[0].window.resets_at.is_some());
assert_eq!(snap.weekly.utilization_pct, 55);
}
#[test]
fn missing_limits_array_yields_empty_scoped() {
let raw = r#"{
"five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
"seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let snap = resp.into_snapshot("Pro".into());
assert!(snap.scoped.is_empty());
}
#[test]
fn missing_sonnet_and_extra_are_none() {
let raw = r#"{
"five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
"seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let snap = resp.into_snapshot("Pro".into());
assert!(snap.sonnet.is_none());
assert!(snap.extra.is_none());
}
#[test]
fn user_disabled_extra_usage_becomes_none() {
let raw = r#"{
"five_hour": {"utilization": 0},
"seven_day": {"utilization": 0},
"extra_usage": {"is_enabled": false, "user_disabled": true,
"monthly_limit": 5000, "used_credits": 0}
}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let snap = resp.into_snapshot("Pro".into());
assert!(snap.extra.is_none());
}
#[test]
fn provider_disabled_extra_usage_keeps_spend_and_reason_visible() {
let raw = r#"{
"extra_usage": {
"is_enabled": false,
"monthly_limit": null,
"used_credits": 1234.0,
"currency": "USD",
"decimal_places": 2,
"disabled_reason": "out_of_credits",
"user_disabled": false,
"spend_limit_reached": false,
"credits_ever_enabled": true
}
}"#;
let extra = serde_json::from_str::<UsageResponse>(raw)
.unwrap()
.into_snapshot("Pro".into())
.extra
.unwrap();
assert_eq!(extra.fmt_spent(), "$12.34");
assert_eq!(extra.status, ExtraUsageStatus::OutOfCredits);
assert_eq!(extra.status_note(), Some("out of credits"));
}
#[test]
fn enabled_extra_usage_without_spend_is_dropped() {
for raw in [
r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000}}"#,
r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000,"used_credits":null}}"#,
] {
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
assert!(resp.into_snapshot("Pro".into()).extra.is_none(), "{raw}");
}
}
#[test]
fn uncapped_plan_keeps_real_spend_visible() {
let resp: UsageResponse = serde_json::from_str(
r#"{"extra_usage":{"is_enabled":true,"monthly_limit":null,
"used_credits":14157.0,"currency":"BRL","decimal_places":2,
"disabled_reason":null}}"#,
)
.unwrap();
let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
assert_eq!(extra.limit, None);
assert_eq!(extra.spent.0, 14157);
assert_eq!(extra.percent(), 0);
assert_eq!(extra.currency.as_deref(), Some("BRL"));
assert_eq!(extra.decimal_places, Some(2));
assert_eq!(extra.fmt_spent(), "R$141.57");
let resp: UsageResponse =
serde_json::from_str(r#"{"extra_usage":{"is_enabled":true,"used_credits":250}}"#)
.unwrap();
let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
assert_eq!(extra.limit, None);
assert_eq!(extra.spent.0, 250);
}
#[test]
fn implausible_decimal_places_is_schema_drift() {
for value in ["7", "-1", "100", "2.5"] {
let raw = format!(
r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"decimal_places":{value}}}}}"#
);
assert!(
serde_json::from_str::<UsageResponse>(&raw).is_err(),
"{raw}"
);
}
let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":2.0}}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
assert_eq!(
resp.into_snapshot("Pro".into())
.extra
.unwrap()
.decimal_places,
Some(2)
);
let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":null}}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
assert_eq!(extra.decimal_places, None);
assert_eq!(extra.fmt_spent(), "$2.50");
let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW"}}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
assert_eq!(extra.decimal_places, None);
assert_eq!(extra.fmt_spent(), "500 minor units KRW");
let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD"}}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
assert_eq!(extra.decimal_places, None);
assert_eq!(extra.fmt_spent(), "1234 minor units KWD");
let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW","decimal_places":0}}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
assert_eq!(
resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
"500 KRW"
);
let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD","decimal_places":3}}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
assert_eq!(
resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
"1.234 KWD"
);
}
#[test]
fn currency_must_be_an_iso_alpha_code() {
for value in [r#""brl""#, r#""""#, r#""R$""#, r#""USD;;0""#, r#""<b>""#] {
let raw = format!(
r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"currency":{value}}}}}"#
);
assert!(
serde_json::from_str::<UsageResponse>(&raw).is_err(),
"{raw}"
);
}
let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"currency":null}}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
assert_eq!(
resp.into_snapshot("Pro".into()).extra.unwrap().currency,
None
);
}
#[test]
fn malformed_cent_values_are_schema_drift() {
for value in ["-1", "1.5", "1e300", "true", r#""lots""#] {
let raw = format!(
r#"{{"extra_usage":{{"is_enabled":true,"monthly_limit":{value},"used_credits":0}}}}"#
);
assert!(
serde_json::from_str::<UsageResponse>(&raw).is_err(),
"{raw}"
);
}
let resp: UsageResponse = serde_json::from_str(
r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000.0,"used_credits":250.0}}"#,
)
.unwrap();
let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
assert_eq!(extra.limit, Some(Cents(5000)));
assert_eq!(extra.spent.0, 250);
}
#[test]
fn empty_object_yields_neutral_snapshot() {
let resp: UsageResponse = serde_json::from_str("{}").unwrap();
let snap = resp.into_snapshot("Unknown".into());
assert_eq!(snap.session.utilization_pct, 0);
assert_eq!(snap.weekly.utilization_pct, 0);
assert!(snap.session.resets_at.is_none());
}
#[test]
fn benign_overshoot_saturates_to_hundred() {
let raw = r#"{
"five_hour": {"utilization": 100.4},
"seven_day": {"utilization": 100.6}
}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let snap = resp.into_snapshot("Pro".into());
assert_eq!(snap.session.utilization_pct, 100);
assert_eq!(snap.weekly.utilization_pct, 100); }
#[test]
fn out_of_range_utilization_is_rejected_not_clamped() {
for raw in [
r#"{"five_hour": {"utilization": 500}}"#,
r#"{"five_hour": {"utilization": -1}}"#,
r#"{"seven_day": {"utilization": 101.5}}"#,
] {
let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
assert!(err.to_string().contains("outside 0..=100"), "{raw}: {err}");
}
}
#[test]
fn out_of_range_scoped_percent_is_rejected() {
let raw = r#"{
"limits": [{"kind": "weekly_scoped", "percent": 420,
"scope": {"model": {"display_name": "Fable"}}}]
}"#;
let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
assert!(err.to_string().contains("outside 0..=100"), "{err}");
}
#[test]
fn non_finite_percentage_is_rejected() {
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(checked_percent::<serde_json::Error>(v).is_err(), "{v}");
}
}
#[test]
fn scoped_limit_without_a_percentage_is_dropped_not_zeroed() {
let raw = r#"{
"limits": [{"kind": "weekly_scoped", "percent": null,
"scope": {"model": {"display_name": "Fable"}}}]
}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let snap = resp.into_snapshot("Pro".into());
assert!(
snap.scoped.is_empty(),
"a scoped limit with no percentage must not render a fabricated bar"
);
let ok = r#"{
"limits": [{"kind": "weekly_scoped", "percent": 84,
"scope": {"model": {"display_name": "Fable"}}}]
}"#;
let resp: UsageResponse = serde_json::from_str(ok).unwrap();
let snap = resp.into_snapshot("Pro".into());
assert_eq!(snap.scoped[0].label, "Fable");
assert_eq!(snap.scoped[0].window.utilization_pct, 84);
}
const LAUNCH_GRANT: &str = r#"{
"five_hour": {"utilization": 2, "resets_at": "2026-09-24T17:49:59Z"},
"seven_day": {"utilization": 63, "resets_at": "2026-09-25T08:59:59Z"},
"cedar_ember": {
"eligible": true,
"ineligible_reason": null,
"at_limit": false,
"exhausted": [],
"grants": [{
"id": "a-redemption-handle",
"label": "Claude Opus 5.5 launch: one usage-limit reset for Team members",
"resets_total": 1,
"resets_left": 1,
"starts_at": "2026-09-22T16:00:00+00:00",
"ends_at": "2026-10-22T16:00:00+00:00",
"clears": ["five_hour", "seven_day", "seven_day_overage_included"],
"paused": false,
"usable_now": true,
"use_requires_limit": false,
"percent_used": {"five_hour": 2, "seven_day": 63},
"blocking": [],
"arm": null
}],
"next_grant_id": "a-redemption-handle",
"weekly_resets_at": "2026-09-25T09:00:00+00:00",
"cooldown_until": null,
"event_props": null
}
}"#;
#[test]
fn parses_banked_resets_from_the_cedar_ember_block() {
let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
let snap = resp.into_snapshot("Max 20x".into());
assert_eq!(snap.reset_credits.available, 1);
assert_eq!(
snap.reset_credits.credits[0].title.as_deref(),
Some("Claude Opus 5.5 launch: one usage-limit reset for Team members")
);
assert_eq!(
snap.reset_credits.next_expiry(),
Some("2026-10-22T16:00:00Z".parse().unwrap())
);
assert_eq!(snap.session.utilization_pct, 2);
assert_eq!(snap.weekly.utilization_pct, 63);
}
#[test]
fn the_redemption_grant_id_never_leaves_the_parser() {
let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
let snap = resp.into_snapshot("Max 20x".into());
assert!(!format!("{snap:?}").contains("a-redemption-handle"));
}
#[test]
fn an_ineligible_block_reports_no_resets() {
let raw = r#"{
"cedar_ember": {
"eligible": false,
"ineligible_reason": "cli_version",
"grants": [{"id": "g", "resets_left": 1, "usable_now": true,
"ends_at": "2026-10-22T16:00:00Z"}]
}
}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
}
#[test]
fn only_redeemable_grants_are_counted() {
let raw = r#"{
"cedar_ember": {
"eligible": true,
"grants": [
{"id": "a", "label": "not open yet", "resets_left": 1,
"usable_now": false, "paused": false},
{"id": "b", "label": "paused", "resets_left": 1,
"usable_now": true, "paused": true},
{"id": "c", "label": "spent", "resets_left": 0,
"usable_now": true, "paused": false},
{"id": "d", "label": "yours", "resets_left": 2,
"usable_now": true, "paused": false,
"ends_at": "2026-10-22T16:00:00Z"}
]
}
}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let credits = resp.into_snapshot("Pro".into()).reset_credits;
assert_eq!(credits.available, 2, "only the redeemable grant counts");
assert_eq!(credits.credits.len(), 1);
assert_eq!(credits.credits[0].title.as_deref(), Some("yours"));
}
#[test]
fn an_absent_or_null_block_is_not_a_parse_failure() {
for raw in [
r#"{"five_hour": {"utilization": 5}}"#,
r#"{"cedar_ember": null}"#,
] {
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
}
}
#[test]
fn a_control_bearing_label_is_dropped_but_the_expiry_survives() {
let raw = r#"{
"cedar_ember": {
"eligible": true,
"grants": [{"id": "a", "label": "line\u0000break", "resets_left": 1,
"usable_now": true, "ends_at": "2026-10-22T16:00:00Z"}]
}
}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let credits = resp.into_snapshot("Pro".into()).reset_credits;
assert_eq!(credits.available, 1);
assert!(credits.credits[0].title.is_none());
assert!(credits.credits[0].expires_at.is_some());
}
#[test]
fn unparseable_reset_becomes_none() {
let raw = r#"{
"five_hour": {"utilization": 50, "resets_at": "not a date"},
"seven_day": {"utilization": 0}
}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let snap = resp.into_snapshot("Pro".into());
assert!(snap.session.resets_at.is_none());
assert_eq!(snap.session.utilization_pct, 50);
}
const CREDITS_PAYLOAD: &str = r#"{
"five_hour": {"utilization": 12, "resets_at": "2026-10-26T15:00:00Z"},
"seven_day": {"utilization": 34, "resets_at": "2026-11-02T07:00:00Z"},
"iguana_necktie": {"utilization":0.0,"resets_at":"2026-11-05T07:59:00+00:00","limit_dollars":250,"used_dollars":0.0,"remaining_dollars":250.0,"locked_reason":null},
"spend": {"used":{"amount_minor":0,"currency":"USD","exponent":2},"limit":{"amount_minor":0,"currency":"USD","exponent":2},"percent":0,"severity":"normal","enabled":false,"disabled_reason":"org_level_disabled_until","cap":{"money":null,"credits":{"amount_minor":0,"exponent":2}},"balance":null,"auto_reload":null,"disclaimer":"Usage credits cover you when you hit your plan limits. [Learn more](https://support.claude.com/articles/12429409)","can_purchase_credits":false,"can_toggle":false}
}"#;
#[test]
fn the_real_credits_payload_lifts_cloud_credits_and_no_usage_balance() {
let snap = serde_json::from_str::<UsageResponse>(CREDITS_PAYLOAD)
.unwrap()
.into_snapshot("Max 5x".into());
let cloud = snap
.cloud_credits
.as_ref()
.expect("iguana_necktie must lift into cloud_credits");
assert_eq!(cloud.limit, Cents(25000));
assert_eq!(cloud.remaining, Cents(25000));
assert_eq!(
cloud.expires_at,
Some("2026-11-05T07:59:00Z".parse().unwrap())
);
assert!(!cloud.locked);
assert!(snap.usage_credits.is_none());
assert_eq!(snap.session.utilization_pct, 12);
assert_eq!(snap.weekly.utilization_pct, 34);
}
#[test]
fn cloud_credits_carry_the_remaining_after_spend() {
let raw = r#"{
"iguana_necktie": {"resets_at": "2026-11-05T07:59:00+00:00",
"limit_dollars": 250, "used_dollars": 100,
"remaining_dollars": 150, "locked_reason": null}
}"#;
let snap = serde_json::from_str::<UsageResponse>(raw)
.unwrap()
.into_snapshot("Max 5x".into());
let cloud = snap.cloud_credits.as_ref().unwrap();
assert_eq!(cloud.limit, Cents(25000));
assert_eq!(cloud.remaining, Cents(15000));
}
#[test]
fn iguana_necktie_drift_degrades_to_none_not_an_error() {
for raw in [
r#"{"five_hour": {"utilization": 5}}"#,
r#"{"five_hour": {"utilization": 5}, "iguana_necktie": null}"#,
r#"{"iguana_necktie": {"limit_dollars": 0, "remaining_dollars": 250}}"#,
r#"{"iguana_necktie": {"limit_dollars": 250, "remaining_dollars": -5}}"#,
r#"{"iguana_necktie": {"limit_dollars": "a lot", "remaining_dollars": 250}}"#,
r#"{"iguana_necktie": {"limit_dollars": 250, "remaining_dollars": null}}"#,
r#"{"iguana_necktie": [1, 2]}"#,
r#"{"iguana_necktie": "hostile"}"#,
] {
let resp: UsageResponse =
serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
assert!(
resp.into_snapshot("Pro".into()).cloud_credits.is_none(),
"{raw}"
);
}
let resp: UsageResponse = serde_json::from_str(
r#"{"five_hour": {"utilization": 5}, "iguana_necktie": "hostile"}"#,
)
.unwrap();
assert_eq!(resp.into_snapshot("Pro".into()).session.utilization_pct, 5);
}
#[test]
fn a_non_empty_locked_reason_locks_the_credits_without_leaving_the_parser() {
let raw = r#"{
"iguana_necktie": {"resets_at": "2026-11-05T07:59:00Z",
"limit_dollars": 250, "remaining_dollars": 250,
"locked_reason": "<b>org_migrating</b>"}
}"#;
let snap = serde_json::from_str::<UsageResponse>(raw)
.unwrap()
.into_snapshot("Max 5x".into());
let cloud = snap.cloud_credits.as_ref().unwrap();
assert!(cloud.locked);
assert!(!format!("{cloud:?}").contains("<b>"));
for (reason, locked) in [("\"\"", false), ("42", false), ("[1]", false)] {
let raw = format!(
r#"{{"iguana_necktie": {{"limit_dollars": 250, "remaining_dollars": 250, "locked_reason": {reason}}}}}"#
);
let snap = serde_json::from_str::<UsageResponse>(&raw)
.unwrap()
.into_snapshot("Max 5x".into());
assert_eq!(snap.cloud_credits.as_ref().unwrap().locked, locked, "{raw}");
}
let raw = r#"{"iguana_necktie": {"limit_dollars": 250, "remaining_dollars": 250,
"resets_at": "not a date"}}"#;
let snap = serde_json::from_str::<UsageResponse>(raw)
.unwrap()
.into_snapshot("Max 5x".into());
let cloud = snap.cloud_credits.as_ref().unwrap();
assert!(cloud.expires_at.is_none());
assert_eq!(cloud.limit, Cents(25000));
}
#[test]
fn spend_balance_shape_a_lifts_minor_units_with_its_own_scale() {
let raw = r#"{
"spend": {"balance": {"amount_minor": 1500, "currency": "USD", "exponent": 2},
"auto_reload": {"enabled": true}}
}"#;
let usage = serde_json::from_str::<UsageResponse>(raw)
.unwrap()
.into_snapshot("Pro".into())
.usage_credits
.expect("shape (a) must lift into usage_credits");
assert_eq!(usage.balance, Cents(1500));
assert_eq!(usage.decimal_places, 2);
assert_eq!(usage.currency.as_deref(), Some("USD"));
assert_eq!(usage.auto_reload, Some(true));
let raw = r#"{
"spend": {"balance": {"amount_minor": 500.0, "currency": "KRW", "exponent": 0.0},
"auto_reload": null}
}"#;
let usage = serde_json::from_str::<UsageResponse>(raw)
.unwrap()
.into_snapshot("Pro".into())
.usage_credits
.unwrap();
assert_eq!(usage.balance, Cents(500));
assert_eq!(usage.decimal_places, 0);
assert_eq!(usage.currency.as_deref(), Some("KRW"));
assert_eq!(usage.auto_reload, None);
}
#[test]
fn spend_balance_shape_b_lifts_usd_minor_units_and_nested_auto_reload() {
let raw = r#"{
"spend": {"balance": {"amount": 750, "currency": "USD",
"auto_reload_settings": {"enabled": false}},
"auto_reload": null}
}"#;
let usage = serde_json::from_str::<UsageResponse>(raw)
.unwrap()
.into_snapshot("Pro".into())
.usage_credits
.expect("shape (b) must lift into usage_credits");
assert_eq!(usage.balance, Cents(750));
assert_eq!(usage.decimal_places, 2);
assert_eq!(usage.currency.as_deref(), Some("USD"));
assert_eq!(usage.auto_reload, Some(false));
let raw = r#"{
"spend": {"balance": {"amount": 750, "currency": "USD",
"auto_reload_settings": {"enabled": false}},
"auto_reload": {"enabled": true}}
}"#;
let usage = serde_json::from_str::<UsageResponse>(raw)
.unwrap()
.into_snapshot("Pro".into())
.usage_credits
.unwrap();
assert_eq!(usage.auto_reload, Some(true));
let raw = r#"{"spend": {"balance": {"amount_minor": 5, "amount": 100,
"currency": "USD"}}}"#;
let usage = serde_json::from_str::<UsageResponse>(raw)
.unwrap()
.into_snapshot("Pro".into())
.usage_credits
.unwrap();
assert_eq!(usage.balance, Cents(100));
assert_eq!(usage.decimal_places, 2);
}
#[test]
fn spend_balances_we_cannot_express_are_absent_not_zero() {
for raw in [
r#"{"spend": {"balance": null}}"#,
r#"{"spend": {"balance": {"amount": 500, "currency": "BRL"}}}"#,
r#"{"spend": {"balance": {"amount_minor": 5, "currency": "USD", "exponent": 5}}}"#,
r#"{"spend": {"balance": 42}}"#,
r#"{"spend": {"balance": {"dollars": 4}}}"#,
r#"{"spend": {"balance": {"amount_minor": 5, "exponent": 2}}}"#,
r#"{"spend": {"balance": {"amount": 750, "currency": "USD",
"auto_reload_settings": {"enabled": "on"}},
"auto_reload": 7}}"#,
] {
let resp: UsageResponse =
serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
let snap = resp.into_snapshot("Pro".into());
if raw.contains(r#""auto_reload": 7"#) {
let usage = snap.usage_credits.as_ref().unwrap();
assert_eq!(usage.auto_reload, None, "{raw}");
} else {
assert!(snap.usage_credits.is_none(), "{raw}");
}
}
}
#[test]
fn spend_drift_degrades_to_none_not_an_error() {
for raw in [
r#"{"five_hour": {"utilization": 5}}"#,
r#"{"spend": null}"#,
r#"{"spend": [1]}"#,
r#"{"spend": "hostile"}"#,
r#"{"spend": {"balance": "not a balance", "auto_reload": {"enabled": "on"}}}"#,
] {
let resp: UsageResponse =
serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
let snap = resp.into_snapshot("Pro".into());
assert!(snap.usage_credits.is_none(), "{raw}");
}
let resp: UsageResponse =
serde_json::from_str(r#"{"five_hour": {"utilization": 5}, "spend": "hostile"}"#)
.unwrap();
assert_eq!(resp.into_snapshot("Pro".into()).session.utilization_pct, 5);
}
#[test]
fn a_number_the_parser_cannot_materialize_loses_only_its_block() {
let raw = r#"{"five_hour":{"utilization":5},"iguana_necktie":{"limit_dollars":1e400,"remaining_dollars":1},"spend":{"balance":{"amount_minor":1e400,"currency":"USD","exponent":2}}}"#;
let resp: UsageResponse =
serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
let snap = resp.into_snapshot("Pro".into());
assert_eq!(snap.session.utilization_pct, 5);
assert!(snap.cloud_credits.is_none(), "1e400 dollars is not a limit");
assert!(
snap.usage_credits.is_none(),
"1e400 minor units is not a balance"
);
}
#[test]
fn a_huge_finite_float_inside_a_credit_block_degrades_the_block_only() {
let raw = r#"{"five_hour":{"utilization":5},"iguana_necktie":{"limit_dollars":1e300,"remaining_dollars":1}}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let snap = resp.into_snapshot("Pro".into());
assert_eq!(snap.session.utilization_pct, 5);
assert!(snap.cloud_credits.is_none());
}
#[test]
fn dollars_to_cents_rounds_monetary_ties_up_in_exact_arithmetic() {
fn cents(text: &str) -> i64 {
let n: serde_json::Number = serde_json::from_str(text).unwrap();
dollars_to_cents(&n).unwrap()
}
assert_eq!(cents("1.005"), 101);
assert_eq!(cents("0.004"), 0);
assert_eq!(cents("149.995"), 15000);
assert_eq!(cents("250"), 25000);
assert_eq!(cents("0.005"), 1);
assert_eq!(cents("19.99"), 1999);
}
#[test]
fn dollars_to_cents_reads_negative_zero_as_an_exhausted_zero() {
for text in ["-0", "-0.0"] {
let n: serde_json::Number = serde_json::from_str(text).unwrap();
assert_eq!(dollars_to_cents(&n), Some(0), "{text}");
}
let n: serde_json::Number = serde_json::from_str("-0.01").unwrap();
assert_eq!(dollars_to_cents(&n), None);
}
#[test]
fn dollars_to_cents_rejects_what_cents_cannot_hold() {
fn opt(text: &str) -> Option<i64> {
serde_json::from_str::<serde_json::Number>(text)
.ok()
.and_then(|n| dollars_to_cents(&n))
}
assert_eq!(opt("-5"), None);
assert_eq!(opt("18446744073709551615"), None);
assert_eq!(opt("1e13"), Some(1_000_000_000_000_000));
assert_eq!(opt("1e14"), None);
}
#[test]
fn a_positive_dollar_limit_that_rounds_to_zero_cents_does_not_stand() {
let raw = r#"{"iguana_necktie":{"limit_dollars":0.004,"remaining_dollars":0.001,"resets_at":"2026-11-05T07:59:00Z"}}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
assert!(
resp.into_snapshot("Pro".into()).cloud_credits.is_none(),
"a limit the conversion rounds to zero is absent, not a 0% meter"
);
}
#[test]
fn json_int_rejects_integers_past_its_own_range() {
assert_eq!(json_int(&serde_json::json!(18446744073709551615u64)), None);
assert_eq!(json_int(&serde_json::json!(1500.0)), Some(1500));
}
#[test]
fn a_non_boolean_primary_auto_reload_falls_to_the_balance_setting() {
let raw = r#"{"spend":{"balance":{"amount":750,"currency":"USD","auto_reload_settings":{"enabled":true}},"auto_reload":{"enabled":"unknown"}}}"#;
let resp: UsageResponse = serde_json::from_str(raw).unwrap();
let snap = resp.into_snapshot("Pro".into());
let credits = snap
.usage_credits
.expect("the balance is still shape (b) with a USD amount");
assert_eq!(credits.balance, Cents(750));
assert_eq!(credits.auto_reload, Some(true));
}
}