Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 129 additions & 28 deletions crates/codex-cli/src/rate_limits/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -675,34 +675,25 @@ fn run_async_mode_impl(
return Ok(1);
}

let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
if !secret_dir.is_dir() {
eprintln!(
"codex-rate-limits-async: CODEX_SECRET_DIR not found: {}",
secret_dir.display()
);
return Ok(1);
}

let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
.collect();

if secret_files.is_empty() {
eprintln!(
"codex-rate-limits-async: no secrets found in {}",
secret_dir.display()
);
return Ok(1);
}

secret_files.sort();

let current_name = current_secret_basename(&secret_files);
let secret_files = match collect_secret_files_for_async_text() {
Ok(value) => value,
Err(err) => {
eprintln!("{err}");
return Ok(1);
}
};

if !watch_mode {
if secret_files.is_empty() {
let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
eprintln!(
"codex-rate-limits-async: no secrets found in {}",
secret_dir.display()
);
return Ok(1);
}

let current_name = current_secret_basename(&secret_files);
let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
render_all_accounts_table(
round.rows,
Expand All @@ -717,9 +708,32 @@ fn run_async_mode_impl(
let mut overall_rc = 0;
let mut rendered_rounds = 0u64;
let max_rounds = watch_max_rounds_for_test();
let watch_interval_seconds = watch_interval_seconds();
let is_terminal_stdout = std::io::stdout().is_terminal();

loop {
let secret_files = match collect_secret_files_for_async_text() {
Ok(value) => value,
Err(err) => {
overall_rc = 1;
if is_terminal_stdout {
print!("{ANSI_CLEAR_SCREEN_AND_HOME}");
}
eprintln!("{err}");
let _ = std::io::stdout().flush();

rendered_rounds += 1;
if let Some(limit) = max_rounds
&& rendered_rounds >= limit
{
break;
}

thread::sleep(Duration::from_secs(watch_interval_seconds));
continue;
}
};
let current_name = current_secret_basename(&secret_files);
let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
if round.rc != 0 {
overall_rc = 1;
Expand Down Expand Up @@ -747,12 +761,32 @@ fn run_async_mode_impl(
break;
}

thread::sleep(Duration::from_secs(WATCH_INTERVAL_SECONDS));
thread::sleep(Duration::from_secs(watch_interval_seconds));
}

Ok(overall_rc)
}

fn collect_secret_files_for_async_text() -> std::result::Result<Vec<PathBuf>, String> {
let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
if !secret_dir.is_dir() {
return Err(format!(
"codex-rate-limits-async: CODEX_SECRET_DIR not found: {}",
secret_dir.display()
));
}

let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)
.map_err(|err| format!("codex-rate-limits-async: failed to read CODEX_SECRET_DIR: {err}"))?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
.collect();

secret_files.sort();
Ok(secret_files)
}

struct AsyncRound {
rc: i32,
rows: Vec<Row>,
Expand Down Expand Up @@ -1034,6 +1068,14 @@ fn watch_max_rounds_for_test() -> Option<u64> {
.filter(|value| *value > 0)
}

fn watch_interval_seconds() -> u64 {
std::env::var("CODEX_RATE_LIMITS_WATCH_INTERVAL_SECONDS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.filter(|value| *value > 0)
.unwrap_or(WATCH_INTERVAL_SECONDS)
}

fn format_watch_update_time(now_epoch: i64) -> String {
render::format_epoch_local(now_epoch, "%Y-%m-%d %H:%M:%S %:z")
.unwrap_or_else(|| now_epoch.to_string())
Expand Down Expand Up @@ -2060,7 +2102,8 @@ fn parse_one_line_output(line: &str) -> Option<ParsedOneLine> {
#[cfg(test)]
mod tests {
use super::{
async_fetch_one_line, cache, collect_json_from_cache, collect_secret_files, env_timeout,
async_fetch_one_line, cache, collect_json_from_cache, collect_secret_files,
collect_secret_files_for_async_text, current_secret_basename, env_timeout,
fetch_one_line_cached, is_auth_file, normalize_one_line, parse_one_line_output,
redact_sensitive_json, resolve_target, secret_display_name, single_one_line,
sync_auth_silent, target_file_name,
Expand Down Expand Up @@ -2181,6 +2224,22 @@ mod tests {
);
}

#[test]
fn collect_secret_files_for_async_text_allows_empty_secret_dir() {
let lock = GlobalStateLock::new();
let dir = tempfile::TempDir::new().expect("tempdir");
let secret_dir = dir.path().join("secrets");
fs::create_dir_all(&secret_dir).expect("secret dir");
let _secret = EnvGuard::set(
&lock,
"CODEX_SECRET_DIR",
secret_dir.to_str().expect("secret"),
);

let files = collect_secret_files_for_async_text().expect("async text secret files");
assert!(files.is_empty());
}

#[test]
fn rate_limits_helper_env_timeout_supports_default_and_parse() {
let lock = GlobalStateLock::new();
Expand Down Expand Up @@ -2479,4 +2538,46 @@ mod tests {
assert_eq!(target_file_name(Path::new("")), "");
assert_eq!(secret_display_name(Path::new("alpha.json")), "alpha");
}

#[test]
fn rate_limits_helper_current_secret_basename_tracks_auth_switch() {
let lock = GlobalStateLock::new();
let dir = tempfile::TempDir::new().expect("tempdir");
let secret_dir = dir.path().join("secrets");
fs::create_dir_all(&secret_dir).expect("secrets");

let auth_file = dir.path().join("auth.json");
let alpha = secret_dir.join("alpha.json");
let beta = secret_dir.join("beta.json");

let alpha_json = auth_json(
PAYLOAD_ALPHA,
"acct_001",
"refresh_alpha",
"2025-01-20T12:34:56Z",
);
let beta_json = auth_json(
PAYLOAD_BETA,
"acct_002",
"refresh_beta",
"2025-01-21T12:34:56Z",
);
fs::write(&alpha, &alpha_json).expect("alpha");
fs::write(&beta, &beta_json).expect("beta");
fs::write(&auth_file, &alpha_json).expect("auth alpha");

let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));

let secret_files = vec![alpha.clone(), beta.clone()];
assert_eq!(
current_secret_basename(&secret_files).as_deref(),
Some("alpha")
);

fs::write(&auth_file, &beta_json).expect("auth beta");
assert_eq!(
current_secret_basename(&secret_files).as_deref(),
Some("beta")
);
}
}
74 changes: 74 additions & 0 deletions crates/codex-cli/tests/rate_limits_async.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ use pretty_assertions::assert_eq;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;

fn codex_cli_bin() -> PathBuf {
bin::resolve("codex-cli")
Expand Down Expand Up @@ -266,6 +268,78 @@ fn rate_limits_async_watch_renders_last_update_timestamp() {
assert!(out.contains("Last update: "));
}

#[test]
fn rate_limits_async_watch_rescans_secrets_and_updates_last_rendered_rows() {
let dir = tempfile::TempDir::new().expect("tempdir");
let secret_dir = dir.path().join("secrets");
fs::create_dir_all(&secret_dir).expect("secret dir");

let alpha_json = r#"{"tokens":{"access_token":"tok-alpha","account_id":"acct_001"}}"#;
let beta_json = r#"{"tokens":{"access_token":"tok-beta","account_id":"acct_002"}}"#;
fs::write(secret_dir.join("alpha.json"), alpha_json).expect("write alpha");

let auth_file = dir.path().join("auth.json");
fs::write(&auth_file, alpha_json).expect("write auth");

let cache_root = dir.path().join("cache_root");
fs::create_dir_all(&cache_root).expect("cache root");

let server = LoopbackServer::new().expect("server");
server.add_route(
"GET",
"/wham/usage",
HttpResponse::new(
200,
r#"{
"rate_limit": {
"primary_window": { "limit_window_seconds": 18000, "used_percent": 6, "reset_at": 1700003600 },
"secondary_window": { "limit_window_seconds": 604800, "used_percent": 12, "reset_at": 1700600000 }
}
}"#,
),
);

let secret_dir_for_update = secret_dir.clone();
let auth_file_for_update = auth_file.clone();
let updater = thread::spawn(move || {
thread::sleep(Duration::from_millis(500));
fs::remove_file(secret_dir_for_update.join("alpha.json")).expect("remove alpha");
fs::write(secret_dir_for_update.join("beta.json"), beta_json).expect("write beta");
fs::write(auth_file_for_update, beta_json).expect("switch auth");
});

let output = run(
&["diag", "rate-limits", "--async", "--watch"],
&[
("CODEX_SECRET_DIR", &secret_dir),
("CODEX_AUTH_FILE", &auth_file),
("ZSH_CACHE_DIR", &cache_root),
],
&[
("CODEX_CHATGPT_BASE_URL", &server.url()),
("CODEX_RATE_LIMITS_DEFAULT_ALL_ENABLED", "false"),
("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", "1"),
("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", "3"),
("CODEX_RATE_LIMITS_WATCH_MAX_ROUNDS", "2"),
("CODEX_RATE_LIMITS_WATCH_INTERVAL_SECONDS", "2"),
("TZ", "UTC"),
("NO_COLOR", "1"),
],
);

updater.join().expect("updater join");
assert_exit(&output, 0);

let out = stdout(&output);
let last_render_start = out
.rfind("🚦 Codex rate limits for all accounts")
.expect("last render start");
let last_render = &out[last_render_start..];
assert!(last_render.contains("beta"));
assert!(!last_render.contains("alpha"));
assert!(cache_kv_path(&cache_root, "beta").is_file());
}

#[test]
fn rate_limits_async_jobs_zero_defaults() {
let dir = tempfile::TempDir::new().expect("tempdir");
Expand Down