1use anyhow::Result;
2use chrono::Utc;
3use serde::Serialize;
4use serde_json::Value;
5use std::io::{IsTerminal, Write};
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, mpsc};
8use std::thread;
9use std::time::Duration;
10
11use crate::auth;
12use crate::diag_output;
13use crate::provider_profile::CODEX_PROVIDER_PROFILE;
14use crate::rate_limits::client::{UsageRequest, fetch_usage};
15use nils_common::env as shared_env;
16use nils_common::fs;
17use nils_common::provider_runtime::persistence::{
18 SyncSecretsError, TimestampPolicy, sync_auth_to_matching_secrets,
19};
20use nils_term::progress::{Progress, ProgressFinish, ProgressOptions};
21
22pub use nils_common::rate_limits_ansi as ansi;
23pub mod cache;
24pub mod client;
25pub mod render;
26pub mod writeback;
27
28#[derive(Clone, Debug)]
29pub struct RateLimitsOptions {
30 pub clear_cache: bool,
31 pub debug: bool,
32 pub cached: bool,
33 pub no_refresh_auth: bool,
34 pub json: bool,
35 pub one_line: bool,
36 pub all: bool,
37 pub async_mode: bool,
38 pub watch: bool,
39 pub jobs: Option<String>,
40 pub secret: Option<String>,
41}
42
43const DIAG_SCHEMA_VERSION: &str = "codex-cli.diag.rate-limits.v1";
44const DIAG_COMMAND: &str = "diag rate-limits";
45const WATCH_INTERVAL_SECONDS: u64 = 60;
46const ANSI_CLEAR_SCREEN_AND_HOME: &str = "\x1b[2J\x1b[H";
47
48fn refresh_on_401_enabled(no_refresh_auth: bool) -> bool {
49 !no_refresh_auth && shared_env::env_truthy(CODEX_PROVIDER_PROFILE.env.auto_refresh_enabled)
50}
51
52#[derive(Debug, Clone, Serialize)]
53struct RateLimitSummary {
54 non_weekly_label: String,
55 non_weekly_remaining: i64,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 non_weekly_reset_epoch: Option<i64>,
58 weekly_remaining: i64,
59 weekly_reset_epoch: i64,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 weekly_reset_local: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize)]
65struct RateLimitWindow {
66 label: String,
67 used_percent: i64,
68 remaining_percent: i64,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 reset_at_epoch: Option<i64>,
71}
72
73#[derive(Debug, Clone, Serialize)]
74struct RateLimitJsonResult {
75 provider: String,
76 name: String,
77 target_file: String,
78 status: String,
79 ok: bool,
80 source: String,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 summary: Option<RateLimitSummary>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 windows: Option<Vec<RateLimitWindow>>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 raw_usage: Option<Value>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 error: Option<diag_output::ErrorEnvelope>,
89}
90
91#[derive(Debug, Clone, Serialize)]
92struct RateLimitSingleEnvelope {
93 schema_version: String,
94 command: String,
95 mode: String,
96 ok: bool,
97 result: RateLimitJsonResult,
98}
99
100#[derive(Debug, Clone, Serialize)]
101struct RateLimitCollectionEnvelope {
102 schema_version: String,
103 command: String,
104 mode: String,
105 ok: bool,
106 results: Vec<RateLimitJsonResult>,
107}
108
109pub fn run(args: &RateLimitsOptions) -> Result<i32> {
110 let cached_mode = args.cached;
111 let mut one_line = args.one_line;
112 let mut all_mode = args.all;
113 let output_json = args.json;
114
115 let mut debug_mode = args.debug;
116 if !debug_mode
117 && let Ok(raw) = std::env::var("ZSH_DEBUG")
118 && raw.parse::<i64>().unwrap_or(0) >= 2
119 {
120 debug_mode = true;
121 }
122
123 if args.async_mode {
124 if !args.cached {
125 maybe_sync_all_mode_auth_silent(debug_mode);
126 }
127 if args.json {
128 return run_async_json_mode(args, debug_mode);
129 }
130 if args.watch {
131 return run_async_watch_mode(args, debug_mode);
132 }
133 return run_async_mode(args, debug_mode);
134 }
135
136 if cached_mode {
137 one_line = true;
138 if output_json {
139 diag_output::emit_error(
140 DIAG_SCHEMA_VERSION,
141 DIAG_COMMAND,
142 "invalid-flag-combination",
143 "codex-rate-limits: --json is not supported with --cached",
144 Some(serde_json::json!({
145 "flags": ["--json", "--cached"],
146 })),
147 )?;
148 return Ok(64);
149 }
150 if args.clear_cache {
151 eprintln!("codex-rate-limits: -c is not compatible with --cached");
152 return Ok(64);
153 }
154 }
155
156 if output_json && one_line {
157 diag_output::emit_error(
158 DIAG_SCHEMA_VERSION,
159 DIAG_COMMAND,
160 "invalid-flag-combination",
161 "codex-rate-limits: --one-line is not compatible with --json",
162 Some(serde_json::json!({
163 "flags": ["--one-line", "--json"],
164 })),
165 )?;
166 return Ok(64);
167 }
168
169 if args.clear_cache
170 && let Err(err) = cache::clear_prompt_segment_cache()
171 {
172 if output_json {
173 diag_output::emit_error(
174 DIAG_SCHEMA_VERSION,
175 DIAG_COMMAND,
176 "cache-clear-failed",
177 err.to_string(),
178 None,
179 )?;
180 } else {
181 eprintln!("{err}");
182 }
183 return Ok(1);
184 }
185
186 if !all_mode
187 && !output_json
188 && !cached_mode
189 && args.secret.is_none()
190 && shared_env::env_truthy("CODEX_RATE_LIMITS_DEFAULT_ALL_ENABLED")
191 {
192 all_mode = true;
193 }
194
195 if all_mode {
196 if !cached_mode {
197 maybe_sync_all_mode_auth_silent(debug_mode);
198 }
199 if args.secret.is_some() {
200 eprintln!(
201 "codex-rate-limits: usage: codex-rate-limits [-c] [-d] [--cached] [--no-refresh-auth] [--json] [--one-line] [--all] [secret.json]"
202 );
203 return Ok(64);
204 }
205 if output_json {
206 return run_all_json_mode(args, cached_mode, debug_mode);
207 }
208 return run_all_mode(args, cached_mode, debug_mode);
209 }
210
211 run_single_mode(args, cached_mode, one_line, output_json)
212}
213
214fn run_async_json_mode(args: &RateLimitsOptions, _debug_mode: bool) -> Result<i32> {
215 if args.one_line {
216 let message = "codex-rate-limits: --async does not support --one-line";
217 diag_output::emit_error(
218 DIAG_SCHEMA_VERSION,
219 DIAG_COMMAND,
220 "invalid-flag-combination",
221 message,
222 Some(serde_json::json!({
223 "flag": "--one-line",
224 "mode": "async",
225 })),
226 )?;
227 return Ok(64);
228 }
229 if let Some(secret) = args.secret.as_deref() {
230 let message = format!(
231 "codex-rate-limits: --async does not accept positional args: {}",
232 secret
233 );
234 diag_output::emit_error(
235 DIAG_SCHEMA_VERSION,
236 DIAG_COMMAND,
237 "invalid-positional-arg",
238 message,
239 Some(serde_json::json!({
240 "secret": secret,
241 "mode": "async",
242 })),
243 )?;
244 return Ok(64);
245 }
246 if args.clear_cache && args.cached {
247 let message = "codex-rate-limits: --async: -c is not compatible with --cached";
248 diag_output::emit_error(
249 DIAG_SCHEMA_VERSION,
250 DIAG_COMMAND,
251 "invalid-flag-combination",
252 message,
253 Some(serde_json::json!({
254 "flags": ["--async", "--cached", "-c"],
255 })),
256 )?;
257 return Ok(64);
258 }
259 if args.clear_cache
260 && let Err(err) = cache::clear_prompt_segment_cache()
261 {
262 diag_output::emit_error(
263 DIAG_SCHEMA_VERSION,
264 DIAG_COMMAND,
265 "cache-clear-failed",
266 err.to_string(),
267 None,
268 )?;
269 return Ok(1);
270 }
271
272 let secret_files = match collect_secret_files() {
273 Ok(value) => value,
274 Err((code, message, details)) => {
275 diag_output::emit_error(
276 DIAG_SCHEMA_VERSION,
277 DIAG_COMMAND,
278 "secret-discovery-failed",
279 message,
280 details,
281 )?;
282 return Ok(code);
283 }
284 };
285
286 let jobs = resolve_async_jobs(args.jobs.as_deref());
287 let cached_mode = args.cached;
288 let no_refresh_auth = args.no_refresh_auth;
289 let mut results_by_secret = collect_async_items(&secret_files, jobs, None, move |path, _| {
290 collect_json_result_for_secret(&path, cached_mode, no_refresh_auth, true)
291 });
292 let mut results = Vec::new();
293 let mut rc = 0;
294 for secret_file in &secret_files {
295 let secret_name = secret_file
296 .file_name()
297 .and_then(|name| name.to_str())
298 .unwrap_or("")
299 .to_string();
300 let result = results_by_secret.remove(&secret_name).unwrap_or_else(|| {
301 json_result_error(
302 secret_file,
303 "network",
304 "request-failed",
305 format!(
306 "codex-rate-limits: async worker did not return a result for {}",
307 secret_file.display()
308 ),
309 None,
310 )
311 });
312 if !args.cached && !result.ok {
313 rc = 1;
314 }
315 results.push(result);
316 }
317 results.sort_by(|a, b| a.name.cmp(&b.name));
318 emit_collection_envelope("async", rc == 0, results)?;
319 Ok(rc)
320}
321
322fn run_all_json_mode(
323 args: &RateLimitsOptions,
324 cached_mode: bool,
325 _debug_mode: bool,
326) -> Result<i32> {
327 let secret_files = match collect_secret_files() {
328 Ok(value) => value,
329 Err((code, message, details)) => {
330 diag_output::emit_error(
331 DIAG_SCHEMA_VERSION,
332 DIAG_COMMAND,
333 "secret-discovery-failed",
334 message,
335 details,
336 )?;
337 return Ok(code);
338 }
339 };
340
341 let mut results = Vec::new();
342 let mut rc = 0;
343 for secret_file in &secret_files {
344 let result =
345 collect_json_result_for_secret(secret_file, cached_mode, args.no_refresh_auth, false);
346 if !cached_mode && !result.ok {
347 rc = 1;
348 }
349 results.push(result);
350 }
351 results.sort_by(|a, b| a.name.cmp(&b.name));
352 emit_collection_envelope("all", rc == 0, results)?;
353 Ok(rc)
354}
355
356fn emit_collection_envelope(mode: &str, ok: bool, results: Vec<RateLimitJsonResult>) -> Result<()> {
357 diag_output::emit_json(&RateLimitCollectionEnvelope {
358 schema_version: DIAG_SCHEMA_VERSION.to_string(),
359 command: DIAG_COMMAND.to_string(),
360 mode: mode.to_string(),
361 ok,
362 results,
363 })
364}
365
366fn collect_secret_files() -> std::result::Result<Vec<PathBuf>, (i32, String, Option<Value>)> {
367 if std::env::var_os(CODEX_PROVIDER_PROFILE.env.secret_dir).is_some() {
368 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
369 return collect_json_secret_files_from_dir(&secret_dir, true);
370 }
371
372 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
373 if secret_dir.is_dir()
374 && let Ok(secret_files) = collect_json_secret_files_from_dir(&secret_dir, false)
375 && !secret_files.is_empty()
376 {
377 return Ok(secret_files);
378 }
379
380 if let Some(auth_file) = existing_active_auth_file() {
381 return Ok(vec![auth_file]);
382 }
383
384 if let Some(auth_file) = official_codex_auth_file() {
385 return Ok(vec![auth_file]);
386 }
387
388 no_secret_files_error(&secret_dir)
389}
390
391fn collect_json_secret_files_from_dir(
392 secret_dir: &Path,
393 strict: bool,
394) -> std::result::Result<Vec<PathBuf>, (i32, String, Option<Value>)> {
395 if !secret_dir.is_dir() {
396 return Err((
397 1,
398 format!(
399 "codex-rate-limits: CODEX_SECRET_DIR not found: {}",
400 secret_dir.display()
401 ),
402 Some(serde_json::json!({
403 "secret_dir": secret_dir.display().to_string(),
404 })),
405 ));
406 }
407
408 let mut secret_files: Vec<PathBuf> = std::fs::read_dir(secret_dir)
409 .map_err(|err| {
410 (
411 1,
412 format!("codex-rate-limits: failed to read CODEX_SECRET_DIR: {err}"),
413 Some(serde_json::json!({
414 "secret_dir": secret_dir.display().to_string(),
415 })),
416 )
417 })?
418 .flatten()
419 .map(|entry| entry.path())
420 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
421 .collect();
422
423 if strict && secret_files.is_empty() {
424 return no_secret_files_error(secret_dir);
425 }
426
427 secret_files.sort();
428 Ok(secret_files)
429}
430
431fn no_secret_files_error(
432 secret_dir: &Path,
433) -> std::result::Result<Vec<PathBuf>, (i32, String, Option<Value>)> {
434 Err((
435 1,
436 format!(
437 "codex-rate-limits: no secrets found in {}",
438 secret_dir.display()
439 ),
440 Some(serde_json::json!({
441 "secret_dir": secret_dir.display().to_string(),
442 })),
443 ))
444}
445
446fn existing_active_auth_file() -> Option<PathBuf> {
447 crate::paths::resolve_auth_file().filter(|path| path.is_file())
448}
449
450fn official_codex_auth_file() -> Option<PathBuf> {
451 if let Some(path) = std::env::var_os("CODEX_HOME")
452 .map(PathBuf::from)
453 .map(|home| home.join("auth.json"))
454 .filter(|path| path.is_file())
455 {
456 return Some(path);
457 }
458
459 std::env::var_os("HOME")
460 .map(PathBuf::from)
461 .map(|home| home.join(".codex").join("auth.json"))
462 .filter(|path| path.is_file())
463}
464
465fn is_official_codex_auth_file(target_file: &Path) -> bool {
466 official_codex_auth_file().as_deref() == Some(target_file)
467}
468
469fn should_writeback_usage(target_file: &Path) -> bool {
470 !is_official_codex_auth_file(target_file)
471}
472
473fn collect_json_result_for_secret(
474 target_file: &Path,
475 cached_mode: bool,
476 no_refresh_auth: bool,
477 allow_cache_fallback: bool,
478) -> RateLimitJsonResult {
479 if cached_mode {
480 return collect_json_from_cache(target_file, "cache", true);
481 }
482
483 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
484 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
485 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
486 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
487 let usage_request = UsageRequest {
488 target_file: target_file.to_path_buf(),
489 refresh_on_401: refresh_on_401_enabled(no_refresh_auth),
490 suppress_auth_refresh_output: false,
491 base_url,
492 connect_timeout_seconds: connect_timeout,
493 max_time_seconds: max_time,
494 };
495
496 match fetch_usage(&usage_request) {
497 Ok(usage) => {
498 if should_writeback_usage(target_file)
499 && let Err(err) = writeback::write_weekly(target_file, &usage.json)
500 {
501 return json_result_error(
502 target_file,
503 "network",
504 "writeback-failed",
505 err.to_string(),
506 None,
507 );
508 }
509 if is_auth_file(target_file)
510 && let Ok(sync_rc) = auth::sync::run_with_json(false)
511 && sync_rc != 0
512 {
513 return json_result_error(
514 target_file,
515 "network",
516 "sync-failed",
517 "codex-rate-limits: failed to sync auth after usage fetch".to_string(),
518 None,
519 );
520 }
521 match summary_and_windows_from_usage(&usage.json) {
522 Some((summary, windows)) => {
523 let fetched_at_epoch = Utc::now().timestamp();
524 if fetched_at_epoch > 0 {
525 let _ = cache::write_prompt_segment_cache(
526 target_file,
527 fetched_at_epoch,
528 &summary.non_weekly_label,
529 summary.non_weekly_remaining,
530 summary.weekly_remaining,
531 summary.weekly_reset_epoch,
532 summary.non_weekly_reset_epoch,
533 );
534 }
535 RateLimitJsonResult {
536 provider: "codex".to_string(),
537 name: secret_display_name(target_file),
538 target_file: target_file_name(target_file),
539 status: "ok".to_string(),
540 ok: true,
541 source: "network".to_string(),
542 summary: Some(summary),
543 windows: Some(windows),
544 raw_usage: Some(redact_sensitive_json(&usage.json)),
545 error: None,
546 }
547 }
548 None if render::rate_limit_is_explicit_null(&usage.json) => {
549 if allow_cache_fallback {
553 let fallback =
554 collect_json_from_cache(target_file, "cache-fallback", false);
555 if fallback.ok {
556 return fallback;
557 }
558 }
559 json_result_no_window(target_file)
560 }
561 None => json_result_error(
562 target_file,
563 "network",
564 "invalid-usage-payload",
565 "codex-rate-limits: invalid usage payload".to_string(),
566 Some(serde_json::json!({
567 "raw_usage": redact_sensitive_json(&usage.json),
568 })),
569 ),
570 }
571 }
572 Err(err) => {
573 if allow_cache_fallback {
574 let fallback = collect_json_from_cache(target_file, "cache-fallback", false);
575 if fallback.ok {
576 return fallback;
577 }
578 }
579 let msg = err.to_string();
580 let code = if msg.contains("missing access_token") {
581 "missing-access-token"
582 } else {
583 "request-failed"
584 };
585 json_result_error(target_file, "network", code, msg, None)
586 }
587 }
588}
589
590fn collect_json_from_cache(
591 target_file: &Path,
592 source: &str,
593 enforce_ttl: bool,
594) -> RateLimitJsonResult {
595 let cache_entry = if enforce_ttl {
596 cache::read_cache_entry_for_cached_mode(target_file)
597 } else {
598 cache::read_cache_entry(target_file)
599 };
600
601 match cache_entry {
602 Ok(entry) => RateLimitJsonResult {
603 provider: "codex".to_string(),
604 name: secret_display_name(target_file),
605 target_file: target_file_name(target_file),
606 status: "ok".to_string(),
607 ok: true,
608 source: source.to_string(),
609 summary: Some(summary_from_cache(&entry)),
610 windows: Some(windows_from_cache(&entry)),
611 raw_usage: None,
612 error: None,
613 },
614 Err(err) => json_result_error(
615 target_file,
616 source,
617 "cache-read-failed",
618 err.to_string(),
619 None,
620 ),
621 }
622}
623
624fn json_result_error(
625 target_file: &Path,
626 source: &str,
627 code: &str,
628 message: String,
629 details: Option<Value>,
630) -> RateLimitJsonResult {
631 RateLimitJsonResult {
632 provider: "codex".to_string(),
633 name: secret_display_name(target_file),
634 target_file: target_file_name(target_file),
635 status: "error".to_string(),
636 ok: false,
637 source: source.to_string(),
638 summary: None,
639 windows: None,
640 raw_usage: None,
641 error: Some(diag_output::ErrorEnvelope {
642 code: code.to_string(),
643 message,
644 details,
645 }),
646 }
647}
648
649fn json_result_no_window(target_file: &Path) -> RateLimitJsonResult {
652 RateLimitJsonResult {
653 provider: "codex".to_string(),
654 name: secret_display_name(target_file),
655 target_file: target_file_name(target_file),
656 status: "no-rate-limit-window".to_string(),
657 ok: true,
658 source: "network".to_string(),
659 summary: None,
660 windows: Some(Vec::new()),
661 raw_usage: None,
662 error: None,
663 }
664}
665
666fn secret_display_name(target_file: &Path) -> String {
667 cache::secret_name_for_target(target_file).unwrap_or_else(|| {
668 target_file
669 .file_name()
670 .and_then(|name| name.to_str())
671 .unwrap_or_default()
672 .trim_end_matches(".json")
673 .to_string()
674 })
675}
676
677fn target_file_name(target_file: &Path) -> String {
678 target_file
679 .file_name()
680 .and_then(|name| name.to_str())
681 .unwrap_or_default()
682 .to_string()
683}
684
685fn summary_and_windows_from_usage(
686 usage_json: &Value,
687) -> Option<(RateLimitSummary, Vec<RateLimitWindow>)> {
688 let usage_data = render::parse_usage(usage_json)?;
689 let values = render::render_values(&usage_data);
690 let weekly = render::weekly_values(&values);
691 let summary = RateLimitSummary {
692 non_weekly_label: weekly.non_weekly_label,
693 non_weekly_remaining: weekly.non_weekly_remaining,
694 non_weekly_reset_epoch: weekly.non_weekly_reset_epoch,
695 weekly_remaining: weekly.weekly_remaining,
696 weekly_reset_epoch: weekly.weekly_reset_epoch,
697 weekly_reset_local: render::format_epoch_local_datetime_with_offset(
698 weekly.weekly_reset_epoch,
699 ),
700 };
701 let windows = windows_from_usage_values(&usage_data, &values);
702 Some((summary, windows))
703}
704
705fn windows_from_usage_values(
706 usage_data: &render::UsageData,
707 values: &render::RenderValues,
708) -> Vec<RateLimitWindow> {
709 vec![
710 RateLimitWindow {
711 label: values.primary_label.clone(),
712 used_percent: percent_i64(usage_data.primary.used_percent),
713 remaining_percent: values.primary_remaining,
714 reset_at_epoch: Some(values.primary_reset_epoch).filter(|epoch| *epoch > 0),
715 },
716 RateLimitWindow {
717 label: values.secondary_label.clone(),
718 used_percent: percent_i64(usage_data.secondary.used_percent),
719 remaining_percent: values.secondary_remaining,
720 reset_at_epoch: Some(values.secondary_reset_epoch).filter(|epoch| *epoch > 0),
721 },
722 ]
723}
724
725fn summary_from_cache(entry: &cache::CacheEntry) -> RateLimitSummary {
726 RateLimitSummary {
727 non_weekly_label: entry.non_weekly_label.clone(),
728 non_weekly_remaining: entry.non_weekly_remaining,
729 non_weekly_reset_epoch: entry.non_weekly_reset_epoch,
730 weekly_remaining: entry.weekly_remaining,
731 weekly_reset_epoch: entry.weekly_reset_epoch,
732 weekly_reset_local: render::format_epoch_local_datetime_with_offset(
733 entry.weekly_reset_epoch,
734 ),
735 }
736}
737
738fn windows_from_cache(entry: &cache::CacheEntry) -> Vec<RateLimitWindow> {
739 vec![
740 RateLimitWindow {
741 label: entry.non_weekly_label.clone(),
742 used_percent: remaining_to_used_percent(entry.non_weekly_remaining),
743 remaining_percent: entry.non_weekly_remaining,
744 reset_at_epoch: entry.non_weekly_reset_epoch,
745 },
746 RateLimitWindow {
747 label: "Weekly".to_string(),
748 used_percent: remaining_to_used_percent(entry.weekly_remaining),
749 remaining_percent: entry.weekly_remaining,
750 reset_at_epoch: Some(entry.weekly_reset_epoch).filter(|epoch| *epoch > 0),
751 },
752 ]
753}
754
755fn percent_i64(percent: f64) -> i64 {
756 if percent.is_finite() {
757 (percent.round() as i64).clamp(0, 100)
758 } else {
759 0
760 }
761}
762
763fn remaining_to_used_percent(remaining: i64) -> i64 {
764 (100 - remaining).clamp(0, 100)
765}
766
767fn redact_sensitive_json(value: &Value) -> Value {
768 match value {
769 Value::Object(map) => {
770 let mut next = serde_json::Map::new();
771 for (key, val) in map {
772 if is_sensitive_key(key) {
773 continue;
774 }
775 next.insert(key.clone(), redact_sensitive_json(val));
776 }
777 Value::Object(next)
778 }
779 Value::Array(items) => Value::Array(items.iter().map(redact_sensitive_json).collect()),
780 _ => value.clone(),
781 }
782}
783
784fn is_sensitive_key(key: &str) -> bool {
785 matches!(
786 key,
787 "access_token" | "refresh_token" | "id_token" | "authorization" | "Authorization"
788 )
789}
790
791#[derive(Default)]
792struct AsyncFetchResult {
793 line: Option<String>,
794 rc: i32,
795 err: String,
796 stale: bool,
798 no_window: bool,
801}
802
803const RC_NO_RATE_LIMIT_WINDOW: i32 = 6;
807
808struct AsyncCollectedItem<T> {
809 secret_name: String,
810 value: T,
811}
812
813fn run_async_mode(args: &RateLimitsOptions, debug_mode: bool) -> Result<i32> {
814 run_async_mode_impl(args, debug_mode, false)
815}
816
817fn run_async_watch_mode(args: &RateLimitsOptions, debug_mode: bool) -> Result<i32> {
818 run_async_mode_impl(args, debug_mode, true)
819}
820
821fn run_async_mode_impl(
822 args: &RateLimitsOptions,
823 debug_mode: bool,
824 watch_mode: bool,
825) -> Result<i32> {
826 if args.json {
827 eprintln!("codex-rate-limits: --async does not support --json");
828 return Ok(64);
829 }
830 if args.one_line {
831 eprintln!("codex-rate-limits: --async does not support --one-line");
832 return Ok(64);
833 }
834 if let Some(secret) = args.secret.as_deref() {
835 let _ = secret;
836 eprintln!("codex-rate-limits: --async does not accept positional args");
837 eprintln!(
838 "codex-rate-limits: hint: async always queries all secrets under CODEX_SECRET_DIR"
839 );
840 return Ok(64);
841 }
842 if args.clear_cache && args.cached {
843 eprintln!("codex-rate-limits: --async: -c is not compatible with --cached");
844 return Ok(64);
845 }
846
847 let jobs = resolve_async_jobs(args.jobs.as_deref());
848
849 if args.clear_cache
850 && let Err(err) = cache::clear_prompt_segment_cache()
851 {
852 eprintln!("{err}");
853 return Ok(1);
854 }
855
856 let secret_files = match collect_secret_files_for_async_text() {
857 Ok(value) => value,
858 Err(err) => {
859 eprintln!("{err}");
860 return Ok(1);
861 }
862 };
863
864 if !watch_mode {
865 if secret_files.is_empty() {
866 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
867 eprintln!(
868 "codex-rate-limits-async: no secrets found in {}",
869 secret_dir.display()
870 );
871 return Ok(1);
872 }
873
874 let current_name = current_secret_basename(&secret_files);
875 let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
876 render_all_accounts_table(
877 round.rows,
878 &round.window_labels,
879 current_name.as_deref(),
880 None,
881 );
882 emit_async_debug(debug_mode, &secret_files, &round.stderr_map);
883 return Ok(round.rc);
884 }
885
886 let mut overall_rc = 0;
887 let mut rendered_rounds = 0u64;
888 let max_rounds = watch_max_rounds_for_test();
889 let watch_interval_seconds = watch_interval_seconds();
890 let is_terminal_stdout = std::io::stdout().is_terminal();
891
892 loop {
893 let secret_files = match collect_secret_files_for_async_text() {
894 Ok(value) => value,
895 Err(err) => {
896 overall_rc = 1;
897 if is_terminal_stdout {
898 print!("{ANSI_CLEAR_SCREEN_AND_HOME}");
899 }
900 eprintln!("{err}");
901 let _ = std::io::stdout().flush();
902
903 rendered_rounds += 1;
904 if let Some(limit) = max_rounds
905 && rendered_rounds >= limit
906 {
907 break;
908 }
909
910 thread::sleep(Duration::from_secs(watch_interval_seconds));
911 continue;
912 }
913 };
914 let current_name = current_secret_basename(&secret_files);
915 let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
916 if round.rc != 0 {
917 overall_rc = 1;
918 }
919
920 if is_terminal_stdout {
921 print!("{ANSI_CLEAR_SCREEN_AND_HOME}");
922 }
923
924 let now_epoch = Utc::now().timestamp();
925 let update_time = format_watch_update_time(now_epoch);
926 render_all_accounts_table(
927 round.rows,
928 &round.window_labels,
929 current_name.as_deref(),
930 Some(update_time.as_str()),
931 );
932 emit_async_debug(debug_mode, &secret_files, &round.stderr_map);
933 let _ = std::io::stdout().flush();
934
935 rendered_rounds += 1;
936 if let Some(limit) = max_rounds
937 && rendered_rounds >= limit
938 {
939 break;
940 }
941
942 thread::sleep(Duration::from_secs(watch_interval_seconds));
943 }
944
945 Ok(overall_rc)
946}
947
948fn collect_secret_files_for_async_text() -> std::result::Result<Vec<PathBuf>, String> {
949 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
950 if !secret_dir.is_dir() {
951 return Err(format!(
952 "codex-rate-limits-async: CODEX_SECRET_DIR not found: {}",
953 secret_dir.display()
954 ));
955 }
956
957 let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)
958 .map_err(|err| format!("codex-rate-limits-async: failed to read CODEX_SECRET_DIR: {err}"))?
959 .flatten()
960 .map(|entry| entry.path())
961 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
962 .collect();
963
964 secret_files.sort();
965 Ok(secret_files)
966}
967
968struct AsyncRound {
969 rc: i32,
970 rows: Vec<Row>,
971 window_labels: std::collections::HashSet<String>,
972 stderr_map: std::collections::HashMap<String, String>,
973}
974
975fn resolve_async_jobs(jobs: Option<&str>) -> usize {
976 jobs.and_then(|raw| raw.parse::<i64>().ok())
977 .filter(|value| *value > 0)
978 .map(|value| value as usize)
979 .unwrap_or(5)
980}
981
982fn collect_async_items<T, F>(
983 secret_files: &[PathBuf],
984 jobs: usize,
985 progress_prefix: Option<&str>,
986 worker: F,
987) -> std::collections::HashMap<String, T>
988where
989 T: Send + 'static,
990 F: Fn(PathBuf, String) -> T + Send + Sync + 'static,
991{
992 let total = secret_files.len();
993 if total == 0 {
994 return std::collections::HashMap::new();
995 }
996
997 let progress = if total > 1 {
998 progress_prefix.map(|prefix| {
999 Progress::new(
1000 total as u64,
1001 ProgressOptions::default()
1002 .with_prefix(prefix)
1003 .with_finish(ProgressFinish::Clear),
1004 )
1005 })
1006 } else {
1007 None
1008 };
1009
1010 let worker_count = jobs.clamp(1, total);
1011 let worker = Arc::new(worker);
1012 let (tx, rx) = mpsc::channel();
1013 let mut handles = Vec::new();
1014 let mut index = 0usize;
1015
1016 while index < total && handles.len() < worker_count {
1017 let path = secret_files[index].clone();
1018 index += 1;
1019 let tx = tx.clone();
1020 let worker = Arc::clone(&worker);
1021 handles.push(thread::spawn(move || {
1022 let secret_name = path
1023 .file_name()
1024 .and_then(|name| name.to_str())
1025 .unwrap_or("")
1026 .to_string();
1027 let value = worker(path, secret_name.clone());
1028 let _ = tx.send(AsyncCollectedItem { secret_name, value });
1029 }));
1030 }
1031
1032 let mut items = std::collections::HashMap::new();
1033 while items.len() < total {
1034 let item = match rx.recv() {
1035 Ok(item) => item,
1036 Err(_) => break,
1037 };
1038 if let Some(progress) = &progress {
1039 progress.set_message(item.secret_name.clone());
1040 progress.inc(1);
1041 }
1042 items.insert(item.secret_name.clone(), item.value);
1043
1044 if index < total {
1045 let path = secret_files[index].clone();
1046 index += 1;
1047 let tx = tx.clone();
1048 let worker = Arc::clone(&worker);
1049 handles.push(thread::spawn(move || {
1050 let secret_name = path
1051 .file_name()
1052 .and_then(|name| name.to_str())
1053 .unwrap_or("")
1054 .to_string();
1055 let value = worker(path, secret_name.clone());
1056 let _ = tx.send(AsyncCollectedItem { secret_name, value });
1057 }));
1058 }
1059 }
1060
1061 if let Some(progress) = progress {
1062 progress.finish_and_clear();
1063 }
1064
1065 drop(tx);
1066 for handle in handles {
1067 let _ = handle.join();
1068 }
1069
1070 items
1071}
1072
1073fn collect_async_round(
1074 secret_files: &[PathBuf],
1075 cached_mode: bool,
1076 no_refresh_auth: bool,
1077 jobs: usize,
1078) -> AsyncRound {
1079 let mut events = collect_async_items(
1080 secret_files,
1081 jobs,
1082 Some("codex-rate-limits "),
1083 move |path, secret_name| {
1084 async_fetch_one_line(&path, cached_mode, no_refresh_auth, &secret_name)
1085 },
1086 );
1087
1088 let mut rc = 0;
1089 let mut rows: Vec<Row> = Vec::new();
1090 let mut window_labels = std::collections::HashSet::new();
1091 let mut stderr_map: std::collections::HashMap<String, String> =
1092 std::collections::HashMap::new();
1093
1094 for secret_file in secret_files {
1095 let secret_name = secret_file
1096 .file_name()
1097 .and_then(|name| name.to_str())
1098 .unwrap_or("")
1099 .to_string();
1100
1101 let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
1102 let mut benign_no_window = false;
1103 let event = events.remove(&secret_name);
1104 if let Some(event) = event {
1105 if !event.err.is_empty() {
1106 stderr_map.insert(secret_name.clone(), event.err.clone());
1107 }
1108 if !cached_mode && event.rc != 0 && !event.no_window {
1111 rc = 1;
1112 }
1113 benign_no_window = event.no_window;
1114
1115 if let Some(line) = &event.line
1116 && let Some(parsed) = parse_one_line_output(line)
1117 {
1118 row.window_label = parsed.window_label.clone();
1119 row.non_weekly_remaining = parsed.non_weekly_remaining;
1120 row.weekly_remaining = parsed.weekly_remaining;
1121 row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
1122
1123 if cached_mode {
1124 if let Ok(cache_entry) = cache::read_cache_entry_for_cached_mode(secret_file) {
1125 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
1126 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
1127 }
1128 } else {
1129 let values = crate::json::read_json(secret_file).ok();
1130 if let Some(values) = values {
1131 row.non_weekly_reset_epoch = crate::json::i64_at(
1132 &values,
1133 &["codex_rate_limits", "non_weekly_reset_at_epoch"],
1134 );
1135 row.weekly_reset_epoch = crate::json::i64_at(
1136 &values,
1137 &["codex_rate_limits", "weekly_reset_at_epoch"],
1138 );
1139 }
1140 if (row.non_weekly_reset_epoch.is_none() || row.weekly_reset_epoch.is_none())
1141 && let Ok(cache_entry) = cache::read_cache_entry(secret_file)
1142 {
1143 if row.non_weekly_reset_epoch.is_none() {
1144 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
1145 }
1146 if row.weekly_reset_epoch.is_none() {
1147 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
1148 }
1149 }
1150 }
1151
1152 row.state = if event.stale {
1153 RowState::Stale
1154 } else {
1155 RowState::Filled
1156 };
1157 window_labels.insert(row.window_label.clone());
1158 rows.push(row);
1159 continue;
1160 }
1161 }
1162
1163 if benign_no_window {
1164 row.state = RowState::NoWindow;
1165 } else if !cached_mode {
1166 rc = 1;
1167 }
1168 rows.push(row);
1169 }
1170
1171 AsyncRound {
1172 rc,
1173 rows,
1174 window_labels,
1175 stderr_map,
1176 }
1177}
1178
1179fn render_all_accounts_table(
1180 mut rows: Vec<Row>,
1181 window_labels: &std::collections::HashSet<String>,
1182 current_name: Option<&str>,
1183 update_time: Option<&str>,
1184) {
1185 println!("\n🚦 Codex rate limits for all accounts\n");
1186
1187 let mut non_weekly_header = "Non-weekly".to_string();
1188 let multiple_labels = window_labels.len() != 1;
1189 if !multiple_labels && let Some(label) = window_labels.iter().next() {
1190 non_weekly_header = label.clone();
1191 }
1192
1193 let now_epoch = Utc::now().timestamp();
1194
1195 println!(
1196 "{:<15} {:>8} {:>7} {:>8} {:>7} {:<18}",
1197 "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
1198 );
1199 println!("----------------------------------------------------------------------------");
1200
1201 rows.sort_by_key(|row| row.sort_key());
1202
1203 for row in rows {
1204 let no_window = row.state == RowState::NoWindow;
1207
1208 let display_non_weekly = if no_window {
1209 "n/a".to_string()
1210 } else if multiple_labels && !row.window_label.is_empty() {
1211 if row.non_weekly_remaining >= 0 {
1212 format!("{}:{}%", row.window_label, row.non_weekly_remaining)
1213 } else {
1214 "-".to_string()
1215 }
1216 } else if row.non_weekly_remaining >= 0 {
1217 format!("{}%", row.non_weekly_remaining)
1218 } else {
1219 "-".to_string()
1220 };
1221
1222 let non_weekly_left = row
1223 .non_weekly_reset_epoch
1224 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1225 .unwrap_or_else(|| "-".to_string());
1226 let weekly_left = row
1227 .weekly_reset_epoch
1228 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1229 .unwrap_or_else(|| "-".to_string());
1230 let mut reset_display = if no_window {
1231 "n/a".to_string()
1232 } else {
1233 row.weekly_reset_epoch
1234 .and_then(render::format_epoch_local_datetime_with_offset)
1235 .unwrap_or_else(|| "-".to_string())
1236 };
1237 if row.state == RowState::Stale {
1238 reset_display.push_str(" (stale)");
1239 }
1240
1241 let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
1242 let weekly_display = if no_window {
1243 ansi::format_percent_cell("n/a", 8, None)
1244 } else if row.weekly_remaining >= 0 {
1245 ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
1246 } else {
1247 ansi::format_percent_cell("-", 8, None)
1248 };
1249
1250 let is_current = current_name == Some(row.name.as_str());
1251 let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
1252
1253 println!(
1254 "{} {} {:>7} {} {:>7} {:<18}",
1255 name_display,
1256 non_weekly_display,
1257 non_weekly_left,
1258 weekly_display,
1259 weekly_left,
1260 reset_display
1261 );
1262 }
1263
1264 if let Some(update_time) = update_time {
1265 println!();
1266 println!("Last update: {update_time}");
1267 }
1268}
1269
1270fn emit_async_debug(
1271 debug_mode: bool,
1272 secret_files: &[PathBuf],
1273 stderr_map: &std::collections::HashMap<String, String>,
1274) {
1275 if !debug_mode {
1276 return;
1277 }
1278
1279 let mut printed = false;
1280 for secret_file in secret_files {
1281 let secret_name = secret_file
1282 .file_name()
1283 .and_then(|name| name.to_str())
1284 .unwrap_or("")
1285 .to_string();
1286 if let Some(err) = stderr_map.get(&secret_name) {
1287 if err.is_empty() {
1288 continue;
1289 }
1290 if !printed {
1291 printed = true;
1292 eprintln!();
1293 eprintln!("codex-rate-limits-async: per-account stderr (captured):");
1294 }
1295 let _ = secret_name;
1296 eprintln!("---- account stderr ----");
1297 eprintln!("{err}");
1298 }
1299 }
1300}
1301
1302fn watch_max_rounds_for_test() -> Option<u64> {
1303 std::env::var("CODEX_RATE_LIMITS_WATCH_MAX_ROUNDS")
1304 .ok()
1305 .and_then(|raw| raw.parse::<u64>().ok())
1306 .filter(|value| *value > 0)
1307}
1308
1309fn watch_interval_seconds() -> u64 {
1310 std::env::var("CODEX_RATE_LIMITS_WATCH_INTERVAL_SECONDS")
1311 .ok()
1312 .and_then(|raw| raw.parse::<u64>().ok())
1313 .filter(|value| *value > 0)
1314 .unwrap_or(WATCH_INTERVAL_SECONDS)
1315}
1316
1317fn format_watch_update_time(now_epoch: i64) -> String {
1318 render::format_epoch_local(now_epoch, "%Y-%m-%d %H:%M:%S %:z")
1319 .unwrap_or_else(|| now_epoch.to_string())
1320}
1321
1322fn async_fetch_one_line(
1323 target_file: &Path,
1324 cached_mode: bool,
1325 no_refresh_auth: bool,
1326 secret_name: &str,
1327) -> AsyncFetchResult {
1328 if cached_mode {
1329 return fetch_one_line_cached(target_file);
1330 }
1331
1332 let mut attempt = 1;
1333 let max_attempts = 2;
1334 let mut network_err: Option<String> = None;
1335
1336 let mut result = fetch_one_line_network(target_file, no_refresh_auth);
1337 if !result.err.is_empty() {
1338 network_err = Some(result.err.clone());
1339 }
1340
1341 while attempt < max_attempts && result.rc == 3 {
1342 thread::sleep(Duration::from_millis(250));
1343 let next = fetch_one_line_network(target_file, no_refresh_auth);
1344 if !next.err.is_empty() {
1345 network_err = Some(next.err.clone());
1346 }
1347 result = next;
1348 attempt += 1;
1349 if result.rc != 3 {
1350 break;
1351 }
1352 }
1353
1354 let mut errors: Vec<String> = Vec::new();
1355 if let Some(err) = network_err {
1356 errors.push(err);
1357 }
1358
1359 let no_window_live = result.rc == RC_NO_RATE_LIMIT_WINDOW;
1363
1364 let missing_line = result
1365 .line
1366 .as_ref()
1367 .map(|line| line.trim().is_empty())
1368 .unwrap_or(true);
1369
1370 if result.rc != 0 || missing_line {
1371 let cached = fetch_one_line_cached_allow_stale(target_file);
1372 if !cached.err.is_empty() {
1373 errors.push(cached.err.clone());
1374 }
1375 if cached.rc == 0
1376 && cached
1377 .line
1378 .as_ref()
1379 .map(|line| !line.trim().is_empty())
1380 .unwrap_or(false)
1381 {
1382 if result.rc != 0 && !no_window_live {
1385 let _ = secret_name;
1386 errors.push(format!(
1387 "codex-rate-limits-async: falling back to cache (rc={})",
1388 result.rc
1389 ));
1390 }
1391 result = AsyncFetchResult {
1392 line: cached.line,
1393 stale: cached.stale,
1394 ..Default::default()
1395 };
1396 } else if no_window_live {
1397 result = AsyncFetchResult {
1399 rc: RC_NO_RATE_LIMIT_WINDOW,
1400 no_window: true,
1401 ..Default::default()
1402 };
1403 }
1404 }
1405
1406 let line = result.line.map(normalize_one_line);
1407 let err = errors.join("\n");
1408 AsyncFetchResult {
1409 line,
1410 rc: result.rc,
1411 err,
1412 stale: result.stale,
1413 no_window: result.no_window,
1414 }
1415}
1416
1417fn fetch_one_line_network(target_file: &Path, no_refresh_auth: bool) -> AsyncFetchResult {
1418 if !target_file.is_file() {
1419 return AsyncFetchResult {
1420 line: None,
1421 rc: 1,
1422 err: format!("codex-rate-limits: {} not found", target_file.display()),
1423 ..Default::default()
1424 };
1425 }
1426
1427 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1428 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1429 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1430 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1431
1432 let usage_request = UsageRequest {
1433 target_file: target_file.to_path_buf(),
1434 refresh_on_401: refresh_on_401_enabled(no_refresh_auth),
1435 suppress_auth_refresh_output: false,
1436 base_url,
1437 connect_timeout_seconds: connect_timeout,
1438 max_time_seconds: max_time,
1439 };
1440
1441 let usage = match fetch_usage(&usage_request) {
1442 Ok(value) => value,
1443 Err(err) => {
1444 let msg = err.to_string();
1445 if msg.contains("missing access_token") {
1446 return AsyncFetchResult {
1447 line: None,
1448 rc: 2,
1449 err: format!(
1450 "codex-rate-limits: missing access_token in {}",
1451 target_file.display()
1452 ),
1453 ..Default::default()
1454 };
1455 }
1456 return AsyncFetchResult {
1457 line: None,
1458 rc: 3,
1459 err: msg,
1460 ..Default::default()
1461 };
1462 }
1463 };
1464
1465 if should_writeback_usage(target_file)
1466 && let Err(err) = writeback::write_weekly(target_file, &usage.json)
1467 {
1468 return AsyncFetchResult {
1469 line: None,
1470 rc: 4,
1471 err: err.to_string(),
1472 ..Default::default()
1473 };
1474 }
1475
1476 if is_auth_file(target_file) {
1477 match sync_auth_silent() {
1478 Ok((sync_rc, sync_err)) => {
1479 if sync_rc != 0 {
1480 return AsyncFetchResult {
1481 line: None,
1482 rc: 5,
1483 err: sync_err.unwrap_or_default(),
1484 ..Default::default()
1485 };
1486 }
1487 }
1488 Err(_) => {
1489 return AsyncFetchResult {
1490 line: None,
1491 rc: 1,
1492 ..Default::default()
1493 };
1494 }
1495 }
1496 }
1497
1498 let usage_data = match render::parse_usage(&usage.json) {
1499 Some(value) => value,
1500 None => {
1501 if render::rate_limit_is_explicit_null(&usage.json) {
1502 return AsyncFetchResult {
1503 rc: RC_NO_RATE_LIMIT_WINDOW,
1504 ..Default::default()
1505 };
1506 }
1507 return AsyncFetchResult {
1508 line: None,
1509 rc: 3,
1510 err: "codex-rate-limits: invalid usage payload".to_string(),
1511 ..Default::default()
1512 };
1513 }
1514 };
1515
1516 let values = render::render_values(&usage_data);
1517 let weekly = render::weekly_values(&values);
1518
1519 let fetched_at_epoch = Utc::now().timestamp();
1520 if fetched_at_epoch > 0 {
1521 let _ = cache::write_prompt_segment_cache(
1522 target_file,
1523 fetched_at_epoch,
1524 &weekly.non_weekly_label,
1525 weekly.non_weekly_remaining,
1526 weekly.weekly_remaining,
1527 weekly.weekly_reset_epoch,
1528 weekly.non_weekly_reset_epoch,
1529 );
1530 }
1531
1532 AsyncFetchResult {
1533 line: Some(format_one_line_output(
1534 target_file,
1535 &weekly.non_weekly_label,
1536 weekly.non_weekly_remaining,
1537 weekly.weekly_remaining,
1538 weekly.weekly_reset_epoch,
1539 )),
1540 rc: 0,
1541 ..Default::default()
1542 }
1543}
1544
1545fn fetch_one_line_cached(target_file: &Path) -> AsyncFetchResult {
1546 match cache::read_cache_entry_for_cached_mode(target_file) {
1547 Ok(entry) => AsyncFetchResult {
1548 line: Some(format_one_line_output(
1549 target_file,
1550 &entry.non_weekly_label,
1551 entry.non_weekly_remaining,
1552 entry.weekly_remaining,
1553 entry.weekly_reset_epoch,
1554 )),
1555 rc: 0,
1556 ..Default::default()
1557 },
1558 Err(err) => AsyncFetchResult {
1559 line: None,
1560 rc: 1,
1561 err: err.to_string(),
1562 ..Default::default()
1563 },
1564 }
1565}
1566
1567fn fetch_one_line_cached_allow_stale(target_file: &Path) -> AsyncFetchResult {
1571 match cache::read_cache_entry_allow_stale(target_file) {
1572 Ok(read) => AsyncFetchResult {
1573 line: Some(format_one_line_output(
1574 target_file,
1575 &read.entry.non_weekly_label,
1576 read.entry.non_weekly_remaining,
1577 read.entry.weekly_remaining,
1578 read.entry.weekly_reset_epoch,
1579 )),
1580 rc: 0,
1581 stale: read.stale,
1582 ..Default::default()
1583 },
1584 Err(err) => AsyncFetchResult {
1585 line: None,
1586 rc: 1,
1587 err: err.to_string(),
1588 ..Default::default()
1589 },
1590 }
1591}
1592
1593fn format_one_line_output(
1594 target_file: &Path,
1595 non_weekly_label: &str,
1596 non_weekly_remaining: i64,
1597 weekly_remaining: i64,
1598 weekly_reset_epoch: i64,
1599) -> String {
1600 let _ = target_file;
1601 let weekly_reset_iso =
1602 render::format_epoch_local_datetime(weekly_reset_epoch).unwrap_or_else(|| "?".to_string());
1603
1604 format!(
1605 "{}:{}% W:{}% {}",
1606 non_weekly_label, non_weekly_remaining, weekly_remaining, weekly_reset_iso
1607 )
1608}
1609
1610fn normalize_one_line(line: String) -> String {
1611 line.replace(['\n', '\r', '\t'], " ")
1612}
1613
1614fn sync_auth_silent() -> Result<(i32, Option<String>)> {
1615 let auth_file = match crate::paths::resolve_auth_file() {
1616 Some(path) => path,
1617 None => return Ok((0, None)),
1618 };
1619
1620 let sync_result = match sync_auth_to_matching_secrets(
1621 &CODEX_PROVIDER_PROFILE,
1622 &auth_file,
1623 fs::SECRET_FILE_MODE,
1624 TimestampPolicy::Strict,
1625 ) {
1626 Ok(result) => result,
1627 Err(SyncSecretsError::HashAuthFile { path, .. })
1628 | Err(SyncSecretsError::HashSecretFile { path, .. }) => {
1629 return Ok((1, Some(format!("codex: failed to hash {}", path.display()))));
1630 }
1631 Err(err) => return Err(err.into()),
1632 };
1633 if !sync_result.auth_file_present || !sync_result.auth_identity_present {
1634 return Ok((0, None));
1635 }
1636
1637 Ok((0, None))
1638}
1639
1640fn maybe_sync_all_mode_auth_silent(debug_mode: bool) {
1641 match sync_auth_silent() {
1642 Ok((0, _)) => {}
1643 Ok((_, sync_err)) => {
1644 if debug_mode
1645 && let Some(message) = sync_err
1646 && !message.trim().is_empty()
1647 {
1648 eprintln!("{message}");
1649 }
1650 }
1651 Err(err) => {
1652 if debug_mode {
1653 eprintln!("codex-rate-limits: failed to sync auth and secrets: {err}");
1654 }
1655 }
1656 }
1657}
1658
1659fn run_all_mode(args: &RateLimitsOptions, cached_mode: bool, debug_mode: bool) -> Result<i32> {
1660 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
1661 if !secret_dir.is_dir() {
1662 eprintln!(
1663 "codex-rate-limits: CODEX_SECRET_DIR not found: {}",
1664 secret_dir.display()
1665 );
1666 return Ok(1);
1667 }
1668
1669 let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
1670 .flatten()
1671 .map(|entry| entry.path())
1672 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
1673 .collect();
1674
1675 if secret_files.is_empty() {
1676 eprintln!(
1677 "codex-rate-limits: no secrets found in {}",
1678 secret_dir.display()
1679 );
1680 return Ok(1);
1681 }
1682
1683 secret_files.sort();
1684
1685 let current_name = current_secret_basename(&secret_files);
1686
1687 let total = secret_files.len();
1688 let progress = if total > 1 {
1689 Some(Progress::new(
1690 total as u64,
1691 ProgressOptions::default()
1692 .with_prefix("codex-rate-limits ")
1693 .with_finish(ProgressFinish::Clear),
1694 ))
1695 } else {
1696 None
1697 };
1698
1699 let mut rc = 0;
1700 let mut rows: Vec<Row> = Vec::new();
1701 let mut window_labels = std::collections::HashSet::new();
1702
1703 for secret_file in secret_files {
1704 let secret_name = secret_file
1705 .file_name()
1706 .and_then(|name| name.to_str())
1707 .unwrap_or("")
1708 .to_string();
1709 if let Some(progress) = &progress {
1710 progress.set_message(secret_name.clone());
1711 }
1712
1713 let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
1714 let output =
1715 match single_one_line(&secret_file, cached_mode, args.no_refresh_auth, debug_mode) {
1716 Ok(Some(line)) => line,
1717 Ok(None) => String::new(),
1718 Err(_) => String::new(),
1719 };
1720
1721 if output.is_empty() {
1722 if !cached_mode {
1723 rc = 1;
1724 }
1725 rows.push(row);
1726 continue;
1727 }
1728
1729 if let Some(parsed) = parse_one_line_output(&output) {
1730 row.window_label = parsed.window_label.clone();
1731 row.non_weekly_remaining = parsed.non_weekly_remaining;
1732 row.weekly_remaining = parsed.weekly_remaining;
1733 row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
1734
1735 if cached_mode {
1736 if let Ok(cache_entry) = cache::read_cache_entry_for_cached_mode(&secret_file) {
1737 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
1738 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
1739 }
1740 } else {
1741 let values = crate::json::read_json(&secret_file).ok();
1742 if let Some(values) = values {
1743 row.non_weekly_reset_epoch = crate::json::i64_at(
1744 &values,
1745 &["codex_rate_limits", "non_weekly_reset_at_epoch"],
1746 );
1747 row.weekly_reset_epoch = crate::json::i64_at(
1748 &values,
1749 &["codex_rate_limits", "weekly_reset_at_epoch"],
1750 );
1751 }
1752 }
1753
1754 window_labels.insert(row.window_label.clone());
1755 rows.push(row);
1756 } else {
1757 if !cached_mode {
1758 rc = 1;
1759 }
1760 rows.push(row);
1761 }
1762
1763 if let Some(progress) = &progress {
1764 progress.inc(1);
1765 }
1766 }
1767
1768 if let Some(progress) = progress {
1769 progress.finish_and_clear();
1770 }
1771
1772 println!("\n🚦 Codex rate limits for all accounts\n");
1773
1774 let mut non_weekly_header = "Non-weekly".to_string();
1775 let multiple_labels = window_labels.len() != 1;
1776 if !multiple_labels && let Some(label) = window_labels.iter().next() {
1777 non_weekly_header = label.clone();
1778 }
1779
1780 let now_epoch = Utc::now().timestamp();
1781
1782 println!(
1783 "{:<15} {:>8} {:>7} {:>8} {:>7} {:<18}",
1784 "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
1785 );
1786 println!("----------------------------------------------------------------------------");
1787
1788 rows.sort_by_key(|row| row.sort_key());
1789
1790 for row in rows {
1791 let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
1792 if row.non_weekly_remaining >= 0 {
1793 format!("{}:{}%", row.window_label, row.non_weekly_remaining)
1794 } else {
1795 "-".to_string()
1796 }
1797 } else if row.non_weekly_remaining >= 0 {
1798 format!("{}%", row.non_weekly_remaining)
1799 } else {
1800 "-".to_string()
1801 };
1802
1803 let non_weekly_left = row
1804 .non_weekly_reset_epoch
1805 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1806 .unwrap_or_else(|| "-".to_string());
1807 let weekly_left = row
1808 .weekly_reset_epoch
1809 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1810 .unwrap_or_else(|| "-".to_string());
1811 let reset_display = row
1812 .weekly_reset_epoch
1813 .and_then(render::format_epoch_local_datetime_with_offset)
1814 .unwrap_or_else(|| "-".to_string());
1815
1816 let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
1817 let weekly_display = if row.weekly_remaining >= 0 {
1818 ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
1819 } else {
1820 ansi::format_percent_cell("-", 8, None)
1821 };
1822
1823 let is_current = current_name.as_deref() == Some(row.name.as_str());
1824 let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
1825
1826 println!(
1827 "{} {} {:>7} {} {:>7} {:<18}",
1828 name_display,
1829 non_weekly_display,
1830 non_weekly_left,
1831 weekly_display,
1832 weekly_left,
1833 reset_display
1834 );
1835 }
1836
1837 Ok(rc)
1838}
1839
1840fn current_secret_basename(secret_files: &[PathBuf]) -> Option<String> {
1841 let auth_file = crate::paths::resolve_auth_file()?;
1842 if !auth_file.is_file() {
1843 return None;
1844 }
1845
1846 let auth_key = auth::identity_key_from_auth_file(&auth_file).ok().flatten();
1847 let auth_hash = fs::sha256_file(&auth_file).ok();
1848
1849 if let Some(auth_hash) = auth_hash.as_deref() {
1850 for secret_file in secret_files {
1851 if let Ok(secret_hash) = fs::sha256_file(secret_file)
1852 && secret_hash == auth_hash
1853 && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
1854 {
1855 return Some(name.trim_end_matches(".json").to_string());
1856 }
1857 }
1858 }
1859
1860 if let Some(auth_key) = auth_key.as_deref() {
1861 for secret_file in secret_files {
1862 if let Ok(Some(candidate_key)) = auth::identity_key_from_auth_file(secret_file)
1863 && candidate_key == auth_key
1864 && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
1865 {
1866 return Some(name.trim_end_matches(".json").to_string());
1867 }
1868 }
1869 }
1870
1871 None
1872}
1873
1874fn run_single_mode(
1875 args: &RateLimitsOptions,
1876 cached_mode: bool,
1877 one_line: bool,
1878 output_json: bool,
1879) -> Result<i32> {
1880 let target_file = match resolve_target(args.secret.as_deref()) {
1881 Ok(path) => path,
1882 Err(code) => return Ok(code),
1883 };
1884
1885 if !target_file.is_file() {
1886 if output_json {
1887 diag_output::emit_error(
1888 DIAG_SCHEMA_VERSION,
1889 DIAG_COMMAND,
1890 "target-not-found",
1891 format!("codex-rate-limits: {} not found", target_file.display()),
1892 Some(serde_json::json!({
1893 "target_file": target_file.display().to_string(),
1894 })),
1895 )?;
1896 } else {
1897 eprintln!("codex-rate-limits: {} not found", target_file.display());
1898 }
1899 return Ok(1);
1900 }
1901
1902 if cached_mode {
1903 match cache::read_cache_entry_for_cached_mode(&target_file) {
1904 Ok(entry) => {
1905 let weekly_reset_iso =
1906 render::format_epoch_local_datetime(entry.weekly_reset_epoch)
1907 .unwrap_or_else(|| "?".to_string());
1908 println!(
1909 "{}:{}% W:{}% {}",
1910 entry.non_weekly_label,
1911 entry.non_weekly_remaining,
1912 entry.weekly_remaining,
1913 weekly_reset_iso
1914 );
1915 return Ok(0);
1916 }
1917 Err(err) => {
1918 eprintln!("{err}");
1919 return Ok(1);
1920 }
1921 }
1922 }
1923
1924 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1925 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1926 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1927 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1928
1929 let usage_request = UsageRequest {
1930 target_file: target_file.clone(),
1931 refresh_on_401: refresh_on_401_enabled(args.no_refresh_auth),
1932 suppress_auth_refresh_output: false,
1933 base_url,
1934 connect_timeout_seconds: connect_timeout,
1935 max_time_seconds: max_time,
1936 };
1937
1938 let usage = match fetch_usage(&usage_request) {
1939 Ok(value) => value,
1940 Err(err) => {
1941 let msg = err.to_string();
1942 if msg.contains("missing access_token") {
1943 if output_json {
1944 diag_output::emit_error(
1945 DIAG_SCHEMA_VERSION,
1946 DIAG_COMMAND,
1947 "missing-access-token",
1948 format!(
1949 "codex-rate-limits: missing access_token in {}",
1950 target_file.display()
1951 ),
1952 Some(serde_json::json!({
1953 "target_file": target_file.display().to_string(),
1954 })),
1955 )?;
1956 } else {
1957 eprintln!(
1958 "codex-rate-limits: missing access_token in {}",
1959 target_file.display()
1960 );
1961 }
1962 return Ok(2);
1963 }
1964 if output_json {
1965 diag_output::emit_error(
1966 DIAG_SCHEMA_VERSION,
1967 DIAG_COMMAND,
1968 "request-failed",
1969 msg,
1970 Some(serde_json::json!({
1971 "target_file": target_file.display().to_string(),
1972 })),
1973 )?;
1974 } else {
1975 eprintln!("{msg}");
1976 }
1977 return Ok(3);
1978 }
1979 };
1980
1981 if should_writeback_usage(&target_file)
1982 && let Err(err) = writeback::write_weekly(&target_file, &usage.json)
1983 {
1984 if output_json {
1985 diag_output::emit_error(
1986 DIAG_SCHEMA_VERSION,
1987 DIAG_COMMAND,
1988 "writeback-failed",
1989 err.to_string(),
1990 Some(serde_json::json!({
1991 "target_file": target_file.display().to_string(),
1992 })),
1993 )?;
1994 } else {
1995 eprintln!("{err}");
1996 }
1997 return Ok(4);
1998 }
1999
2000 if is_auth_file(&target_file) {
2001 let sync_rc = auth::sync::run_with_json(false)?;
2002 if sync_rc != 0 {
2003 if output_json {
2004 diag_output::emit_error(
2005 DIAG_SCHEMA_VERSION,
2006 DIAG_COMMAND,
2007 "sync-failed",
2008 "codex-rate-limits: failed to sync auth file",
2009 Some(serde_json::json!({
2010 "target_file": target_file.display().to_string(),
2011 })),
2012 )?;
2013 }
2014 return Ok(5);
2015 }
2016 }
2017
2018 let usage_data = match render::parse_usage(&usage.json) {
2019 Some(value) => value,
2020 None => {
2021 if render::rate_limit_is_explicit_null(&usage.json) {
2022 return emit_single_no_window(&target_file, output_json, one_line);
2023 }
2024 if output_json {
2025 diag_output::emit_error(
2026 DIAG_SCHEMA_VERSION,
2027 DIAG_COMMAND,
2028 "invalid-usage-payload",
2029 "codex-rate-limits: invalid usage payload",
2030 Some(serde_json::json!({
2031 "target_file": target_file.display().to_string(),
2032 "raw_usage": redact_sensitive_json(&usage.json),
2033 })),
2034 )?;
2035 } else {
2036 eprintln!("codex-rate-limits: invalid usage payload");
2037 }
2038 return Ok(3);
2039 }
2040 };
2041
2042 let values = render::render_values(&usage_data);
2043 let weekly = render::weekly_values(&values);
2044
2045 let fetched_at_epoch = Utc::now().timestamp();
2046 if fetched_at_epoch > 0 {
2047 let _ = cache::write_prompt_segment_cache(
2048 &target_file,
2049 fetched_at_epoch,
2050 &weekly.non_weekly_label,
2051 weekly.non_weekly_remaining,
2052 weekly.weekly_remaining,
2053 weekly.weekly_reset_epoch,
2054 weekly.non_weekly_reset_epoch,
2055 );
2056 }
2057
2058 if output_json {
2059 let windows = windows_from_usage_values(&usage_data, &values);
2060 let result = RateLimitJsonResult {
2061 provider: "codex".to_string(),
2062 name: secret_display_name(&target_file),
2063 target_file: target_file_name(&target_file),
2064 status: "ok".to_string(),
2065 ok: true,
2066 source: "network".to_string(),
2067 summary: Some(RateLimitSummary {
2068 non_weekly_label: weekly.non_weekly_label,
2069 non_weekly_remaining: weekly.non_weekly_remaining,
2070 non_weekly_reset_epoch: weekly.non_weekly_reset_epoch,
2071 weekly_remaining: weekly.weekly_remaining,
2072 weekly_reset_epoch: weekly.weekly_reset_epoch,
2073 weekly_reset_local: render::format_epoch_local_datetime_with_offset(
2074 weekly.weekly_reset_epoch,
2075 ),
2076 }),
2077 windows: Some(windows),
2078 raw_usage: Some(redact_sensitive_json(&usage.json)),
2079 error: None,
2080 };
2081 diag_output::emit_json(&RateLimitSingleEnvelope {
2082 schema_version: DIAG_SCHEMA_VERSION.to_string(),
2083 command: DIAG_COMMAND.to_string(),
2084 mode: "single".to_string(),
2085 ok: true,
2086 result,
2087 })?;
2088 return Ok(0);
2089 }
2090
2091 if one_line {
2092 let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
2093 .unwrap_or_else(|| "?".to_string());
2094
2095 println!(
2096 "{}:{}% W:{}% {}",
2097 weekly.non_weekly_label,
2098 weekly.non_weekly_remaining,
2099 weekly.weekly_remaining,
2100 weekly_reset_iso
2101 );
2102 return Ok(0);
2103 }
2104
2105 println!("Rate limits remaining");
2106 let primary_reset = render::format_epoch_local_datetime(values.primary_reset_epoch)
2107 .unwrap_or_else(|| "?".to_string());
2108 let secondary_reset = render::format_epoch_local_datetime(values.secondary_reset_epoch)
2109 .unwrap_or_else(|| "?".to_string());
2110
2111 println!(
2112 "{} {}% • {}",
2113 values.primary_label, values.primary_remaining, primary_reset
2114 );
2115 println!(
2116 "{} {}% • {}",
2117 values.secondary_label, values.secondary_remaining, secondary_reset
2118 );
2119
2120 Ok(0)
2121}
2122
2123fn emit_single_no_window(target_file: &Path, output_json: bool, one_line: bool) -> Result<i32> {
2127 if let Ok(read) = cache::read_cache_entry_allow_stale(target_file) {
2128 if output_json {
2129 let result = collect_json_from_cache(target_file, "cache-fallback", false);
2130 let ok = result.ok;
2131 diag_output::emit_json(&RateLimitSingleEnvelope {
2132 schema_version: DIAG_SCHEMA_VERSION.to_string(),
2133 command: DIAG_COMMAND.to_string(),
2134 mode: "single".to_string(),
2135 ok,
2136 result,
2137 })?;
2138 return Ok(0);
2139 }
2140
2141 let entry = read.entry;
2142 let stale_suffix = if read.stale { " (stale)" } else { "" };
2143 if one_line {
2144 let weekly_reset_iso = render::format_epoch_local_datetime(entry.weekly_reset_epoch)
2145 .unwrap_or_else(|| "?".to_string());
2146 println!(
2147 "{}:{}% W:{}% {}{}",
2148 entry.non_weekly_label,
2149 entry.non_weekly_remaining,
2150 entry.weekly_remaining,
2151 weekly_reset_iso,
2152 stale_suffix
2153 );
2154 return Ok(0);
2155 }
2156
2157 let weekly_reset = render::format_epoch_local_datetime(entry.weekly_reset_epoch)
2158 .unwrap_or_else(|| "?".to_string());
2159 let non_weekly_reset = entry
2160 .non_weekly_reset_epoch
2161 .and_then(render::format_epoch_local_datetime)
2162 .unwrap_or_else(|| "?".to_string());
2163 println!("Rate limits remaining{stale_suffix}");
2164 println!(
2165 "{} {}% • {}",
2166 entry.non_weekly_label, entry.non_weekly_remaining, non_weekly_reset
2167 );
2168 println!("Weekly {}% • {}", entry.weekly_remaining, weekly_reset);
2169 return Ok(0);
2170 }
2171
2172 if output_json {
2173 let result = json_result_no_window(target_file);
2174 diag_output::emit_json(&RateLimitSingleEnvelope {
2175 schema_version: DIAG_SCHEMA_VERSION.to_string(),
2176 command: DIAG_COMMAND.to_string(),
2177 mode: "single".to_string(),
2178 ok: true,
2179 result,
2180 })?;
2181 return Ok(0);
2182 }
2183
2184 println!("No active rate-limit window");
2185 Ok(0)
2186}
2187
2188fn single_one_line(
2189 target_file: &Path,
2190 cached_mode: bool,
2191 no_refresh_auth: bool,
2192 debug_mode: bool,
2193) -> Result<Option<String>> {
2194 if !target_file.is_file() {
2195 if debug_mode {
2196 eprintln!("codex-rate-limits: target file not found");
2197 }
2198 return Ok(None);
2199 }
2200
2201 if cached_mode {
2202 return match cache::read_cache_entry_for_cached_mode(target_file) {
2203 Ok(entry) => {
2204 let weekly_reset_iso =
2205 render::format_epoch_local_datetime(entry.weekly_reset_epoch)
2206 .unwrap_or_else(|| "?".to_string());
2207 Ok(Some(format!(
2208 "{}:{}% W:{}% {}",
2209 entry.non_weekly_label,
2210 entry.non_weekly_remaining,
2211 entry.weekly_remaining,
2212 weekly_reset_iso
2213 )))
2214 }
2215 Err(err) => {
2216 if debug_mode {
2217 eprintln!("{err}");
2218 }
2219 Ok(None)
2220 }
2221 };
2222 }
2223
2224 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
2225 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
2226 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
2227 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
2228
2229 let usage_request = UsageRequest {
2230 target_file: target_file.to_path_buf(),
2231 refresh_on_401: refresh_on_401_enabled(no_refresh_auth),
2232 suppress_auth_refresh_output: false,
2233 base_url,
2234 connect_timeout_seconds: connect_timeout,
2235 max_time_seconds: max_time,
2236 };
2237
2238 let usage = match fetch_usage(&usage_request) {
2239 Ok(value) => value,
2240 Err(err) => {
2241 if debug_mode {
2242 eprintln!("{err}");
2243 }
2244 return Ok(None);
2245 }
2246 };
2247
2248 if should_writeback_usage(target_file) {
2249 let _ = writeback::write_weekly(target_file, &usage.json);
2250 }
2251 if is_auth_file(target_file) {
2252 let _ = auth::sync::run();
2253 }
2254
2255 let usage_data = match render::parse_usage(&usage.json) {
2256 Some(value) => value,
2257 None => return Ok(None),
2258 };
2259 let values = render::render_values(&usage_data);
2260 let weekly = render::weekly_values(&values);
2261 let fetched_at_epoch = Utc::now().timestamp();
2262 if fetched_at_epoch > 0 {
2263 let _ = cache::write_prompt_segment_cache(
2264 target_file,
2265 fetched_at_epoch,
2266 &weekly.non_weekly_label,
2267 weekly.non_weekly_remaining,
2268 weekly.weekly_remaining,
2269 weekly.weekly_reset_epoch,
2270 weekly.non_weekly_reset_epoch,
2271 );
2272 }
2273 let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
2274 .unwrap_or_else(|| "?".to_string());
2275
2276 Ok(Some(format!(
2277 "{}:{}% W:{}% {}",
2278 weekly.non_weekly_label,
2279 weekly.non_weekly_remaining,
2280 weekly.weekly_remaining,
2281 weekly_reset_iso
2282 )))
2283}
2284
2285fn resolve_target(secret: Option<&str>) -> std::result::Result<PathBuf, i32> {
2286 if let Some(secret_name) = secret {
2287 if secret_name.is_empty() || secret_name.contains('/') || secret_name.contains("..") {
2288 eprintln!("codex-rate-limits: invalid secret file name");
2289 return Err(64);
2290 }
2291 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
2292 return Ok(secret_dir.join(secret_name));
2293 }
2294
2295 if let Some(auth_file) = existing_active_auth_file() {
2296 return Ok(auth_file);
2297 }
2298
2299 if let Some(auth_file) = official_codex_auth_file() {
2300 return Ok(auth_file);
2301 }
2302
2303 if let Some(auth_file) = crate::paths::resolve_auth_file() {
2304 return Ok(auth_file);
2305 }
2306
2307 Err(1)
2308}
2309
2310fn is_auth_file(target_file: &Path) -> bool {
2311 if let Some(auth_file) = crate::paths::resolve_auth_file() {
2312 return auth_file == target_file;
2313 }
2314 false
2315}
2316
2317fn env_timeout(key: &str, default: u64) -> u64 {
2318 std::env::var(key)
2319 .ok()
2320 .and_then(|raw| raw.parse::<u64>().ok())
2321 .unwrap_or(default)
2322}
2323
2324#[derive(Clone, Copy, PartialEq, Eq)]
2325enum RowState {
2326 Missing,
2328 Filled,
2330 Stale,
2332 NoWindow,
2334}
2335
2336struct Row {
2337 name: String,
2338 window_label: String,
2339 non_weekly_remaining: i64,
2340 non_weekly_reset_epoch: Option<i64>,
2341 weekly_remaining: i64,
2342 weekly_reset_epoch: Option<i64>,
2343 weekly_reset_iso: String,
2344 state: RowState,
2345}
2346
2347impl Row {
2348 fn empty(name: String) -> Self {
2349 Self {
2350 name,
2351 window_label: String::new(),
2352 non_weekly_remaining: -1,
2353 non_weekly_reset_epoch: None,
2354 weekly_remaining: -1,
2355 weekly_reset_epoch: None,
2356 weekly_reset_iso: String::new(),
2357 state: RowState::Missing,
2358 }
2359 }
2360
2361 fn sort_key(&self) -> (i32, i64, String) {
2362 if let Some(epoch) = self.weekly_reset_epoch {
2363 (0, epoch, self.name.clone())
2364 } else {
2365 (1, i64::MAX, self.name.clone())
2366 }
2367 }
2368}
2369
2370struct ParsedOneLine {
2371 window_label: String,
2372 non_weekly_remaining: i64,
2373 weekly_remaining: i64,
2374 weekly_reset_iso: String,
2375}
2376
2377fn parse_one_line_output(line: &str) -> Option<ParsedOneLine> {
2378 let parts: Vec<&str> = line.split_whitespace().collect();
2379 if parts.len() < 3 {
2380 return None;
2381 }
2382
2383 fn parse_fields(
2384 window_field: &str,
2385 weekly_field: &str,
2386 reset_iso: String,
2387 ) -> Option<ParsedOneLine> {
2388 let window_label = window_field
2389 .split(':')
2390 .next()?
2391 .trim_matches('"')
2392 .to_string();
2393 let non_weekly_remaining = window_field.split(':').nth(1)?;
2394 let non_weekly_remaining = non_weekly_remaining
2395 .trim_end_matches('%')
2396 .parse::<i64>()
2397 .ok()?;
2398
2399 let weekly_remaining = weekly_field.trim_start_matches("W:").trim_end_matches('%');
2400 let weekly_remaining = weekly_remaining.parse::<i64>().ok()?;
2401
2402 Some(ParsedOneLine {
2403 window_label,
2404 non_weekly_remaining,
2405 weekly_remaining,
2406 weekly_reset_iso: reset_iso,
2407 })
2408 }
2409
2410 let len = parts.len();
2411 let window_field = parts[len - 3];
2412 let weekly_field = parts[len - 2];
2413 let reset_iso = parts[len - 1].to_string();
2414
2415 if let Some(parsed) = parse_fields(window_field, weekly_field, reset_iso) {
2416 return Some(parsed);
2417 }
2418
2419 if len < 4 {
2420 return None;
2421 }
2422
2423 parse_fields(
2424 parts[len - 4],
2425 parts[len - 3],
2426 format!("{} {}", parts[len - 2], parts[len - 1]),
2427 )
2428}
2429
2430#[cfg(test)]
2431mod tests {
2432 use super::{
2433 async_fetch_one_line, cache, collect_json_from_cache, collect_secret_files,
2434 collect_secret_files_for_async_text, current_secret_basename, env_timeout,
2435 fetch_one_line_cached, is_auth_file, normalize_one_line, parse_one_line_output,
2436 redact_sensitive_json, resolve_target, secret_display_name, single_one_line,
2437 sync_auth_silent, target_file_name,
2438 };
2439 use chrono::Utc;
2440 use nils_test_support::{EnvGuard, GlobalStateLock};
2441 use serde_json::json;
2442 use std::fs;
2443 use std::path::Path;
2444
2445 const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
2446 const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
2447 const PAYLOAD_BETA: &str = "eyJzdWIiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSIsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSJ9fQ";
2448
2449 fn token(payload: &str) -> String {
2450 format!("{HEADER}.{payload}.sig")
2451 }
2452
2453 fn auth_json(
2454 payload: &str,
2455 account_id: &str,
2456 refresh_token: &str,
2457 last_refresh: &str,
2458 ) -> String {
2459 format!(
2460 r#"{{"tokens":{{"access_token":"{}","id_token":"{}","refresh_token":"{}","account_id":"{}"}},"last_refresh":"{}"}}"#,
2461 token(payload),
2462 token(payload),
2463 refresh_token,
2464 account_id,
2465 last_refresh
2466 )
2467 }
2468
2469 fn fresh_fetched_at() -> i64 {
2470 Utc::now().timestamp()
2471 }
2472
2473 #[test]
2474 fn redact_sensitive_json_removes_tokens_recursively() {
2475 let input = json!({
2476 "tokens": {
2477 "access_token": "a",
2478 "refresh_token": "b",
2479 "nested": {
2480 "id_token": "c",
2481 "Authorization": "Bearer x",
2482 "ok": 1
2483 }
2484 },
2485 "items": [
2486 {"authorization": "Bearer y", "value": 2}
2487 ],
2488 "safe": true
2489 });
2490
2491 let redacted = redact_sensitive_json(&input);
2492 assert_eq!(redacted["tokens"]["nested"]["ok"], 1);
2493 assert_eq!(redacted["safe"], true);
2494 assert!(
2495 redacted["tokens"].get("access_token").is_none(),
2496 "access_token should be removed"
2497 );
2498 assert!(
2499 redacted["tokens"]["nested"].get("id_token").is_none(),
2500 "id_token should be removed"
2501 );
2502 assert!(
2503 redacted["tokens"]["nested"].get("Authorization").is_none(),
2504 "Authorization should be removed"
2505 );
2506 assert!(
2507 redacted["items"][0].get("authorization").is_none(),
2508 "authorization should be removed"
2509 );
2510 }
2511
2512 #[test]
2513 fn collect_secret_files_reports_missing_secret_dir() {
2514 let lock = GlobalStateLock::new();
2515 let dir = tempfile::TempDir::new().expect("tempdir");
2516 let missing = dir.path().join("missing");
2517 let _secret = EnvGuard::set(
2518 &lock,
2519 "CODEX_SECRET_DIR",
2520 missing.to_str().expect("missing path"),
2521 );
2522
2523 let err = collect_secret_files().expect_err("expected missing dir error");
2524 assert_eq!(err.0, 1);
2525 assert!(err.1.contains("CODEX_SECRET_DIR not found"));
2526 }
2527
2528 #[test]
2529 fn collect_secret_files_returns_sorted_json_files_only() {
2530 let lock = GlobalStateLock::new();
2531 let dir = tempfile::TempDir::new().expect("tempdir");
2532 let secrets = dir.path().join("secrets");
2533 fs::create_dir_all(&secrets).expect("secrets dir");
2534 fs::write(secrets.join("beta.json"), "{}").expect("write beta");
2535 fs::write(secrets.join("alpha.json"), "{}").expect("write alpha");
2536 fs::write(secrets.join("note.txt"), "ignore").expect("write note");
2537 let _secret = EnvGuard::set(
2538 &lock,
2539 "CODEX_SECRET_DIR",
2540 secrets.to_str().expect("secrets path"),
2541 );
2542
2543 let files = collect_secret_files().expect("secret files");
2544 assert_eq!(files.len(), 2);
2545 assert_eq!(
2546 files[0].file_name().and_then(|name| name.to_str()),
2547 Some("alpha.json")
2548 );
2549 assert_eq!(
2550 files[1].file_name().and_then(|name| name.to_str()),
2551 Some("beta.json")
2552 );
2553 }
2554
2555 #[test]
2556 fn collect_secret_files_for_async_text_allows_empty_secret_dir() {
2557 let lock = GlobalStateLock::new();
2558 let dir = tempfile::TempDir::new().expect("tempdir");
2559 let secret_dir = dir.path().join("secrets");
2560 fs::create_dir_all(&secret_dir).expect("secret dir");
2561 let _secret = EnvGuard::set(
2562 &lock,
2563 "CODEX_SECRET_DIR",
2564 secret_dir.to_str().expect("secret"),
2565 );
2566
2567 let files = collect_secret_files_for_async_text().expect("async text secret files");
2568 assert!(files.is_empty());
2569 }
2570
2571 #[test]
2572 fn rate_limits_helper_env_timeout_supports_default_and_parse() {
2573 let lock = GlobalStateLock::new();
2574 let key = "CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS";
2575
2576 let _removed = EnvGuard::remove(&lock, key);
2577 assert_eq!(env_timeout(key, 7), 7);
2578
2579 let _set = EnvGuard::set(&lock, key, "11");
2580 assert_eq!(env_timeout(key, 7), 11);
2581
2582 let _invalid = EnvGuard::set(&lock, key, "oops");
2583 assert_eq!(env_timeout(key, 7), 7);
2584 }
2585
2586 #[test]
2587 fn rate_limits_helper_resolve_target_and_is_auth_file() {
2588 let lock = GlobalStateLock::new();
2589 let dir = tempfile::TempDir::new().expect("tempdir");
2590 let secret_dir = dir.path().join("secrets");
2591 fs::create_dir_all(&secret_dir).expect("secret dir");
2592 let auth_file = dir.path().join("auth.json");
2593 fs::write(&auth_file, "{}").expect("auth");
2594
2595 let _secret = EnvGuard::set(
2596 &lock,
2597 "CODEX_SECRET_DIR",
2598 secret_dir.to_str().expect("secret"),
2599 );
2600 let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2601
2602 assert_eq!(
2603 resolve_target(Some("alpha.json")).expect("target"),
2604 secret_dir.join("alpha.json")
2605 );
2606 assert_eq!(resolve_target(Some("../bad")).expect_err("usage"), 64);
2607 assert_eq!(resolve_target(None).expect("auth default"), auth_file);
2608 assert!(is_auth_file(&auth_file));
2609 assert!(!is_auth_file(&secret_dir.join("alpha.json")));
2610 }
2611
2612 #[test]
2613 fn rate_limits_helper_resolve_target_without_auth_returns_default_active_path() {
2614 let lock = GlobalStateLock::new();
2615 let dir = tempfile::TempDir::new().expect("tempdir");
2616 let home = dir.path().join("home");
2617 let codex_home = dir.path().join("codex-home");
2618 let _auth = EnvGuard::remove(&lock, "CODEX_AUTH_FILE");
2619 let _codex_home = EnvGuard::set(&lock, "CODEX_HOME", codex_home.to_str().expect("home"));
2620 let _home = EnvGuard::set(&lock, "HOME", home.to_str().expect("home"));
2621
2622 assert_eq!(
2623 resolve_target(None).expect("default active auth"),
2624 home.join(".agents").join("auth.json")
2625 );
2626 }
2627
2628 #[test]
2629 fn rate_limits_helper_collect_json_from_cache_covers_hit_and_miss() {
2630 let lock = GlobalStateLock::new();
2631 let dir = tempfile::TempDir::new().expect("tempdir");
2632 let secret_dir = dir.path().join("secrets");
2633 let cache_root = dir.path().join("cache-root");
2634 fs::create_dir_all(&secret_dir).expect("secrets");
2635 fs::create_dir_all(&cache_root).expect("cache");
2636
2637 let alpha = secret_dir.join("alpha.json");
2638 fs::write(&alpha, "{}").expect("alpha");
2639
2640 let _secret = EnvGuard::set(
2641 &lock,
2642 "CODEX_SECRET_DIR",
2643 secret_dir.to_str().expect("secret"),
2644 );
2645 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2646 cache::write_prompt_segment_cache(
2647 &alpha,
2648 fresh_fetched_at(),
2649 "3h",
2650 92,
2651 88,
2652 1_700_003_600,
2653 Some(1_700_001_200),
2654 )
2655 .expect("write cache");
2656
2657 let hit = collect_json_from_cache(&alpha, "cache", true);
2658 assert!(hit.ok);
2659 assert_eq!(hit.status, "ok");
2660 let summary = hit.summary.expect("summary");
2661 assert_eq!(summary.non_weekly_label, "3h");
2662 assert_eq!(summary.non_weekly_remaining, 92);
2663 assert_eq!(summary.weekly_remaining, 88);
2664
2665 let missing_target = secret_dir.join("missing.json");
2666 let miss = collect_json_from_cache(&missing_target, "cache", true);
2667 assert!(!miss.ok);
2668 let error = miss.error.expect("error");
2669 assert_eq!(error.code, "cache-read-failed");
2670 assert!(error.message.contains("cache not found"));
2671 }
2672
2673 #[test]
2674 fn rate_limits_helper_fetch_one_line_cached_covers_success_and_error() {
2675 let lock = GlobalStateLock::new();
2676 let dir = tempfile::TempDir::new().expect("tempdir");
2677 let secret_dir = dir.path().join("secrets");
2678 let cache_root = dir.path().join("cache-root");
2679 fs::create_dir_all(&secret_dir).expect("secrets");
2680 fs::create_dir_all(&cache_root).expect("cache");
2681
2682 let alpha = secret_dir.join("alpha.json");
2683 fs::write(&alpha, "{}").expect("alpha");
2684
2685 let _secret = EnvGuard::set(
2686 &lock,
2687 "CODEX_SECRET_DIR",
2688 secret_dir.to_str().expect("secret"),
2689 );
2690 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2691 cache::write_prompt_segment_cache(
2692 &alpha,
2693 fresh_fetched_at(),
2694 "3h",
2695 70,
2696 55,
2697 1_700_003_600,
2698 Some(1_700_001_200),
2699 )
2700 .expect("write cache");
2701
2702 let cached = fetch_one_line_cached(&alpha);
2703 assert_eq!(cached.rc, 0);
2704 assert!(cached.err.is_empty());
2705 assert!(cached.line.expect("line").contains("3h:70%"));
2706
2707 let miss = fetch_one_line_cached(&secret_dir.join("beta.json"));
2708 assert_eq!(miss.rc, 1);
2709 assert!(miss.line.is_none());
2710 assert!(miss.err.contains("cache not found"));
2711 }
2712
2713 #[test]
2714 fn rate_limits_helper_async_fetch_one_line_uses_cache_fallback() {
2715 let lock = GlobalStateLock::new();
2716 let dir = tempfile::TempDir::new().expect("tempdir");
2717 let secret_dir = dir.path().join("secrets");
2718 let cache_root = dir.path().join("cache-root");
2719 fs::create_dir_all(&secret_dir).expect("secrets");
2720 fs::create_dir_all(&cache_root).expect("cache");
2721
2722 let missing = secret_dir.join("ghost.json");
2723 let _secret = EnvGuard::set(
2724 &lock,
2725 "CODEX_SECRET_DIR",
2726 secret_dir.to_str().expect("secret"),
2727 );
2728 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2729 cache::write_prompt_segment_cache(
2730 &missing,
2731 fresh_fetched_at(),
2732 "3h",
2733 68,
2734 42,
2735 1_700_003_600,
2736 Some(1_700_001_200),
2737 )
2738 .expect("write cache");
2739
2740 let result = async_fetch_one_line(&missing, false, true, "ghost");
2741 assert_eq!(result.rc, 0);
2742 let line = result.line.expect("line");
2743 assert!(line.contains("3h:68%"));
2744 assert!(result.err.contains("falling back to cache"));
2745 }
2746
2747 #[test]
2748 fn rate_limits_helper_single_one_line_cached_mode_handles_hit_and_miss() {
2749 let lock = GlobalStateLock::new();
2750 let dir = tempfile::TempDir::new().expect("tempdir");
2751 let secret_dir = dir.path().join("secrets");
2752 let cache_root = dir.path().join("cache-root");
2753 fs::create_dir_all(&secret_dir).expect("secrets");
2754 fs::create_dir_all(&cache_root).expect("cache");
2755
2756 let alpha = secret_dir.join("alpha.json");
2757 let beta = secret_dir.join("beta.json");
2758 fs::write(&alpha, "{}").expect("alpha");
2759 fs::write(&beta, "{}").expect("beta");
2760
2761 let _secret = EnvGuard::set(
2762 &lock,
2763 "CODEX_SECRET_DIR",
2764 secret_dir.to_str().expect("secret"),
2765 );
2766 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2767 cache::write_prompt_segment_cache(
2768 &alpha,
2769 fresh_fetched_at(),
2770 "3h",
2771 61,
2772 39,
2773 1_700_003_600,
2774 Some(1_700_001_200),
2775 )
2776 .expect("write cache");
2777
2778 let hit = single_one_line(&alpha, true, true, false).expect("single");
2779 assert!(hit.expect("line").contains("3h:61%"));
2780
2781 let miss = single_one_line(&beta, true, true, true).expect("single");
2782 assert!(miss.is_none());
2783
2784 let missing =
2785 single_one_line(&secret_dir.join("missing.json"), true, true, true).expect("single");
2786 assert!(missing.is_none());
2787 }
2788
2789 #[test]
2790 fn rate_limits_helper_sync_auth_silent_updates_matching_secret_and_timestamps() {
2791 let lock = GlobalStateLock::new();
2792 let dir = tempfile::TempDir::new().expect("tempdir");
2793 let secret_dir = dir.path().join("secrets");
2794 let cache_dir = dir.path().join("cache");
2795 fs::create_dir_all(&secret_dir).expect("secrets");
2796 fs::create_dir_all(&cache_dir).expect("cache");
2797
2798 let auth_file = dir.path().join("auth.json");
2799 let alpha = secret_dir.join("alpha.json");
2800 let beta = secret_dir.join("beta.json");
2801 fs::write(
2802 &auth_file,
2803 auth_json(
2804 PAYLOAD_ALPHA,
2805 "acct_001",
2806 "refresh_new",
2807 "2025-01-20T12:34:56Z",
2808 ),
2809 )
2810 .expect("auth");
2811 fs::write(
2812 &alpha,
2813 auth_json(
2814 PAYLOAD_ALPHA,
2815 "acct_001",
2816 "refresh_old",
2817 "2025-01-19T12:34:56Z",
2818 ),
2819 )
2820 .expect("alpha");
2821 fs::write(
2822 &beta,
2823 auth_json(
2824 PAYLOAD_BETA,
2825 "acct_002",
2826 "refresh_beta",
2827 "2025-01-18T12:34:56Z",
2828 ),
2829 )
2830 .expect("beta");
2831 fs::write(secret_dir.join("invalid.json"), "{invalid").expect("invalid");
2832 fs::write(secret_dir.join("note.txt"), "ignore").expect("note");
2833
2834 let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2835 let _secret = EnvGuard::set(
2836 &lock,
2837 "CODEX_SECRET_DIR",
2838 secret_dir.to_str().expect("secret"),
2839 );
2840 let _cache = EnvGuard::set(
2841 &lock,
2842 "CODEX_SECRET_CACHE_DIR",
2843 cache_dir.to_str().expect("cache"),
2844 );
2845
2846 let (rc, err) = sync_auth_silent().expect("sync");
2847 assert_eq!(rc, 0);
2848 assert!(err.is_none());
2849 assert_eq!(
2850 fs::read(&alpha).expect("alpha"),
2851 fs::read(&auth_file).expect("auth")
2852 );
2853 assert_ne!(
2854 fs::read(&beta).expect("beta"),
2855 fs::read(&auth_file).expect("auth")
2856 );
2857 assert!(cache_dir.join("alpha.json.timestamp").is_file());
2858 assert!(cache_dir.join("auth.json.timestamp").is_file());
2859 }
2860
2861 #[test]
2862 fn rate_limits_helper_parsers_and_name_helpers_cover_fallbacks() {
2863 let parsed =
2864 parse_one_line_output("alpha 3h:90% W:80% 2025-01-20 12:00:00+00:00").expect("parsed");
2865 assert_eq!(parsed.window_label, "3h");
2866 assert_eq!(parsed.non_weekly_remaining, 90);
2867 assert_eq!(parsed.weekly_remaining, 80);
2868 assert_eq!(parsed.weekly_reset_iso, "2025-01-20 12:00:00+00:00");
2869 assert!(parse_one_line_output("bad").is_none());
2870
2871 assert_eq!(normalize_one_line("a\tb\nc\r".to_string()), "a b c ");
2872 assert_eq!(target_file_name(Path::new("alpha.json")), "alpha.json");
2873 assert_eq!(target_file_name(Path::new("")), "");
2874 assert_eq!(secret_display_name(Path::new("alpha.json")), "alpha");
2875 }
2876
2877 #[test]
2878 fn rate_limits_helper_current_secret_basename_tracks_auth_switch() {
2879 let lock = GlobalStateLock::new();
2880 let dir = tempfile::TempDir::new().expect("tempdir");
2881 let secret_dir = dir.path().join("secrets");
2882 fs::create_dir_all(&secret_dir).expect("secrets");
2883
2884 let auth_file = dir.path().join("auth.json");
2885 let alpha = secret_dir.join("alpha.json");
2886 let beta = secret_dir.join("beta.json");
2887
2888 let alpha_json = auth_json(
2889 PAYLOAD_ALPHA,
2890 "acct_001",
2891 "refresh_alpha",
2892 "2025-01-20T12:34:56Z",
2893 );
2894 let beta_json = auth_json(
2895 PAYLOAD_BETA,
2896 "acct_002",
2897 "refresh_beta",
2898 "2025-01-21T12:34:56Z",
2899 );
2900 fs::write(&alpha, &alpha_json).expect("alpha");
2901 fs::write(&beta, &beta_json).expect("beta");
2902 fs::write(&auth_file, &alpha_json).expect("auth alpha");
2903
2904 let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2905
2906 let secret_files = vec![alpha.clone(), beta.clone()];
2907 assert_eq!(
2908 current_secret_basename(&secret_files).as_deref(),
2909 Some("alpha")
2910 );
2911
2912 fs::write(&auth_file, &beta_json).expect("auth beta");
2913 assert_eq!(
2914 current_secret_basename(&secret_files).as_deref(),
2915 Some("beta")
2916 );
2917 }
2918}