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>,
}
#[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, 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")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub error_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),
error: None,
error_class: 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,
error: Some(error.to_string()),
error_class: 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 {
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,
}),
..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,
};
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,
};
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,
};
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 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"));
}
}