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")]
135 pub account: Option<String>,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub source: Option<String>,
139 #[serde(skip_serializing_if = "account_info_is_empty", default)]
140 pub account_info: Option<AccountInfo>,
141 #[serde(skip_serializing_if = "Option::is_none", default)]
142 pub fetched_at: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none", default)]
144 pub saved_resets: Option<SavedResets>,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub usage: Option<Usage>,
147 #[serde(skip_serializing_if = "Option::is_none")]
150 pub error: Option<String>,
151}
152
153impl ProviderUsage {
154 pub fn healthy(provider: &str, account: Option<String>, source: &str, usage: Usage) -> Self {
156 Self {
157 provider: provider.to_string(),
158 account,
159 source: Some(source.to_string()),
160 account_info: None,
161 fetched_at: None,
162 saved_resets: None,
163 usage: Some(usage),
164 error: None,
165 }
166 }
167
168 pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self {
171 Self {
172 provider: provider.to_string(),
173 account: None,
174 source: None,
175 account_info: None,
176 fetched_at: None,
177 saved_resets: None,
178 usage: None,
179 error: Some(error.to_string()),
180 }
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn account_info_is_omitted_when_empty_and_keeps_partial_labels() {
190 let bare = ProviderUsage::healthy(
191 "codex",
192 None,
193 "oauth",
194 Usage {
195 primary: Some(RateWindow {
196 used_percent: 10.0,
197 raw_used_percent: None,
198 resets_at: None,
199 window_minutes: Some(300),
200 }),
201 ..Default::default()
202 },
203 );
204 let json = serde_json::to_string(&bare).unwrap();
205 assert!(
206 !json.contains("accountInfo"),
207 "empty accountInfo must be omitted"
208 );
209
210 let mut labeled = bare.clone();
211 labeled.account_info = Some(AccountInfo {
212 email: Some("a@b.com".to_string()),
213 org_name: None,
214 plan_type: Some("pro".to_string()),
215 });
216 let json = serde_json::to_string(&labeled).unwrap();
217 assert!(json.contains("\"email\":\"a@b.com\""));
218 assert!(json.contains("\"planType\":\"pro\""));
219 assert!(!json.contains("orgName"), "absent orgName must be omitted");
220 }
221
222 #[test]
223 fn saved_resets_use_camel_case_and_round_trip() {
224 let entry = ProviderUsage {
225 saved_resets: Some(SavedResets {
226 available_count: 2,
227 soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()),
228 credits: vec![CreditExpiry {
229 expires_at: "2026-07-31T20:11:35Z".to_string(),
230 }],
231 }),
232 ..ProviderUsage::degraded("codex", "x")
233 };
234 let json = serde_json::to_string(&entry).unwrap();
235 assert!(json.contains("\"savedResets\""));
236 assert!(json.contains("\"availableCount\":2"));
237 assert!(json.contains("\"soonestExpiresAt\""));
238 let back: ProviderUsage = serde_json::from_str(&json).unwrap();
239 assert_eq!(back, entry);
240 }
241
242 #[test]
243 fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() {
244 let unrelaxed = RateWindow {
245 used_percent: 41.0,
246 raw_used_percent: None,
247 resets_at: Some("2026-07-20T00:00:00Z".to_string()),
248 window_minutes: Some(10080),
249 };
250 let json = serde_json::to_string(&unrelaxed).unwrap();
251 assert!(
252 !json.contains("rawUsedPercent"),
253 "unrelaxed window must not carry the field"
254 );
255
256 let relaxed = RateWindow {
257 used_percent: 0.0,
258 raw_used_percent: Some(70.0),
259 resets_at: Some("2026-07-20T00:00:00Z".to_string()),
260 window_minutes: Some(10080),
261 };
262 let json = serde_json::to_string(&relaxed).unwrap();
263 assert!(json.contains("\"rawUsedPercent\":70.0"));
264 let back: RateWindow = serde_json::from_str(&json).unwrap();
265 assert_eq!(back, relaxed);
266 }
267
268 #[test]
269 fn healthy_entry_omits_error_and_degraded_entry_omits_usage() {
270 let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
271 let json = serde_json::to_string(&healthy).unwrap();
272 assert!(!json.contains("error"));
273
274 let degraded = ProviderUsage::degraded("codex", "no session");
275 let json = serde_json::to_string(°raded).unwrap();
276 assert!(json.contains("\"error\":\"no session\""));
277 assert!(!json.contains("usage"));
278 }
279}