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, QuotaWindow};
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
42const CODEX_FIVE_HOUR_SECONDS: i64 = 5 * 60 * 60;
43const CODEX_SEVEN_DAY_SECONDS: i64 = 7 * 24 * 60 * 60;
44
45#[derive(Clone, Copy)]
47pub(crate) enum CodexWindowSlot {
48 FiveHour,
49 SevenDay,
50}
51
52pub(crate) fn assign_codex_window(
58 five_hour: &mut Option<QuotaWindow>,
59 seven_day: &mut Option<QuotaWindow>,
60 window: QuotaWindow,
61 period_seconds: Option<i64>,
62 fallback: CodexWindowSlot,
63) {
64 let slot = match period_seconds {
65 Some(CODEX_FIVE_HOUR_SECONDS) => Some(CodexWindowSlot::FiveHour),
66 Some(CODEX_SEVEN_DAY_SECONDS) => Some(CodexWindowSlot::SevenDay),
67 Some(_) => None,
68 None => Some(fallback),
69 };
70 let Some(slot) = slot else {
71 return;
72 };
73 match slot {
74 CodexWindowSlot::FiveHour => *five_hour = Some(window),
75 CodexWindowSlot::SevenDay => *seven_day = Some(window),
76 }
77}
78
79enum CodexFetch {
81 Ok(CodexQuotaSnapshot),
82 NeedsLogin,
84 Transient,
86}
87
88#[derive(Default)]
94pub struct CodexState {
95 token: Option<(String, Option<SystemTime>)>,
98 cooldown: RefreshCooldown,
99 reset_credits_cooldown: RefreshCooldown,
102}
103
104impl CodexState {
105 pub fn resolve(
107 &mut self,
108 client: &reqwest::blocking::Client,
109 ) -> QuotaOutcome<CodexQuotaSnapshot> {
110 let now = chrono::Local::now().timestamp();
111 let auth = crate::utils::resolve_paths()
112 .ok()
113 .map(|p| p.codex_dir.join("auth.json"));
114
115 if let Some(auth) = &auth
116 && auth.exists()
117 {
118 match self.fetch_with_refresh(
119 client,
120 auth,
121 now,
122 wham::WHAM_URL,
123 wham::WHAM_RESET_CREDITS_URL,
124 wham::CODEX_TOKEN_URL,
125 ) {
126 CodexFetch::Ok(snap) => return QuotaOutcome::Data(snap),
127 CodexFetch::NeedsLogin => {
128 let mut snap = codex_session::latest_session_rate_limits()
130 .ok()
131 .flatten()
132 .unwrap_or_default();
133 snap.needs_login = true;
134 snap.fetched_at = now;
135 return QuotaOutcome::Data(snap);
136 }
137 CodexFetch::Transient => { }
138 }
139 }
140
141 match codex_session::latest_session_rate_limits() {
142 Ok(Some(snap)) => QuotaOutcome::Data(snap),
143 _ => QuotaOutcome::Transient,
144 }
145 }
146
147 fn fetch_with_refresh(
153 &mut self,
154 client: &reqwest::blocking::Client,
155 auth: &std::path::Path,
156 now: i64,
157 wham_url: &str,
158 reset_credits_url: &str,
159 token_url: &str,
160 ) -> CodexFetch {
161 let body = match std::fs::read_to_string(auth) {
162 Ok(b) => b,
163 Err(e) => {
164 log::warn!("codex quota: failed to read {}: {e}", auth.display());
165 return CodexFetch::Transient;
166 }
167 };
168 let (file_token, account_id) = match wham::parse_auth(&body) {
169 Ok(x) => x,
170 Err(e) => {
171 log::warn!("codex quota: failed to parse auth.json: {e}");
172 return CodexFetch::Transient;
173 }
174 };
175 let cur_mtime = file_mtime(auth);
176 let token = match &self.token {
179 Some((t, m)) if *m == cur_mtime => t.clone(),
180 _ => file_token,
181 };
182
183 match wham::call_wham(client, &token, account_id.as_deref(), now, wham_url) {
184 WhamResult::Ok(snap) => self.finish_with_reset_credits(
185 client,
186 auth,
187 snap,
188 token,
189 cur_mtime,
190 account_id.as_deref(),
191 now,
192 reset_credits_url,
193 token_url,
194 true,
195 ),
196 WhamResult::Unauthorized => {
197 if self.cooldown.active(now, cur_mtime) {
198 self.token = None;
199 return CodexFetch::NeedsLogin;
200 }
201 match wham::refresh_codex(client, auth, token_url) {
202 Ok(new_tok) => {
203 self.cooldown.clear();
204 let post_mtime = file_mtime(auth);
208 self.token = Some((new_tok.clone(), post_mtime));
209 match wham::call_wham(
210 client,
211 &new_tok,
212 account_id.as_deref(),
213 now,
214 wham_url,
215 ) {
216 WhamResult::Ok(snap) => self.finish_with_reset_credits(
217 client,
218 auth,
219 snap,
220 new_tok,
221 post_mtime,
222 account_id.as_deref(),
223 now,
224 reset_credits_url,
225 token_url,
226 false,
227 ),
228 WhamResult::Transient => CodexFetch::Transient,
231 WhamResult::Unauthorized => {
232 self.cooldown.arm(now, post_mtime);
233 self.token = None;
234 CodexFetch::NeedsLogin
235 }
236 }
237 }
238 Err(e) => {
239 log::warn!("codex token refresh failed: {e}");
240 self.cooldown.arm(now, file_mtime(auth));
241 self.token = None;
242 CodexFetch::NeedsLogin
243 }
244 }
245 }
246 WhamResult::Transient => CodexFetch::Transient,
247 }
248 }
249
250 #[allow(clippy::too_many_arguments)]
253 fn finish_with_reset_credits(
254 &mut self,
255 client: &reqwest::blocking::Client,
256 auth: &std::path::Path,
257 mut snap: CodexQuotaSnapshot,
258 token: String,
259 token_mtime: Option<SystemTime>,
260 account_id: Option<&str>,
261 now: i64,
262 reset_credits_url: &str,
263 token_url: &str,
264 refresh_on_unauthorized: bool,
265 ) -> CodexFetch {
266 self.cooldown.clear();
267 self.token = Some((token.clone(), token_mtime));
268
269 match wham::call_reset_credit_details(client, &token, account_id, reset_credits_url) {
270 ResetCreditsResult::Ok {
271 available_count,
272 expirations,
273 } => {
274 self.reset_credits_cooldown.clear();
275 snap.reset_credits_available = Some(available_count);
276 snap.reset_credit_expirations = Some(expirations);
277 }
278 ResetCreditsResult::Transient => {}
279 ResetCreditsResult::Unauthorized => {
280 if !refresh_on_unauthorized {
281 self.reset_credits_cooldown.arm(now, token_mtime);
282 return CodexFetch::Ok(snap);
283 }
284 if self.reset_credits_cooldown.active(now, token_mtime) {
285 return CodexFetch::Ok(snap);
286 }
287
288 let new_token = match wham::refresh_codex(client, auth, token_url) {
289 Ok(new_token) => new_token,
290 Err(e) => {
291 log::warn!("codex token refresh for reset-credit details failed: {e}");
292 self.reset_credits_cooldown.arm(now, file_mtime(auth));
293 return CodexFetch::Ok(snap);
294 }
295 };
296 let post_mtime = file_mtime(auth);
297 self.token = Some((new_token.clone(), post_mtime));
298 match wham::call_reset_credit_details(
299 client,
300 &new_token,
301 account_id,
302 reset_credits_url,
303 ) {
304 ResetCreditsResult::Ok {
305 available_count,
306 expirations,
307 } => {
308 self.reset_credits_cooldown.clear();
309 snap.reset_credits_available = Some(available_count);
310 snap.reset_credit_expirations = Some(expirations);
311 }
312 ResetCreditsResult::Unauthorized => {
313 self.reset_credits_cooldown.arm(now, post_mtime);
314 }
315 ResetCreditsResult::Transient => {}
316 }
317 }
318 }
319
320 CodexFetch::Ok(snap)
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use httpmock::prelude::*;
328
329 #[test]
337 fn fetch_with_refresh_recovers_from_401() {
338 let server = MockServer::start();
339 let stale = server.mock(|when, then| {
340 when.method(GET)
341 .path("/wham")
342 .header("authorization", "Bearer stale");
343 then.status(401);
344 });
345 let fresh = server.mock(|when, then| {
346 when.method(GET)
347 .path("/wham")
348 .header("authorization", "Bearer new-access");
349 then.status(200).body(r#"{"plan_type":"plus"}"#);
350 });
351 let reset_credits = server.mock(|when, then| {
352 when.method(GET)
353 .path("/reset-credits")
354 .header("authorization", "Bearer new-access");
355 then.status(200)
356 .body(r#"{"credits":[],"available_count":0}"#);
357 });
358 let token = server.mock(|when, then| {
359 when.method(POST).path("/token");
360 then.status(200).json_body(serde_json::json!({
361 "access_token": "new-access",
362 "refresh_token": "new-refresh"
363 }));
364 });
365
366 let dir = tempfile::tempdir().unwrap();
367 let auth = dir.path().join("auth.json");
368 std::fs::write(
369 &auth,
370 r#"{"tokens":{"access_token":"stale","refresh_token":"rt","account_id":"acct"}}"#,
371 )
372 .unwrap();
373
374 let client = crate::quota::http::build_client().unwrap();
375 let mut state = CodexState::default();
376 let result = state.fetch_with_refresh(
377 &client,
378 &auth,
379 1_000_000,
380 &server.url("/wham"),
381 &server.url("/reset-credits"),
382 &server.url("/token"),
383 );
384
385 stale.assert();
386 token.assert();
387 fresh.assert();
388 reset_credits.assert();
389 match result {
390 CodexFetch::Ok(snap) => assert_eq!(snap.plan_type.as_deref(), Some("plus")),
391 _ => panic!("expected a recovered snapshot after refresh"),
392 }
393
394 let written: serde_json::Value =
396 serde_json::from_str(&std::fs::read_to_string(&auth).unwrap()).unwrap();
397 assert_eq!(written["tokens"]["access_token"], "new-access");
398 }
399
400 #[test]
403 fn fetch_with_refresh_recovers_from_reset_credit_401() {
404 let server = MockServer::start();
405 let usage = server.mock(|when, then| {
406 when.method(GET)
407 .path("/wham")
408 .header("authorization", "Bearer stale");
409 then.status(200)
410 .body(r#"{"plan_type":"plus","rate_limit_reset_credits":{"available_count":2}}"#);
411 });
412 let stale_details = server.mock(|when, then| {
413 when.method(GET)
414 .path("/reset-credits")
415 .header("authorization", "Bearer stale");
416 then.status(401);
417 });
418 let token = server.mock(|when, then| {
419 when.method(POST).path("/token");
420 then.status(200).json_body(serde_json::json!({
421 "access_token": "new-access",
422 "refresh_token": "new-refresh"
423 }));
424 });
425 let fresh_details = server.mock(|when, then| {
426 when.method(GET)
427 .path("/reset-credits")
428 .header("authorization", "Bearer new-access");
429 then.status(200).body(
430 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}"#,
431 );
432 });
433
434 let dir = tempfile::tempdir().unwrap();
435 let auth = dir.path().join("auth.json");
436 std::fs::write(
437 &auth,
438 r#"{"tokens":{"access_token":"stale","refresh_token":"rt","account_id":"acct"}}"#,
439 )
440 .unwrap();
441
442 let client = crate::quota::http::build_client().unwrap();
443 let mut state = CodexState::default();
444 let result = state.fetch_with_refresh(
445 &client,
446 &auth,
447 1_000_000,
448 &server.url("/wham"),
449 &server.url("/reset-credits"),
450 &server.url("/token"),
451 );
452
453 usage.assert();
454 stale_details.assert();
455 token.assert();
456 fresh_details.assert();
457 match result {
458 CodexFetch::Ok(snap) => {
459 assert_eq!(snap.plan_type.as_deref(), Some("plus"));
460 assert_eq!(snap.reset_credits_available, Some(1));
461 assert_eq!(snap.reset_credit_expirations.unwrap().len(), 1);
462 }
463 _ => panic!("expected a recovered snapshot after details refresh"),
464 }
465 }
466
467 #[test]
470 fn fetch_with_refresh_needs_login_when_refresh_fails() {
471 let server = MockServer::start();
472 server.mock(|when, then| {
473 when.method(GET).path("/wham");
474 then.status(401);
475 });
476 server.mock(|when, then| {
477 when.method(POST).path("/token");
478 then.status(400)
479 .json_body(serde_json::json!({ "error": "invalid_grant" }));
480 });
481
482 let dir = tempfile::tempdir().unwrap();
483 let auth = dir.path().join("auth.json");
484 std::fs::write(
485 &auth,
486 r#"{"tokens":{"access_token":"stale","refresh_token":"rt"}}"#,
487 )
488 .unwrap();
489
490 let client = crate::quota::http::build_client().unwrap();
491 let mut state = CodexState::default();
492 let result = state.fetch_with_refresh(
493 &client,
494 &auth,
495 1_000_000,
496 &server.url("/wham"),
497 &server.url("/reset-credits"),
498 &server.url("/token"),
499 );
500 assert!(matches!(result, CodexFetch::NeedsLogin));
501 }
502}