1use serde::{Deserialize, Serialize};
33
34#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
36#[serde(rename_all = "camelCase")]
37pub struct RateWindow {
38 pub used_percent: f64,
42 #[serde(skip_serializing_if = "Option::is_none", default)]
47 pub raw_used_percent: Option<f64>,
48 #[serde(skip_serializing_if = "Option::is_none")]
52 pub resets_at: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
56 pub window_minutes: Option<i64>,
57 #[serde(skip_serializing_if = "Option::is_none", default)]
61 pub used_count: Option<f64>,
62 #[serde(skip_serializing_if = "Option::is_none", default)]
65 pub total_count: Option<f64>,
66}
67
68#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
70#[serde(rename_all = "camelCase")]
71pub struct ExtraWindow {
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub title: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub id: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub window: Option<RateWindow>,
78}
79
80#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
83#[serde(rename_all = "camelCase")]
84pub struct Usage {
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub primary: Option<RateWindow>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub secondary: Option<RateWindow>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub tertiary: Option<RateWindow>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub extra_rate_windows: Option<Vec<ExtraWindow>>,
93}
94
95#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
97#[serde(rename_all = "camelCase")]
98pub struct AccountInfo {
99 #[serde(skip_serializing_if = "Option::is_none", default)]
100 pub email: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none", default)]
102 pub org_name: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none", default)]
104 pub plan_type: Option<String>,
105}
106
107impl AccountInfo {
108 pub fn is_empty(&self) -> bool {
109 self.email.is_none() && self.org_name.is_none() && self.plan_type.is_none()
110 }
111}
112
113#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
115#[serde(rename_all = "camelCase")]
116pub struct CreditExpiry {
117 pub expires_at: String,
118}
119
120#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
122#[serde(rename_all = "camelCase")]
123pub struct SavedResets {
124 #[serde(default)]
125 pub available_count: u32,
126 #[serde(skip_serializing_if = "Option::is_none", default)]
127 pub soonest_expires_at: Option<String>,
128 #[serde(default)]
129 pub credits: Vec<CreditExpiry>,
130}
131
132fn account_info_is_empty(value: &Option<AccountInfo>) -> bool {
133 value.as_ref().map(AccountInfo::is_empty).unwrap_or(true)
134}
135
136#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
140#[serde(rename_all = "camelCase")]
141pub struct ProviderUsage {
142 pub provider: String,
144 #[serde(skip_serializing_if = "Option::is_none", default)]
152 pub api_provider: Option<String>,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub account: Option<String>,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub source: Option<String>,
158 #[serde(skip_serializing_if = "account_info_is_empty", default)]
159 pub account_info: Option<AccountInfo>,
160 #[serde(skip_serializing_if = "Option::is_none", default)]
161 pub fetched_at: Option<String>,
162 #[serde(skip_serializing_if = "Option::is_none", default)]
163 pub saved_resets: Option<SavedResets>,
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub usage: Option<Usage>,
166 #[serde(skip_serializing_if = "Option::is_none")]
169 pub error: Option<String>,
170 #[serde(skip_serializing_if = "Option::is_none", default)]
201 pub error_class: Option<String>,
202}
203
204impl ProviderUsage {
205 pub fn healthy(provider: &str, account: Option<String>, source: &str, usage: Usage) -> Self {
207 Self {
208 provider: provider.to_string(),
209 api_provider: None,
210 account,
211 source: Some(source.to_string()),
212 account_info: None,
213 fetched_at: None,
214 saved_resets: None,
215 usage: Some(usage),
216 error: None,
217 error_class: None,
218 }
219 }
220
221 pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self {
224 Self {
225 provider: provider.to_string(),
226 api_provider: None,
227 account: None,
228 source: None,
229 account_info: None,
230 fetched_at: None,
231 saved_resets: None,
232 usage: None,
233 error: Some(error.to_string()),
234 error_class: None,
235 }
236 }
237
238 pub fn degraded_with_class(
246 provider: &str,
247 error: impl std::fmt::Display,
248 error_class: impl Into<String>,
249 ) -> Self {
250 Self {
251 error_class: Some(error_class.into()),
252 ..Self::degraded(provider, error)
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn account_info_is_omitted_when_empty_and_keeps_partial_labels() {
263 let bare = ProviderUsage::healthy(
264 "codex",
265 None,
266 "oauth",
267 Usage {
268 primary: Some(RateWindow {
269 used_percent: 10.0,
270 raw_used_percent: None,
271 resets_at: None,
272 window_minutes: Some(300),
273 used_count: None,
274 total_count: None,
275 }),
276 ..Default::default()
277 },
278 );
279 let json = serde_json::to_string(&bare).unwrap();
280 assert!(
281 !json.contains("accountInfo"),
282 "empty accountInfo must be omitted"
283 );
284
285 let mut labeled = bare.clone();
286 labeled.account_info = Some(AccountInfo {
287 email: Some("a@b.com".to_string()),
288 org_name: None,
289 plan_type: Some("pro".to_string()),
290 });
291 let json = serde_json::to_string(&labeled).unwrap();
292 assert!(json.contains("\"email\":\"a@b.com\""));
293 assert!(json.contains("\"planType\":\"pro\""));
294 assert!(!json.contains("orgName"), "absent orgName must be omitted");
295 }
296
297 #[test]
298 fn saved_resets_use_camel_case_and_round_trip() {
299 let entry = ProviderUsage {
300 saved_resets: Some(SavedResets {
301 available_count: 2,
302 soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()),
303 credits: vec![CreditExpiry {
304 expires_at: "2026-07-31T20:11:35Z".to_string(),
305 }],
306 }),
307 ..ProviderUsage::degraded("codex", "x")
308 };
309 let json = serde_json::to_string(&entry).unwrap();
310 assert!(json.contains("\"savedResets\""));
311 assert!(json.contains("\"availableCount\":2"));
312 assert!(json.contains("\"soonestExpiresAt\""));
313 let back: ProviderUsage = serde_json::from_str(&json).unwrap();
314 assert_eq!(back, entry);
315 }
316
317 #[test]
318 fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() {
319 let unrelaxed = RateWindow {
320 used_percent: 41.0,
321 raw_used_percent: None,
322 resets_at: Some("2026-07-20T00:00:00Z".to_string()),
323 window_minutes: Some(10080),
324 used_count: None,
325 total_count: None,
326 };
327 let json = serde_json::to_string(&unrelaxed).unwrap();
328 assert!(
329 !json.contains("rawUsedPercent"),
330 "unrelaxed window must not carry the field"
331 );
332
333 let relaxed = RateWindow {
334 used_percent: 0.0,
335 raw_used_percent: Some(70.0),
336 resets_at: Some("2026-07-20T00:00:00Z".to_string()),
337 window_minutes: Some(10080),
338 used_count: None,
339 total_count: None,
340 };
341 let json = serde_json::to_string(&relaxed).unwrap();
342 assert!(json.contains("\"rawUsedPercent\":70.0"));
343 let back: RateWindow = serde_json::from_str(&json).unwrap();
344 assert_eq!(back, relaxed);
345 }
346
347 #[test]
348 fn healthy_entry_omits_error_and_degraded_entry_omits_usage() {
349 let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
350 let json = serde_json::to_string(&healthy).unwrap();
351 assert!(!json.contains("error"));
352
353 let degraded = ProviderUsage::degraded("codex", "no session");
354 let json = serde_json::to_string(°raded).unwrap();
355 assert!(json.contains("\"error\":\"no session\""));
356 assert!(!json.contains("usage"));
357 }
358
359 #[test]
360 fn api_provider_is_camel_case_present_when_set_and_omitted_when_absent() {
361 let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
362 let json = serde_json::to_string(&entry).unwrap();
363 assert!(
364 !json.contains("apiProvider"),
365 "absent api_provider must be omitted"
366 );
367
368 entry.api_provider = Some("openai".to_string());
369 let json = serde_json::to_string(&entry).unwrap();
370 assert!(json.contains("\"apiProvider\":\"openai\""));
371 let back: ProviderUsage = serde_json::from_str(&json).unwrap();
372 assert_eq!(back, entry);
373 }
374
375 #[test]
376 fn used_count_and_total_count_are_camel_case_and_omitted_when_absent() {
377 let window = RateWindow {
378 used_percent: 25.8,
379 raw_used_percent: None,
380 resets_at: Some("2026-07-26T14:09:00Z".to_string()),
381 window_minutes: Some(10080),
382 used_count: None,
383 total_count: None,
384 };
385 let json = serde_json::to_string(&window).unwrap();
386 assert!(
387 !json.contains("usedCount"),
388 "absent used_count must be omitted"
389 );
390 assert!(
391 !json.contains("totalCount"),
392 "absent total_count must be omitted"
393 );
394
395 let enriched = RateWindow {
396 used_count: Some(10336.0),
397 total_count: Some(40000.0),
398 ..window
399 };
400 let json = serde_json::to_string(&enriched).unwrap();
401 assert!(json.contains("\"usedCount\":10336.0"));
402 assert!(json.contains("\"totalCount\":40000.0"));
403 let back: RateWindow = serde_json::from_str(&json).unwrap();
404 assert_eq!(back, enriched);
405 }
406
407 #[test]
410 fn an_entry_without_a_class_serializes_as_it_did_before_the_field_existed() {
411 let entry = ProviderUsage::degraded("codex", "no session: nothing configured");
412 let json = serde_json::to_string(&entry).unwrap();
413
414 assert!(!json.contains("errorClass"), "absent class must be omitted");
415 assert!(json.contains("\"error\":\"no session: nothing configured\""));
418
419 let back: ProviderUsage = serde_json::from_str(&json).unwrap();
420 assert_eq!(back, entry);
421 assert_eq!(back.error_class, None);
422 }
423
424 #[test]
425 fn a_class_round_trips_under_its_camel_case_wire_name() {
426 let entry = ProviderUsage::degraded_with_class(
427 "gemini",
428 "credential unusable: gemini creds have no refresh_token",
429 "credential_unusable",
430 );
431 let json = serde_json::to_string(&entry).unwrap();
432
433 assert!(json.contains("\"errorClass\":\"credential_unusable\""));
434 let back: ProviderUsage = serde_json::from_str(&json).unwrap();
435 assert_eq!(back, entry);
436 }
437
438 #[test]
444 fn an_unknown_class_decodes_rather_than_failing() {
445 let json = r#"{"provider":"someprovider","error":"something new","errorClass":"a_class_from_the_future"}"#;
446
447 let entry: ProviderUsage =
448 serde_json::from_str(json).expect("an unrecognised class must not fail to decode");
449
450 assert_eq!(
451 entry.error_class.as_deref(),
452 Some("a_class_from_the_future")
453 );
454 assert_eq!(entry.provider, "someprovider");
457 assert_eq!(entry.error.as_deref(), Some("something new"));
458 }
459
460 #[test]
464 fn a_healthy_entry_carries_no_class() {
465 let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
466 assert_eq!(entry.error_class, None);
467 assert!(!serde_json::to_string(&entry)
468 .unwrap()
469 .contains("errorClass"));
470 }
471}