use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RateWindow {
pub used_percent: f64,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub raw_used_percent: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resets_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub window_minutes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub used_count: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub total_count: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub regeneration: Option<Regeneration>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Regeneration {
pub mechanic: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub rate: Option<RegenerationRate>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RegenerationRate {
pub amount: f64,
pub per_minutes: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExtraWindow {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub window: Option<RateWindow>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct Usage {
#[serde(skip_serializing_if = "Option::is_none")]
pub primary: Option<RateWindow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secondary: Option<RateWindow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tertiary: Option<RateWindow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_rate_windows: Option<Vec<ExtraWindow>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Amount {
pub minor: i64,
pub exponent: u8,
pub unit: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PoolFunding {
Granted,
Purchased,
Subscription,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PoolBasis {
Reported,
Derived,
#[serde(other)]
Unstated,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Pool {
pub id: String,
pub label: String,
pub funding: PoolFunding,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub remaining: Option<Amount>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub total: Option<Amount>,
pub basis: PoolBasis,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub spendable: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct AccountInfo {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub org_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub plan_type: Option<String>,
}
impl AccountInfo {
pub fn is_empty(&self) -> bool {
self.email.is_none() && self.org_name.is_none() && self.plan_type.is_none()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreditExpiry {
pub expires_at: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct SavedResets {
#[serde(default)]
pub available_count: u32,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub soonest_expires_at: Option<String>,
#[serde(default)]
pub credits: Vec<CreditExpiry>,
}
fn account_info_is_empty(value: &Option<AccountInfo>) -> bool {
value.as_ref().map(AccountInfo::is_empty).unwrap_or(true)
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProviderUsage {
pub provider: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub api_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "account_info_is_empty", default)]
pub account_info: Option<AccountInfo>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub fetched_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub saved_resets: Option<SavedResets>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub spend: Option<Vec<Pool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub error_class: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub stale: Option<Stale>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Stale {
pub since: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub class: Option<String>,
}
impl ProviderUsage {
pub fn healthy(provider: &str, account: Option<String>, source: &str, usage: Usage) -> Self {
Self {
provider: provider.to_string(),
api_provider: None,
account,
source: Some(source.to_string()),
account_info: None,
fetched_at: None,
saved_resets: None,
usage: Some(usage),
spend: None,
error: None,
error_class: None,
stale: None,
}
}
pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self {
Self {
provider: provider.to_string(),
api_provider: None,
account: None,
source: None,
account_info: None,
fetched_at: None,
saved_resets: None,
usage: None,
spend: None,
error: Some(error.to_string()),
error_class: None,
stale: None,
}
}
pub fn degraded_with_class(
provider: &str,
error: impl std::fmt::Display,
error_class: impl Into<String>,
) -> Self {
Self {
error_class: Some(error_class.into()),
..Self::degraded(provider, error)
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_window_without_regeneration_is_unchanged_on_the_wire() {
let window = RateWindow {
used_percent: 42.0,
raw_used_percent: None,
resets_at: Some("2026-08-17T00:00:00Z".to_string()),
window_minutes: Some(300),
used_count: None,
total_count: None,
regeneration: None,
};
let json = serde_json::to_string(&window).expect("serializes");
assert!(
!json.contains("regeneration"),
"an absent mechanic must not appear on the wire: {json}"
);
}
#[test]
fn a_pre_regeneration_payload_still_decodes() {
let json = r#"{"usedPercent":42.0,"resetsAt":"2026-08-17T00:00:00Z","windowMinutes":300}"#;
let window: RateWindow = serde_json::from_str(json).expect("older payloads must decode");
assert_eq!(window.regeneration, None);
}
#[test]
fn a_stated_cliff_refill_round_trips() {
let window = RateWindow {
used_percent: 0.67,
raw_used_percent: None,
resets_at: Some("2026-08-15T06:00:00.000Z".to_string()),
window_minutes: None,
used_count: Some(8100.0),
total_count: Some(1_207_000.0),
regeneration: Some(Regeneration {
mechanic: "cliff".to_string(),
rate: Some(RegenerationRate {
amount: 1_000_000.0,
per_minutes: 43_200,
}),
}),
};
let json = serde_json::to_string(&window).expect("serializes");
let back: RateWindow = serde_json::from_str(&json).expect("round-trips");
assert_eq!(back, window);
assert!(
json.contains("\"perMinutes\":43200"),
"camelCase on the wire: {json}"
);
}
#[test]
fn a_mechanic_without_a_rate_is_valid() {
let json = r#"{"usedPercent":10.0,"regeneration":{"mechanic":"drip"}}"#;
let window: RateWindow = serde_json::from_str(json).expect("decodes");
let regeneration = window.regeneration.expect("the mechanic is stated");
assert_eq!(regeneration.mechanic, "drip");
assert_eq!(
regeneration.rate, None,
"the mechanic stands without a rate"
);
}
#[test]
fn an_unrecognised_mechanic_decodes_rather_than_dropping_the_window() {
let json = r#"{"usedPercent":10.0,"regeneration":{"mechanic":"stepped_thaw"}}"#;
let window: RateWindow = serde_json::from_str(json).expect("a future variant must decode");
assert_eq!(
window.regeneration.expect("present").mechanic,
"stepped_thaw"
);
assert_eq!(window.used_percent, 10.0, "the rest of the window survives");
}
#[test]
fn a_fresh_entry_carries_no_stale_key() {
let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
let json = serde_json::to_string(&entry).expect("entry serializes");
assert!(
!json.contains("stale"),
"a fresh entry must not emit the key at all: {json}"
);
}
#[test]
fn a_preserved_reading_states_when_the_failure_began() {
let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
entry.fetched_at = Some("2026-08-13T10:00:00Z".to_string());
entry.stale = Some(Stale {
since: "2026-08-13T10:02:00Z".to_string(),
class: Some("upstream_failed".to_string()),
});
let json = serde_json::to_string(&entry).expect("entry serializes");
let back: ProviderUsage = serde_json::from_str(&json).expect("entry round-trips");
let stale = back.stale.expect("the disclosure survives the round trip");
assert_eq!(stale.since, "2026-08-13T10:02:00Z");
assert_eq!(stale.class.as_deref(), Some("upstream_failed"));
assert_ne!(
Some(stale.since.as_str()),
back.fetched_at.as_deref(),
"the two timestamps answer different questions and must not be conflated"
);
assert!(
json.contains("\"stale\""),
"the key is camelCase on the wire: {json}"
);
}
#[test]
fn a_disclosure_without_a_class_still_decodes() {
let json = r#"{"provider":"codex","stale":{"since":"2026-08-13T10:02:00Z"}}"#;
let entry: ProviderUsage = serde_json::from_str(json).expect("decodes");
let stale = entry.stale.expect("present");
assert_eq!(stale.class, None);
assert_eq!(stale.since, "2026-08-13T10:02:00Z");
}
#[test]
fn an_entry_without_the_field_decodes() {
let json = r#"{"provider":"codex","source":"oauth"}"#;
let entry: ProviderUsage = serde_json::from_str(json).expect("decodes");
assert_eq!(entry.stale, None);
assert_eq!(entry.provider, "codex");
}
use super::*;
#[test]
fn account_info_is_omitted_when_empty_and_keeps_partial_labels() {
let bare = ProviderUsage::healthy(
"codex",
None,
"oauth",
Usage {
primary: Some(RateWindow {
used_percent: 10.0,
raw_used_percent: None,
resets_at: None,
window_minutes: Some(300),
used_count: None,
total_count: None,
regeneration: None,
}),
..Default::default()
},
);
let json = serde_json::to_string(&bare).unwrap();
assert!(
!json.contains("accountInfo"),
"empty accountInfo must be omitted"
);
let mut labeled = bare.clone();
labeled.account_info = Some(AccountInfo {
email: Some("a@b.com".to_string()),
org_name: None,
plan_type: Some("pro".to_string()),
});
let json = serde_json::to_string(&labeled).unwrap();
assert!(json.contains("\"email\":\"a@b.com\""));
assert!(json.contains("\"planType\":\"pro\""));
assert!(!json.contains("orgName"), "absent orgName must be omitted");
}
#[test]
fn saved_resets_use_camel_case_and_round_trip() {
let entry = ProviderUsage {
saved_resets: Some(SavedResets {
available_count: 2,
soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()),
credits: vec![CreditExpiry {
expires_at: "2026-07-31T20:11:35Z".to_string(),
}],
}),
..ProviderUsage::degraded("codex", "x")
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"savedResets\""));
assert!(json.contains("\"availableCount\":2"));
assert!(json.contains("\"soonestExpiresAt\""));
let back: ProviderUsage = serde_json::from_str(&json).unwrap();
assert_eq!(back, entry);
}
#[test]
fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() {
let unrelaxed = RateWindow {
used_percent: 41.0,
raw_used_percent: None,
resets_at: Some("2026-07-20T00:00:00Z".to_string()),
window_minutes: Some(10080),
used_count: None,
total_count: None,
regeneration: None,
};
let json = serde_json::to_string(&unrelaxed).unwrap();
assert!(
!json.contains("rawUsedPercent"),
"unrelaxed window must not carry the field"
);
let relaxed = RateWindow {
used_percent: 0.0,
raw_used_percent: Some(70.0),
resets_at: Some("2026-07-20T00:00:00Z".to_string()),
window_minutes: Some(10080),
used_count: None,
total_count: None,
regeneration: None,
};
let json = serde_json::to_string(&relaxed).unwrap();
assert!(json.contains("\"rawUsedPercent\":70.0"));
let back: RateWindow = serde_json::from_str(&json).unwrap();
assert_eq!(back, relaxed);
}
#[test]
fn healthy_entry_omits_error_and_degraded_entry_omits_usage() {
let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
let json = serde_json::to_string(&healthy).unwrap();
assert!(!json.contains("error"));
let degraded = ProviderUsage::degraded("codex", "no session");
let json = serde_json::to_string(°raded).unwrap();
assert!(json.contains("\"error\":\"no session\""));
assert!(!json.contains("usage"));
}
#[test]
fn api_provider_is_camel_case_present_when_set_and_omitted_when_absent() {
let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
let json = serde_json::to_string(&entry).unwrap();
assert!(
!json.contains("apiProvider"),
"absent api_provider must be omitted"
);
entry.api_provider = Some("openai".to_string());
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"apiProvider\":\"openai\""));
let back: ProviderUsage = serde_json::from_str(&json).unwrap();
assert_eq!(back, entry);
}
#[test]
fn used_count_and_total_count_are_camel_case_and_omitted_when_absent() {
let window = RateWindow {
used_percent: 25.8,
raw_used_percent: None,
resets_at: Some("2026-07-26T14:09:00Z".to_string()),
window_minutes: Some(10080),
used_count: None,
total_count: None,
regeneration: None,
};
let json = serde_json::to_string(&window).unwrap();
assert!(
!json.contains("usedCount"),
"absent used_count must be omitted"
);
assert!(
!json.contains("totalCount"),
"absent total_count must be omitted"
);
let enriched = RateWindow {
used_count: Some(10336.0),
total_count: Some(40000.0),
..window
};
let json = serde_json::to_string(&enriched).unwrap();
assert!(json.contains("\"usedCount\":10336.0"));
assert!(json.contains("\"totalCount\":40000.0"));
let back: RateWindow = serde_json::from_str(&json).unwrap();
assert_eq!(back, enriched);
}
#[test]
fn an_entry_without_a_class_serializes_as_it_did_before_the_field_existed() {
let entry = ProviderUsage::degraded("codex", "no session: nothing configured");
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("errorClass"), "absent class must be omitted");
assert!(json.contains("\"error\":\"no session: nothing configured\""));
let back: ProviderUsage = serde_json::from_str(&json).unwrap();
assert_eq!(back, entry);
assert_eq!(back.error_class, None);
}
#[test]
fn a_class_round_trips_under_its_camel_case_wire_name() {
let entry = ProviderUsage::degraded_with_class(
"gemini",
"credential unusable: gemini creds have no refresh_token",
"credential_unusable",
);
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"errorClass\":\"credential_unusable\""));
let back: ProviderUsage = serde_json::from_str(&json).unwrap();
assert_eq!(back, entry);
}
#[test]
fn an_unknown_class_decodes_rather_than_failing() {
let json = r#"{"provider":"someprovider","error":"something new","errorClass":"a_class_from_the_future"}"#;
let entry: ProviderUsage =
serde_json::from_str(json).expect("an unrecognised class must not fail to decode");
assert_eq!(
entry.error_class.as_deref(),
Some("a_class_from_the_future")
);
assert_eq!(entry.provider, "someprovider");
assert_eq!(entry.error.as_deref(), Some("something new"));
}
#[test]
fn an_entry_without_pools_does_not_mention_them() {
let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("spend"), "unexpected spend key: {json}");
}
#[test]
fn pools_round_trip_with_their_basis_and_funding() {
let pool = Pool {
id: "granted_balance".to_string(),
label: "Granted".to_string(),
funding: PoolFunding::Granted,
remaining: Some(Amount {
minor: 1050,
exponent: 2,
unit: "CNY".to_string(),
}),
total: None,
basis: PoolBasis::Reported,
spendable: Some(true),
};
let mut entry = ProviderUsage::healthy("deepseek", None, "api", Usage::default());
entry.spend = Some(vec![pool.clone()]);
let json = serde_json::to_string(&entry).unwrap();
let back: ProviderUsage = serde_json::from_str(&json).unwrap();
assert_eq!(back.spend, Some(vec![pool]));
assert!(json.contains(r#""funding":"granted""#), "{json}");
assert!(json.contains(r#""basis":"reported""#), "{json}");
assert!(json.contains(r#""minor":1050"#), "{json}");
assert!(
!json.contains("10.5"),
"an amount was rendered as a decimal: {json}"
);
}
#[test]
fn an_unknown_funding_kind_does_not_discard_the_entry() {
let json = r#"{
"provider": "minimax",
"usage": { "primary": { "usedPercent": 42.0 } },
"spend": [
{ "id": "a", "label": "A", "funding": "granted", "basis": "reported" },
{ "id": "b", "label": "B", "funding": "crypto_grant", "basis": "reported" }
]
}"#;
let entry: ProviderUsage = serde_json::from_str(json).expect("entry must survive");
let pools = entry.spend.expect("pools present");
assert_eq!(pools.len(), 2, "no pool may be dropped");
assert_eq!(pools[0].funding, PoolFunding::Granted);
assert_eq!(pools[1].funding, PoolFunding::Unknown);
assert_eq!(
entry.usage.and_then(|u| u.primary).map(|w| w.used_percent),
Some(42.0)
);
}
#[test]
fn an_unknown_basis_is_unstated_rather_than_exact() {
let json = r#"{ "id": "a", "label": "A", "funding": "granted",
"basis": "sampled_hourly" }"#;
let pool: Pool = serde_json::from_str(json).expect("pool must survive");
assert_eq!(pool.basis, PoolBasis::Unstated);
assert_ne!(
pool.basis,
PoolBasis::Reported,
"an unknown basis must never read as an exact remainder"
);
}
#[test]
fn a_healthy_entry_carries_no_class() {
let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
assert_eq!(entry.error_class, None);
assert!(!serde_json::to_string(&entry)
.unwrap()
.contains("errorClass"));
}
}