cortexkit_provider_usage/
lib.rs1use serde::{Deserialize, Serialize};
32
33#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
35#[serde(rename_all = "camelCase")]
36pub struct RateWindow {
37 pub used_percent: f64,
41 #[serde(skip_serializing_if = "Option::is_none", default)]
46 pub raw_used_percent: Option<f64>,
47 #[serde(skip_serializing_if = "Option::is_none")]
51 pub resets_at: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
55 pub window_minutes: Option<i64>,
56}
57
58#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
60#[serde(rename_all = "camelCase")]
61pub struct ExtraWindow {
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub title: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub id: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub window: Option<RateWindow>,
68}
69
70#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
73#[serde(rename_all = "camelCase")]
74pub struct Usage {
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub primary: Option<RateWindow>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub secondary: Option<RateWindow>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub tertiary: Option<RateWindow>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub extra_rate_windows: Option<Vec<ExtraWindow>>,
83}
84
85#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
87#[serde(rename_all = "camelCase")]
88pub struct AccountInfo {
89 #[serde(skip_serializing_if = "Option::is_none", default)]
90 pub email: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none", default)]
92 pub org_name: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none", default)]
94 pub plan_type: Option<String>,
95}
96
97impl AccountInfo {
98 pub fn is_empty(&self) -> bool {
99 self.email.is_none() && self.org_name.is_none() && self.plan_type.is_none()
100 }
101}
102
103#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
105#[serde(rename_all = "camelCase")]
106pub struct CreditExpiry {
107 pub expires_at: String,
108}
109
110#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
112#[serde(rename_all = "camelCase")]
113pub struct SavedResets {
114 #[serde(default)]
115 pub available_count: u32,
116 #[serde(skip_serializing_if = "Option::is_none", default)]
117 pub soonest_expires_at: Option<String>,
118 #[serde(default)]
119 pub credits: Vec<CreditExpiry>,
120}
121
122fn account_info_is_empty(value: &Option<AccountInfo>) -> bool {
123 value.as_ref().map(AccountInfo::is_empty).unwrap_or(true)
124}
125
126#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
130#[serde(rename_all = "camelCase")]
131pub struct ProviderUsage {
132 pub provider: String,
134 #[serde(skip_serializing_if = "Option::is_none", default)]
142 pub api_provider: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub account: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub source: Option<String>,
148 #[serde(skip_serializing_if = "account_info_is_empty", default)]
149 pub account_info: Option<AccountInfo>,
150 #[serde(skip_serializing_if = "Option::is_none", default)]
151 pub fetched_at: Option<String>,
152 #[serde(skip_serializing_if = "Option::is_none", default)]
153 pub saved_resets: Option<SavedResets>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub usage: Option<Usage>,
156 #[serde(skip_serializing_if = "Option::is_none")]
159 pub error: Option<String>,
160}
161
162impl ProviderUsage {
163 pub fn healthy(provider: &str, account: Option<String>, source: &str, usage: Usage) -> Self {
165 Self {
166 provider: provider.to_string(),
167 api_provider: None,
168 account,
169 source: Some(source.to_string()),
170 account_info: None,
171 fetched_at: None,
172 saved_resets: None,
173 usage: Some(usage),
174 error: None,
175 }
176 }
177
178 pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self {
181 Self {
182 provider: provider.to_string(),
183 api_provider: None,
184 account: None,
185 source: None,
186 account_info: None,
187 fetched_at: None,
188 saved_resets: None,
189 usage: None,
190 error: Some(error.to_string()),
191 }
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn account_info_is_omitted_when_empty_and_keeps_partial_labels() {
201 let bare = ProviderUsage::healthy(
202 "codex",
203 None,
204 "oauth",
205 Usage {
206 primary: Some(RateWindow {
207 used_percent: 10.0,
208 raw_used_percent: None,
209 resets_at: None,
210 window_minutes: Some(300),
211 }),
212 ..Default::default()
213 },
214 );
215 let json = serde_json::to_string(&bare).unwrap();
216 assert!(
217 !json.contains("accountInfo"),
218 "empty accountInfo must be omitted"
219 );
220
221 let mut labeled = bare.clone();
222 labeled.account_info = Some(AccountInfo {
223 email: Some("a@b.com".to_string()),
224 org_name: None,
225 plan_type: Some("pro".to_string()),
226 });
227 let json = serde_json::to_string(&labeled).unwrap();
228 assert!(json.contains("\"email\":\"a@b.com\""));
229 assert!(json.contains("\"planType\":\"pro\""));
230 assert!(!json.contains("orgName"), "absent orgName must be omitted");
231 }
232
233 #[test]
234 fn saved_resets_use_camel_case_and_round_trip() {
235 let entry = ProviderUsage {
236 saved_resets: Some(SavedResets {
237 available_count: 2,
238 soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()),
239 credits: vec![CreditExpiry {
240 expires_at: "2026-07-31T20:11:35Z".to_string(),
241 }],
242 }),
243 ..ProviderUsage::degraded("codex", "x")
244 };
245 let json = serde_json::to_string(&entry).unwrap();
246 assert!(json.contains("\"savedResets\""));
247 assert!(json.contains("\"availableCount\":2"));
248 assert!(json.contains("\"soonestExpiresAt\""));
249 let back: ProviderUsage = serde_json::from_str(&json).unwrap();
250 assert_eq!(back, entry);
251 }
252
253 #[test]
254 fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() {
255 let unrelaxed = RateWindow {
256 used_percent: 41.0,
257 raw_used_percent: None,
258 resets_at: Some("2026-07-20T00:00:00Z".to_string()),
259 window_minutes: Some(10080),
260 };
261 let json = serde_json::to_string(&unrelaxed).unwrap();
262 assert!(
263 !json.contains("rawUsedPercent"),
264 "unrelaxed window must not carry the field"
265 );
266
267 let relaxed = RateWindow {
268 used_percent: 0.0,
269 raw_used_percent: Some(70.0),
270 resets_at: Some("2026-07-20T00:00:00Z".to_string()),
271 window_minutes: Some(10080),
272 };
273 let json = serde_json::to_string(&relaxed).unwrap();
274 assert!(json.contains("\"rawUsedPercent\":70.0"));
275 let back: RateWindow = serde_json::from_str(&json).unwrap();
276 assert_eq!(back, relaxed);
277 }
278
279 #[test]
280 fn healthy_entry_omits_error_and_degraded_entry_omits_usage() {
281 let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
282 let json = serde_json::to_string(&healthy).unwrap();
283 assert!(!json.contains("error"));
284
285 let degraded = ProviderUsage::degraded("codex", "no session");
286 let json = serde_json::to_string(°raded).unwrap();
287 assert!(json.contains("\"error\":\"no session\""));
288 assert!(!json.contains("usage"));
289 }
290
291 #[test]
292 fn api_provider_is_camel_case_present_when_set_and_omitted_when_absent() {
293 let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
294 let json = serde_json::to_string(&entry).unwrap();
295 assert!(
296 !json.contains("apiProvider"),
297 "absent api_provider must be omitted"
298 );
299
300 entry.api_provider = Some("openai".to_string());
301 let json = serde_json::to_string(&entry).unwrap();
302 assert!(json.contains("\"apiProvider\":\"openai\""));
303 let back: ProviderUsage = serde_json::from_str(&json).unwrap();
304 assert_eq!(back, entry);
305 }
306}