1use serde::Deserialize;
21
22use crate::usage::{OpenAiCredits, OpenAiSnapshot, OpenAiSource, UsageWindow};
23
24#[derive(Debug, Default, Clone, Deserialize)]
25#[serde(default)]
26pub struct UsageResponse {
27 pub plan_type: Option<String>,
28 pub rate_limit: Option<RateLimit>,
29 pub code_review_rate_limit: Option<RateLimit>,
30 pub credits: Option<CreditsBlock>,
31}
32
33#[derive(Debug, Default, Clone, Deserialize)]
34#[serde(default)]
35pub struct RateLimit {
36 pub primary_window: Option<Window>,
37 pub secondary_window: Option<Window>,
38}
39
40#[derive(Debug, Default, Clone, Deserialize)]
41#[serde(default)]
42pub struct Window {
43 #[serde(deserialize_with = "de_int_or_float_lenient")]
44 pub used_percent: i64,
45 #[serde(deserialize_with = "de_int_or_float_lenient")]
46 pub limit_window_seconds: i64,
47 #[serde(default, deserialize_with = "de_opt_int_or_float")]
49 pub reset_at: Option<i64>,
50 #[serde(default, deserialize_with = "de_opt_int_or_float")]
52 pub reset_after_seconds: Option<i64>,
53}
54
55#[derive(Debug, Default, Clone, Deserialize)]
56#[serde(default)]
57pub struct CreditsBlock {
58 #[serde(deserialize_with = "de_money_string")]
59 pub balance: String,
60 pub has_credits: bool,
61 pub unlimited: bool,
62 #[serde(default)]
63 pub approx_local_messages: Option<Vec<i64>>,
64 #[serde(default)]
65 pub approx_cloud_messages: Option<Vec<i64>>,
66}
67
68fn de_int_or_float_lenient<'de, D>(d: D) -> Result<i64, D::Error>
69where
70 D: serde::Deserializer<'de>,
71{
72 let v = serde_json::Value::deserialize(d)?;
73 Ok(match v {
74 serde_json::Value::Null => 0,
75 serde_json::Value::Number(n) => n
76 .as_i64()
77 .or_else(|| n.as_f64().map(|f| f as i64))
78 .unwrap_or(0),
79 _ => 0,
80 })
81}
82
83fn de_opt_int_or_float<'de, D>(d: D) -> Result<Option<i64>, D::Error>
84where
85 D: serde::Deserializer<'de>,
86{
87 let v = serde_json::Value::deserialize(d)?;
88 Ok(match v {
89 serde_json::Value::Null => None,
90 serde_json::Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
91 _ => None,
92 })
93}
94
95fn de_money_string<'de, D>(d: D) -> Result<String, D::Error>
97where
98 D: serde::Deserializer<'de>,
99{
100 let v = serde_json::Value::deserialize(d)?;
101 Ok(match v {
102 serde_json::Value::String(s) => s,
103 serde_json::Value::Number(n) => format!("${:.2}", n.as_f64().unwrap_or(0.0)),
104 _ => "$0.00".to_string(),
105 })
106}
107
108impl UsageResponse {
109 pub fn into_snapshot(self, plan_hint: Option<&str>) -> OpenAiSnapshot {
110 let plan_type = self.plan_type.as_deref().or(plan_hint).unwrap_or("Unknown");
111 let plan = format!("ChatGPT {}", capitalize(plan_type));
112
113 let rl = self.rate_limit.unwrap_or_default();
114 let session = window_or_default(rl.primary_window, chrono::Duration::hours(5));
115 let weekly = window_or_default(rl.secondary_window, chrono::Duration::days(7));
116 let code_review = self
117 .code_review_rate_limit
118 .and_then(|c| c.primary_window)
119 .map(|w| to_window(&w, chrono::Duration::days(7)));
120
121 let credits = self.credits.map(|c| OpenAiCredits {
122 balance: c.balance,
123 has_credits: c.has_credits,
124 unlimited: c.unlimited,
125 approx_local_messages: range_from_vec(c.approx_local_messages),
126 approx_cloud_messages: range_from_vec(c.approx_cloud_messages),
127 });
128
129 OpenAiSnapshot {
130 plan,
131 session,
132 weekly,
133 code_review,
134 credits,
135 source: OpenAiSource::CodexOauth,
136 }
137 }
138}
139
140fn window_or_default(w: Option<Window>, default_dur: chrono::Duration) -> UsageWindow {
141 let Some(w) = w else {
142 return UsageWindow {
143 utilization_pct: 0,
144 resets_at: None,
145 window_duration: default_dur,
146 };
147 };
148 to_window(&w, default_dur)
149}
150
151fn to_window(w: &Window, default_dur: chrono::Duration) -> UsageWindow {
152 let dur = if w.limit_window_seconds > 0 {
153 chrono::Duration::seconds(w.limit_window_seconds)
154 } else {
155 default_dur
156 };
157 let resets_at = match w.reset_at {
158 Some(secs) => chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0),
159 None => w
160 .reset_after_seconds
161 .map(|s| chrono::Utc::now() + chrono::Duration::seconds(s)),
162 };
163 UsageWindow {
164 utilization_pct: (w.used_percent as i32).clamp(0, 100),
165 resets_at,
166 window_duration: dur,
167 }
168}
169
170fn range_from_vec(v: Option<Vec<i64>>) -> Option<(i64, i64)> {
171 let v = v?;
172 if v.len() >= 2 {
173 Some((v[0], v[1]))
174 } else if v.len() == 1 {
175 Some((v[0], v[0]))
176 } else {
177 None
178 }
179}
180
181fn capitalize(s: &str) -> String {
182 let mut chars = s.chars();
183 match chars.next() {
184 Some(c) => {
185 let mut out = String::with_capacity(s.len());
186 for u in c.to_uppercase() {
187 out.push(u);
188 }
189 out.push_str(chars.as_str());
190 out
191 }
192 None => String::new(),
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 const REAL: &str = r#"{
201 "user_id":"u","account_id":"a","email":"e",
202 "plan_type":"plus",
203 "rate_limit":{"allowed":true,"limit_reached":false,
204 "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_after_seconds":18000,"reset_at":1779597324},
205 "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_after_seconds":604800,"reset_at":1780184124}
206 }
207 }"#;
208
209 #[test]
210 fn parses_real_shape() {
211 let r: UsageResponse = serde_json::from_str(REAL).unwrap();
212 let s = r.into_snapshot(None);
213 assert_eq!(s.plan, "ChatGPT Plus");
214 assert_eq!(s.session.utilization_pct, 1);
215 assert_eq!(s.weekly.utilization_pct, 0);
216 assert_eq!(s.session.window_duration, chrono::Duration::hours(5));
217 assert_eq!(s.weekly.window_duration, chrono::Duration::days(7));
218 assert!(s.session.resets_at.is_some());
219 assert!(s.code_review.is_none());
220 assert!(s.credits.is_none());
221 assert!(matches!(s.source, OpenAiSource::CodexOauth));
222 }
223
224 #[test]
225 fn missing_rate_limit_yields_neutral() {
226 let r: UsageResponse = serde_json::from_str(r#"{"plan_type":"pro"}"#).unwrap();
227 let s = r.into_snapshot(None);
228 assert_eq!(s.plan, "ChatGPT Pro");
229 assert_eq!(s.session.utilization_pct, 0);
230 assert_eq!(s.weekly.utilization_pct, 0);
231 }
232
233 #[test]
234 fn credits_block_parses_with_message_ranges() {
235 let body = r#"{
236 "plan_type":"plus",
237 "credits":{"balance":"$2.50","has_credits":true,"unlimited":false,
238 "approx_local_messages":[100,200],"approx_cloud_messages":[40,60]}
239 }"#;
240 let r: UsageResponse = serde_json::from_str(body).unwrap();
241 let s = r.into_snapshot(None);
242 let c = s.credits.unwrap();
243 assert_eq!(c.balance, "$2.50");
244 assert!(c.has_credits);
245 assert_eq!(c.approx_local_messages, Some((100, 200)));
246 assert_eq!(c.approx_cloud_messages, Some((40, 60)));
247 }
248
249 #[test]
250 fn balance_as_number_formats_to_dollars() {
251 let body = r#"{"credits":{"balance":1.5,"has_credits":true,"unlimited":false}}"#;
252 let r: UsageResponse = serde_json::from_str(body).unwrap();
253 let s = r.into_snapshot(None);
254 assert_eq!(s.credits.unwrap().balance, "$1.50");
255 }
256
257 #[test]
258 fn used_percent_clamps_to_hundred() {
259 let body =
260 r#"{"rate_limit":{"primary_window":{"used_percent":250,"limit_window_seconds":1}}}"#;
261 let r: UsageResponse = serde_json::from_str(body).unwrap();
262 let s = r.into_snapshot(None);
263 assert_eq!(s.session.utilization_pct, 100);
264 }
265
266 #[test]
267 fn plan_hint_used_when_response_omits_plan_type() {
268 let r: UsageResponse = serde_json::from_str("{}").unwrap();
269 let s = r.into_snapshot(Some("team"));
270 assert_eq!(s.plan, "ChatGPT Team");
271 }
272
273 #[test]
274 fn missing_reset_at_falls_back_to_after_seconds() {
275 let body = r#"{"rate_limit":{"primary_window":{
276 "used_percent":50,"limit_window_seconds":1000,"reset_after_seconds":500
277 }}}"#;
278 let r: UsageResponse = serde_json::from_str(body).unwrap();
279 let s = r.into_snapshot(None);
280 let now = chrono::Utc::now();
282 let reset = s.session.resets_at.unwrap();
283 let delta = reset.signed_duration_since(now).num_seconds();
284 assert!((400..=600).contains(&delta), "got delta={delta}");
285 }
286}