cortexkit_provider_usage/
lib.rs1use 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}
171
172impl ProviderUsage {
173 pub fn healthy(provider: &str, account: Option<String>, source: &str, usage: Usage) -> Self {
175 Self {
176 provider: provider.to_string(),
177 api_provider: None,
178 account,
179 source: Some(source.to_string()),
180 account_info: None,
181 fetched_at: None,
182 saved_resets: None,
183 usage: Some(usage),
184 error: None,
185 }
186 }
187
188 pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self {
191 Self {
192 provider: provider.to_string(),
193 api_provider: None,
194 account: None,
195 source: None,
196 account_info: None,
197 fetched_at: None,
198 saved_resets: None,
199 usage: None,
200 error: Some(error.to_string()),
201 }
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn account_info_is_omitted_when_empty_and_keeps_partial_labels() {
211 let bare = ProviderUsage::healthy(
212 "codex",
213 None,
214 "oauth",
215 Usage {
216 primary: Some(RateWindow {
217 used_percent: 10.0,
218 raw_used_percent: None,
219 resets_at: None,
220 window_minutes: Some(300),
221 used_count: None,
222 total_count: None,
223 }),
224 ..Default::default()
225 },
226 );
227 let json = serde_json::to_string(&bare).unwrap();
228 assert!(
229 !json.contains("accountInfo"),
230 "empty accountInfo must be omitted"
231 );
232
233 let mut labeled = bare.clone();
234 labeled.account_info = Some(AccountInfo {
235 email: Some("a@b.com".to_string()),
236 org_name: None,
237 plan_type: Some("pro".to_string()),
238 });
239 let json = serde_json::to_string(&labeled).unwrap();
240 assert!(json.contains("\"email\":\"a@b.com\""));
241 assert!(json.contains("\"planType\":\"pro\""));
242 assert!(!json.contains("orgName"), "absent orgName must be omitted");
243 }
244
245 #[test]
246 fn saved_resets_use_camel_case_and_round_trip() {
247 let entry = ProviderUsage {
248 saved_resets: Some(SavedResets {
249 available_count: 2,
250 soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()),
251 credits: vec![CreditExpiry {
252 expires_at: "2026-07-31T20:11:35Z".to_string(),
253 }],
254 }),
255 ..ProviderUsage::degraded("codex", "x")
256 };
257 let json = serde_json::to_string(&entry).unwrap();
258 assert!(json.contains("\"savedResets\""));
259 assert!(json.contains("\"availableCount\":2"));
260 assert!(json.contains("\"soonestExpiresAt\""));
261 let back: ProviderUsage = serde_json::from_str(&json).unwrap();
262 assert_eq!(back, entry);
263 }
264
265 #[test]
266 fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() {
267 let unrelaxed = RateWindow {
268 used_percent: 41.0,
269 raw_used_percent: None,
270 resets_at: Some("2026-07-20T00:00:00Z".to_string()),
271 window_minutes: Some(10080),
272 used_count: None,
273 total_count: None,
274 };
275 let json = serde_json::to_string(&unrelaxed).unwrap();
276 assert!(
277 !json.contains("rawUsedPercent"),
278 "unrelaxed window must not carry the field"
279 );
280
281 let relaxed = RateWindow {
282 used_percent: 0.0,
283 raw_used_percent: Some(70.0),
284 resets_at: Some("2026-07-20T00:00:00Z".to_string()),
285 window_minutes: Some(10080),
286 used_count: None,
287 total_count: None,
288 };
289 let json = serde_json::to_string(&relaxed).unwrap();
290 assert!(json.contains("\"rawUsedPercent\":70.0"));
291 let back: RateWindow = serde_json::from_str(&json).unwrap();
292 assert_eq!(back, relaxed);
293 }
294
295 #[test]
296 fn healthy_entry_omits_error_and_degraded_entry_omits_usage() {
297 let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
298 let json = serde_json::to_string(&healthy).unwrap();
299 assert!(!json.contains("error"));
300
301 let degraded = ProviderUsage::degraded("codex", "no session");
302 let json = serde_json::to_string(°raded).unwrap();
303 assert!(json.contains("\"error\":\"no session\""));
304 assert!(!json.contains("usage"));
305 }
306
307 #[test]
308 fn api_provider_is_camel_case_present_when_set_and_omitted_when_absent() {
309 let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
310 let json = serde_json::to_string(&entry).unwrap();
311 assert!(
312 !json.contains("apiProvider"),
313 "absent api_provider must be omitted"
314 );
315
316 entry.api_provider = Some("openai".to_string());
317 let json = serde_json::to_string(&entry).unwrap();
318 assert!(json.contains("\"apiProvider\":\"openai\""));
319 let back: ProviderUsage = serde_json::from_str(&json).unwrap();
320 assert_eq!(back, entry);
321 }
322
323 #[test]
324 fn used_count_and_total_count_are_camel_case_and_omitted_when_absent() {
325 let window = RateWindow {
326 used_percent: 25.8,
327 raw_used_percent: None,
328 resets_at: Some("2026-07-26T14:09:00Z".to_string()),
329 window_minutes: Some(10080),
330 used_count: None,
331 total_count: None,
332 };
333 let json = serde_json::to_string(&window).unwrap();
334 assert!(
335 !json.contains("usedCount"),
336 "absent used_count must be omitted"
337 );
338 assert!(
339 !json.contains("totalCount"),
340 "absent total_count must be omitted"
341 );
342
343 let enriched = RateWindow {
344 used_count: Some(10336.0),
345 total_count: Some(40000.0),
346 ..window
347 };
348 let json = serde_json::to_string(&enriched).unwrap();
349 assert!(json.contains("\"usedCount\":10336.0"));
350 assert!(json.contains("\"totalCount\":40000.0"));
351 let back: RateWindow = serde_json::from_str(&json).unwrap();
352 assert_eq!(back, enriched);
353 }
354}