1pub mod cache;
13pub mod claude;
14pub mod codex_session;
15pub mod copilot;
16pub mod cursor;
17pub mod grok;
18pub mod http;
19pub mod provider;
20pub mod refresh;
21pub mod wham;
22
23pub use cache::{
24 load_claude_cache, load_codex_cache, load_copilot_cache, load_cursor_cache, load_grok_cache,
25 save_claude_cache, save_codex_cache, save_copilot_cache, save_cursor_cache, save_grok_cache,
26};
27pub use claude::{CLAUDE_LOGIN_HINT, ClaudeState};
28pub use copilot::{COPILOT_LOGIN_HINT, CopilotState};
29pub use cursor::{CURSOR_LOGIN_HINT, CursorState};
30pub use grok::{GROK_LOGIN_HINT, GrokState};
31pub use http::enable_cli_version_detection;
32pub use provider::{QuotaOutcome, QuotaSnapshot, spawn_quota_worker};
33
34use crate::models::CodexQuotaSnapshot;
35use crate::quota::refresh::{RefreshCooldown, file_mtime};
36use crate::quota::wham::{ResetCreditsResult, WhamResult};
37use std::time::SystemTime;
38
39pub const CODEX_LOGIN_HINT: &str = "run: codex auth login";
41
42enum CodexFetch {
44 Ok(CodexQuotaSnapshot),
45 NeedsLogin,
47 Transient,
49}
50
51#[derive(Default)]
57pub struct CodexState {
58 token: Option<(String, Option<SystemTime>)>,
61 cooldown: RefreshCooldown,
62 reset_credits_cooldown: RefreshCooldown,
65}
66
67impl CodexState {
68 pub fn resolve(
70 &mut self,
71 client: &reqwest::blocking::Client,
72 ) -> QuotaOutcome<CodexQuotaSnapshot> {
73 let now = chrono::Local::now().timestamp();
74 let auth = crate::utils::resolve_paths()
75 .ok()
76 .map(|p| p.codex_dir.join("auth.json"));
77
78 if let Some(auth) = &auth
79 && auth.exists()
80 {
81 match self.fetch_with_refresh(
82 client,
83 auth,
84 now,
85 wham::WHAM_URL,
86 wham::WHAM_RESET_CREDITS_URL,
87 wham::CODEX_TOKEN_URL,
88 ) {
89 CodexFetch::Ok(snap) => return QuotaOutcome::Data(snap),
90 CodexFetch::NeedsLogin => {
91 let mut snap = codex_session::latest_session_rate_limits()
93 .ok()
94 .flatten()
95 .unwrap_or_default();
96 snap.needs_login = true;
97 snap.fetched_at = now;
98 return QuotaOutcome::Data(snap);
99 }
100 CodexFetch::Transient => { }
101 }
102 }
103
104 match codex_session::latest_session_rate_limits() {
105 Ok(Some(snap)) => QuotaOutcome::Data(snap),
106 _ => QuotaOutcome::Transient,
107 }
108 }
109
110 fn fetch_with_refresh(
116 &mut self,
117 client: &reqwest::blocking::Client,
118 auth: &std::path::Path,
119 now: i64,
120 wham_url: &str,
121 reset_credits_url: &str,
122 token_url: &str,
123 ) -> CodexFetch {
124 let body = match std::fs::read_to_string(auth) {
125 Ok(b) => b,
126 Err(e) => {
127 log::warn!("codex quota: failed to read {}: {e}", auth.display());
128 return CodexFetch::Transient;
129 }
130 };
131 let (file_token, account_id) = match wham::parse_auth(&body) {
132 Ok(x) => x,
133 Err(e) => {
134 log::warn!("codex quota: failed to parse auth.json: {e}");
135 return CodexFetch::Transient;
136 }
137 };
138 let cur_mtime = file_mtime(auth);
139 let token = match &self.token {
142 Some((t, m)) if *m == cur_mtime => t.clone(),
143 _ => file_token,
144 };
145
146 match wham::call_wham(client, &token, account_id.as_deref(), now, wham_url) {
147 WhamResult::Ok(snap) => self.finish_with_reset_credits(
148 client,
149 auth,
150 snap,
151 token,
152 cur_mtime,
153 account_id.as_deref(),
154 now,
155 reset_credits_url,
156 token_url,
157 true,
158 ),
159 WhamResult::Unauthorized => {
160 if self.cooldown.active(now, cur_mtime) {
161 self.token = None;
162 return CodexFetch::NeedsLogin;
163 }
164 match wham::refresh_codex(client, auth, token_url) {
165 Ok(new_tok) => {
166 self.cooldown.clear();
167 let post_mtime = file_mtime(auth);
171 self.token = Some((new_tok.clone(), post_mtime));
172 match wham::call_wham(
173 client,
174 &new_tok,
175 account_id.as_deref(),
176 now,
177 wham_url,
178 ) {
179 WhamResult::Ok(snap) => self.finish_with_reset_credits(
180 client,
181 auth,
182 snap,
183 new_tok,
184 post_mtime,
185 account_id.as_deref(),
186 now,
187 reset_credits_url,
188 token_url,
189 false,
190 ),
191 WhamResult::Transient => CodexFetch::Transient,
194 WhamResult::Unauthorized => {
195 self.cooldown.arm(now, post_mtime);
196 self.token = None;
197 CodexFetch::NeedsLogin
198 }
199 }
200 }
201 Err(e) => {
202 log::warn!("codex token refresh failed: {e}");
203 self.cooldown.arm(now, file_mtime(auth));
204 self.token = None;
205 CodexFetch::NeedsLogin
206 }
207 }
208 }
209 WhamResult::Transient => CodexFetch::Transient,
210 }
211 }
212
213 #[allow(clippy::too_many_arguments)]
216 fn finish_with_reset_credits(
217 &mut self,
218 client: &reqwest::blocking::Client,
219 auth: &std::path::Path,
220 mut snap: CodexQuotaSnapshot,
221 token: String,
222 token_mtime: Option<SystemTime>,
223 account_id: Option<&str>,
224 now: i64,
225 reset_credits_url: &str,
226 token_url: &str,
227 refresh_on_unauthorized: bool,
228 ) -> CodexFetch {
229 self.cooldown.clear();
230 self.token = Some((token.clone(), token_mtime));
231
232 match wham::call_reset_credit_details(client, &token, account_id, reset_credits_url) {
233 ResetCreditsResult::Ok {
234 available_count,
235 expirations,
236 } => {
237 self.reset_credits_cooldown.clear();
238 snap.reset_credits_available = Some(available_count);
239 snap.reset_credit_expirations = Some(expirations);
240 }
241 ResetCreditsResult::Transient => {}
242 ResetCreditsResult::Unauthorized => {
243 if !refresh_on_unauthorized {
244 self.reset_credits_cooldown.arm(now, token_mtime);
245 return CodexFetch::Ok(snap);
246 }
247 if self.reset_credits_cooldown.active(now, token_mtime) {
248 return CodexFetch::Ok(snap);
249 }
250
251 let new_token = match wham::refresh_codex(client, auth, token_url) {
252 Ok(new_token) => new_token,
253 Err(e) => {
254 log::warn!("codex token refresh for reset-credit details failed: {e}");
255 self.reset_credits_cooldown.arm(now, file_mtime(auth));
256 return CodexFetch::Ok(snap);
257 }
258 };
259 let post_mtime = file_mtime(auth);
260 self.token = Some((new_token.clone(), post_mtime));
261 match wham::call_reset_credit_details(
262 client,
263 &new_token,
264 account_id,
265 reset_credits_url,
266 ) {
267 ResetCreditsResult::Ok {
268 available_count,
269 expirations,
270 } => {
271 self.reset_credits_cooldown.clear();
272 snap.reset_credits_available = Some(available_count);
273 snap.reset_credit_expirations = Some(expirations);
274 }
275 ResetCreditsResult::Unauthorized => {
276 self.reset_credits_cooldown.arm(now, post_mtime);
277 }
278 ResetCreditsResult::Transient => {}
279 }
280 }
281 }
282
283 CodexFetch::Ok(snap)
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use httpmock::prelude::*;
291
292 #[test]
300 fn fetch_with_refresh_recovers_from_401() {
301 let server = MockServer::start();
302 let stale = server.mock(|when, then| {
303 when.method(GET)
304 .path("/wham")
305 .header("authorization", "Bearer stale");
306 then.status(401);
307 });
308 let fresh = server.mock(|when, then| {
309 when.method(GET)
310 .path("/wham")
311 .header("authorization", "Bearer new-access");
312 then.status(200).body(r#"{"plan_type":"plus"}"#);
313 });
314 let reset_credits = server.mock(|when, then| {
315 when.method(GET)
316 .path("/reset-credits")
317 .header("authorization", "Bearer new-access");
318 then.status(200)
319 .body(r#"{"credits":[],"available_count":0}"#);
320 });
321 let token = server.mock(|when, then| {
322 when.method(POST).path("/token");
323 then.status(200).json_body(serde_json::json!({
324 "access_token": "new-access",
325 "refresh_token": "new-refresh"
326 }));
327 });
328
329 let dir = tempfile::tempdir().unwrap();
330 let auth = dir.path().join("auth.json");
331 std::fs::write(
332 &auth,
333 r#"{"tokens":{"access_token":"stale","refresh_token":"rt","account_id":"acct"}}"#,
334 )
335 .unwrap();
336
337 let client = crate::quota::http::build_client().unwrap();
338 let mut state = CodexState::default();
339 let result = state.fetch_with_refresh(
340 &client,
341 &auth,
342 1_000_000,
343 &server.url("/wham"),
344 &server.url("/reset-credits"),
345 &server.url("/token"),
346 );
347
348 stale.assert();
349 token.assert();
350 fresh.assert();
351 reset_credits.assert();
352 match result {
353 CodexFetch::Ok(snap) => assert_eq!(snap.plan_type.as_deref(), Some("plus")),
354 _ => panic!("expected a recovered snapshot after refresh"),
355 }
356
357 let written: serde_json::Value =
359 serde_json::from_str(&std::fs::read_to_string(&auth).unwrap()).unwrap();
360 assert_eq!(written["tokens"]["access_token"], "new-access");
361 }
362
363 #[test]
366 fn fetch_with_refresh_recovers_from_reset_credit_401() {
367 let server = MockServer::start();
368 let usage = server.mock(|when, then| {
369 when.method(GET)
370 .path("/wham")
371 .header("authorization", "Bearer stale");
372 then.status(200)
373 .body(r#"{"plan_type":"plus","rate_limit_reset_credits":{"available_count":2}}"#);
374 });
375 let stale_details = server.mock(|when, then| {
376 when.method(GET)
377 .path("/reset-credits")
378 .header("authorization", "Bearer stale");
379 then.status(401);
380 });
381 let token = server.mock(|when, then| {
382 when.method(POST).path("/token");
383 then.status(200).json_body(serde_json::json!({
384 "access_token": "new-access",
385 "refresh_token": "new-refresh"
386 }));
387 });
388 let fresh_details = server.mock(|when, then| {
389 when.method(GET)
390 .path("/reset-credits")
391 .header("authorization", "Bearer new-access");
392 then.status(200).body(
393 r#"{"credits":[{"id":"credit-1","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-07-01T00:00:00Z","expires_at":"2026-08-01T00:00:00Z"}],"available_count":1}"#,
394 );
395 });
396
397 let dir = tempfile::tempdir().unwrap();
398 let auth = dir.path().join("auth.json");
399 std::fs::write(
400 &auth,
401 r#"{"tokens":{"access_token":"stale","refresh_token":"rt","account_id":"acct"}}"#,
402 )
403 .unwrap();
404
405 let client = crate::quota::http::build_client().unwrap();
406 let mut state = CodexState::default();
407 let result = state.fetch_with_refresh(
408 &client,
409 &auth,
410 1_000_000,
411 &server.url("/wham"),
412 &server.url("/reset-credits"),
413 &server.url("/token"),
414 );
415
416 usage.assert();
417 stale_details.assert();
418 token.assert();
419 fresh_details.assert();
420 match result {
421 CodexFetch::Ok(snap) => {
422 assert_eq!(snap.plan_type.as_deref(), Some("plus"));
423 assert_eq!(snap.reset_credits_available, Some(1));
424 assert_eq!(snap.reset_credit_expirations.unwrap().len(), 1);
425 }
426 _ => panic!("expected a recovered snapshot after details refresh"),
427 }
428 }
429
430 #[test]
433 fn fetch_with_refresh_needs_login_when_refresh_fails() {
434 let server = MockServer::start();
435 server.mock(|when, then| {
436 when.method(GET).path("/wham");
437 then.status(401);
438 });
439 server.mock(|when, then| {
440 when.method(POST).path("/token");
441 then.status(400)
442 .json_body(serde_json::json!({ "error": "invalid_grant" }));
443 });
444
445 let dir = tempfile::tempdir().unwrap();
446 let auth = dir.path().join("auth.json");
447 std::fs::write(
448 &auth,
449 r#"{"tokens":{"access_token":"stale","refresh_token":"rt"}}"#,
450 )
451 .unwrap();
452
453 let client = crate::quota::http::build_client().unwrap();
454 let mut state = CodexState::default();
455 let result = state.fetch_with_refresh(
456 &client,
457 &auth,
458 1_000_000,
459 &server.url("/wham"),
460 &server.url("/reset-credits"),
461 &server.url("/token"),
462 );
463 assert!(matches!(result, CodexFetch::NeedsLogin));
464 }
465}