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