Skip to main content

codex_cli/rate_limits/
mod.rs

1use anyhow::Result;
2use chrono::Utc;
3use std::path::{Path, PathBuf};
4use std::sync::mpsc;
5use std::thread;
6use std::time::Duration;
7
8use crate::auth;
9use crate::rate_limits::client::{UsageRequest, fetch_usage};
10use nils_common::env as shared_env;
11use nils_term::progress::{Progress, ProgressFinish, ProgressOptions};
12
13pub mod ansi;
14pub mod cache;
15pub mod client;
16pub mod render;
17pub mod writeback;
18
19#[derive(Clone, Debug)]
20pub struct RateLimitsOptions {
21    pub clear_cache: bool,
22    pub debug: bool,
23    pub cached: bool,
24    pub no_refresh_auth: bool,
25    pub json: bool,
26    pub one_line: bool,
27    pub all: bool,
28    pub async_mode: bool,
29    pub jobs: Option<String>,
30    pub secret: Option<String>,
31}
32
33pub fn run(args: &RateLimitsOptions) -> Result<i32> {
34    let cached_mode = args.cached;
35    let mut one_line = args.one_line;
36    let mut all_mode = args.all;
37    let output_json = args.json;
38
39    let mut debug_mode = args.debug;
40    if !debug_mode
41        && let Ok(raw) = std::env::var("ZSH_DEBUG")
42        && raw.parse::<i64>().unwrap_or(0) >= 2
43    {
44        debug_mode = true;
45    }
46
47    if args.async_mode {
48        return run_async_mode(args, debug_mode);
49    }
50
51    if cached_mode {
52        one_line = true;
53        if output_json {
54            eprintln!("codex-rate-limits: --json is not supported with --cached");
55            return Ok(64);
56        }
57        if args.clear_cache {
58            eprintln!("codex-rate-limits: -c is not compatible with --cached");
59            return Ok(64);
60        }
61    }
62
63    if output_json && one_line {
64        eprintln!("codex-rate-limits: --one-line is not compatible with --json");
65        return Ok(64);
66    }
67
68    if args.clear_cache
69        && let Err(err) = cache::clear_starship_cache()
70    {
71        eprintln!("{err}");
72        return Ok(1);
73    }
74
75    if !all_mode
76        && !output_json
77        && !cached_mode
78        && args.secret.is_none()
79        && shared_env::env_truthy("CODEX_RATE_LIMITS_DEFAULT_ALL_ENABLED")
80    {
81        all_mode = true;
82    }
83
84    if all_mode {
85        if output_json {
86            eprintln!("codex-rate-limits: --json is not supported with --all");
87            return Ok(64);
88        }
89        if args.secret.is_some() {
90            eprintln!(
91                "codex-rate-limits: usage: codex-rate-limits [-c] [-d] [--cached] [--no-refresh-auth] [--json] [--one-line] [--all] [secret.json]"
92            );
93            return Ok(64);
94        }
95        return run_all_mode(args, cached_mode, debug_mode);
96    }
97
98    run_single_mode(args, cached_mode, one_line, output_json)
99}
100
101struct AsyncEvent {
102    secret_name: String,
103    line: Option<String>,
104    rc: i32,
105    err: String,
106}
107
108struct AsyncFetchResult {
109    line: Option<String>,
110    rc: i32,
111    err: String,
112}
113
114fn run_async_mode(args: &RateLimitsOptions, debug_mode: bool) -> Result<i32> {
115    if args.json {
116        eprintln!("codex-rate-limits: --async does not support --json");
117        return Ok(64);
118    }
119    if args.one_line {
120        eprintln!("codex-rate-limits: --async does not support --one-line");
121        return Ok(64);
122    }
123    if let Some(secret) = args.secret.as_deref() {
124        eprintln!(
125            "codex-rate-limits: --async does not accept positional args: {}",
126            secret
127        );
128        eprintln!(
129            "codex-rate-limits: hint: async always queries all secrets under CODEX_SECRET_DIR"
130        );
131        return Ok(64);
132    }
133    if args.clear_cache && args.cached {
134        eprintln!("codex-rate-limits: --async: -c is not compatible with --cached");
135        return Ok(64);
136    }
137
138    let jobs = args
139        .jobs
140        .as_deref()
141        .and_then(|raw| raw.parse::<i64>().ok())
142        .filter(|value| *value > 0)
143        .map(|value| value as usize)
144        .unwrap_or(5);
145
146    if args.clear_cache
147        && let Err(err) = cache::clear_starship_cache()
148    {
149        eprintln!("{err}");
150        return Ok(1);
151    }
152
153    let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
154    if !secret_dir.is_dir() {
155        eprintln!(
156            "codex-rate-limits-async: CODEX_SECRET_DIR not found: {}",
157            secret_dir.display()
158        );
159        return Ok(1);
160    }
161
162    let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
163        .flatten()
164        .map(|entry| entry.path())
165        .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
166        .collect();
167
168    if secret_files.is_empty() {
169        eprintln!(
170            "codex-rate-limits-async: no secrets found in {}",
171            secret_dir.display()
172        );
173        return Ok(1);
174    }
175
176    secret_files.sort();
177
178    let total = secret_files.len();
179    let progress = if total > 1 {
180        Some(Progress::new(
181            total as u64,
182            ProgressOptions::default()
183                .with_prefix("codex-rate-limits ")
184                .with_finish(ProgressFinish::Clear),
185        ))
186    } else {
187        None
188    };
189
190    let (tx, rx) = mpsc::channel();
191    let mut handles = Vec::new();
192    let mut index = 0usize;
193    let worker_count = jobs.min(total);
194
195    let spawn_worker = |path: PathBuf,
196                        cached_mode: bool,
197                        no_refresh_auth: bool,
198                        tx: mpsc::Sender<AsyncEvent>|
199     -> thread::JoinHandle<()> {
200        thread::spawn(move || {
201            let secret_name = path
202                .file_name()
203                .and_then(|name| name.to_str())
204                .unwrap_or("")
205                .to_string();
206            let result = async_fetch_one_line(&path, cached_mode, no_refresh_auth, &secret_name);
207            let _ = tx.send(AsyncEvent {
208                secret_name,
209                line: result.line,
210                rc: result.rc,
211                err: result.err,
212            });
213        })
214    };
215
216    while index < total && handles.len() < worker_count {
217        let path = secret_files[index].clone();
218        index += 1;
219        handles.push(spawn_worker(
220            path,
221            args.cached,
222            args.no_refresh_auth,
223            tx.clone(),
224        ));
225    }
226
227    let mut events: std::collections::HashMap<String, AsyncEvent> =
228        std::collections::HashMap::new();
229    while events.len() < total {
230        let event = match rx.recv() {
231            Ok(event) => event,
232            Err(_) => break,
233        };
234        if let Some(progress) = &progress {
235            progress.set_message(event.secret_name.clone());
236            progress.inc(1);
237        }
238        events.insert(event.secret_name.clone(), event);
239
240        if index < total {
241            let path = secret_files[index].clone();
242            index += 1;
243            handles.push(spawn_worker(
244                path,
245                args.cached,
246                args.no_refresh_auth,
247                tx.clone(),
248            ));
249        }
250    }
251
252    if let Some(progress) = progress {
253        progress.finish_and_clear();
254    }
255
256    drop(tx);
257    for handle in handles {
258        let _ = handle.join();
259    }
260
261    println!("\n🚦 Codex rate limits for all accounts\n");
262
263    let mut rc = 0;
264    let mut rows: Vec<Row> = Vec::new();
265    let mut window_labels = std::collections::HashSet::new();
266    let mut stderr_map: std::collections::HashMap<String, String> =
267        std::collections::HashMap::new();
268
269    for secret_file in &secret_files {
270        let secret_name = secret_file
271            .file_name()
272            .and_then(|name| name.to_str())
273            .unwrap_or("")
274            .to_string();
275
276        let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
277        let event = events.get(&secret_name);
278        if let Some(event) = event {
279            if !event.err.is_empty() {
280                stderr_map.insert(secret_name.clone(), event.err.clone());
281            }
282            if !args.cached && event.rc != 0 {
283                rc = 1;
284            }
285
286            if let Some(line) = &event.line
287                && let Some(parsed) = parse_one_line_output(line)
288            {
289                row.window_label = parsed.window_label.clone();
290                row.non_weekly_remaining = parsed.non_weekly_remaining;
291                row.weekly_remaining = parsed.weekly_remaining;
292                row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
293
294                if args.cached {
295                    if let Ok(cache_entry) = cache::read_cache_entry(secret_file) {
296                        row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
297                        row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
298                    }
299                } else {
300                    let values = crate::json::read_json(secret_file).ok();
301                    if let Some(values) = values {
302                        row.non_weekly_reset_epoch = crate::json::i64_at(
303                            &values,
304                            &["codex_rate_limits", "non_weekly_reset_at_epoch"],
305                        );
306                        row.weekly_reset_epoch = crate::json::i64_at(
307                            &values,
308                            &["codex_rate_limits", "weekly_reset_at_epoch"],
309                        );
310                    }
311                    if (row.non_weekly_reset_epoch.is_none() || row.weekly_reset_epoch.is_none())
312                        && let Ok(cache_entry) = cache::read_cache_entry(secret_file)
313                    {
314                        if row.non_weekly_reset_epoch.is_none() {
315                            row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
316                        }
317                        if row.weekly_reset_epoch.is_none() {
318                            row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
319                        }
320                    }
321                }
322
323                window_labels.insert(row.window_label.clone());
324                rows.push(row);
325                continue;
326            }
327        }
328
329        if !args.cached {
330            rc = 1;
331        }
332        rows.push(row);
333    }
334
335    let mut non_weekly_header = "Non-weekly".to_string();
336    let multiple_labels = window_labels.len() != 1;
337    if !multiple_labels && let Some(label) = window_labels.iter().next() {
338        non_weekly_header = label.clone();
339    }
340
341    let current_name = current_secret_basename(&secret_files);
342
343    let now_epoch = Utc::now().timestamp();
344
345    println!(
346        "{:<15}  {:>8}  {:>7}  {:>8}  {:>7}  {:<18}",
347        "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
348    );
349    println!("----------------------------------------------------------------------------");
350
351    rows.sort_by_key(|row| row.sort_key());
352
353    for row in rows {
354        let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
355            if row.non_weekly_remaining >= 0 {
356                format!("{}:{}%", row.window_label, row.non_weekly_remaining)
357            } else {
358                "-".to_string()
359            }
360        } else if row.non_weekly_remaining >= 0 {
361            format!("{}%", row.non_weekly_remaining)
362        } else {
363            "-".to_string()
364        };
365
366        let non_weekly_left = row
367            .non_weekly_reset_epoch
368            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
369            .unwrap_or_else(|| "-".to_string());
370        let weekly_left = row
371            .weekly_reset_epoch
372            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
373            .unwrap_or_else(|| "-".to_string());
374        let reset_display = row
375            .weekly_reset_epoch
376            .and_then(render::format_epoch_local_datetime_with_offset)
377            .unwrap_or_else(|| "-".to_string());
378
379        let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
380        let weekly_display = if row.weekly_remaining >= 0 {
381            ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
382        } else {
383            ansi::format_percent_cell("-", 8, None)
384        };
385
386        let is_current = current_name.as_deref() == Some(row.name.as_str());
387        let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
388
389        println!(
390            "{}  {}  {:>7}  {}  {:>7}  {:<18}",
391            name_display,
392            non_weekly_display,
393            non_weekly_left,
394            weekly_display,
395            weekly_left,
396            reset_display
397        );
398    }
399
400    if debug_mode {
401        let mut printed = false;
402        for secret_file in &secret_files {
403            let secret_name = secret_file
404                .file_name()
405                .and_then(|name| name.to_str())
406                .unwrap_or("")
407                .to_string();
408            if let Some(err) = stderr_map.get(&secret_name) {
409                if err.is_empty() {
410                    continue;
411                }
412                if !printed {
413                    printed = true;
414                    eprintln!();
415                    eprintln!("codex-rate-limits-async: per-account stderr (captured):");
416                }
417                eprintln!("---- {} ----", secret_name);
418                eprintln!("{err}");
419            }
420        }
421    }
422
423    Ok(rc)
424}
425
426fn async_fetch_one_line(
427    target_file: &Path,
428    cached_mode: bool,
429    no_refresh_auth: bool,
430    secret_name: &str,
431) -> AsyncFetchResult {
432    if cached_mode {
433        return fetch_one_line_cached(target_file);
434    }
435
436    let mut attempt = 1;
437    let max_attempts = 2;
438    let mut network_err: Option<String> = None;
439
440    let mut result = fetch_one_line_network(target_file, no_refresh_auth);
441    if !result.err.is_empty() {
442        network_err = Some(result.err.clone());
443    }
444
445    while attempt < max_attempts && result.rc == 3 {
446        thread::sleep(Duration::from_millis(250));
447        let next = fetch_one_line_network(target_file, no_refresh_auth);
448        if !next.err.is_empty() {
449            network_err = Some(next.err.clone());
450        }
451        result = next;
452        attempt += 1;
453        if result.rc != 3 {
454            break;
455        }
456    }
457
458    let mut errors: Vec<String> = Vec::new();
459    if let Some(err) = network_err {
460        errors.push(err);
461    }
462
463    let missing_line = result
464        .line
465        .as_ref()
466        .map(|line| line.trim().is_empty())
467        .unwrap_or(true);
468
469    if result.rc != 0 || missing_line {
470        let cached = fetch_one_line_cached(target_file);
471        if !cached.err.is_empty() {
472            errors.push(cached.err.clone());
473        }
474        if cached.rc == 0
475            && cached
476                .line
477                .as_ref()
478                .map(|line| !line.trim().is_empty())
479                .unwrap_or(false)
480        {
481            if result.rc != 0 {
482                errors.push(format!(
483                    "codex-rate-limits-async: falling back to cache for {} (rc={})",
484                    secret_name, result.rc
485                ));
486            }
487            result = AsyncFetchResult {
488                line: cached.line,
489                rc: 0,
490                err: String::new(),
491            };
492        }
493    }
494
495    let line = result.line.map(normalize_one_line);
496    let err = errors.join("\n");
497    AsyncFetchResult {
498        line,
499        rc: result.rc,
500        err,
501    }
502}
503
504fn fetch_one_line_network(target_file: &Path, no_refresh_auth: bool) -> AsyncFetchResult {
505    if !target_file.is_file() {
506        return AsyncFetchResult {
507            line: None,
508            rc: 1,
509            err: format!("codex-rate-limits: {} not found", target_file.display()),
510        };
511    }
512
513    let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
514        .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
515    let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
516    let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
517
518    let usage_request = UsageRequest {
519        target_file: target_file.to_path_buf(),
520        refresh_on_401: !no_refresh_auth,
521        base_url,
522        connect_timeout_seconds: connect_timeout,
523        max_time_seconds: max_time,
524    };
525
526    let usage = match fetch_usage(&usage_request) {
527        Ok(value) => value,
528        Err(err) => {
529            let msg = err.to_string();
530            if msg.contains("missing access_token") {
531                return AsyncFetchResult {
532                    line: None,
533                    rc: 2,
534                    err: format!(
535                        "codex-rate-limits: missing access_token in {}",
536                        target_file.display()
537                    ),
538                };
539            }
540            return AsyncFetchResult {
541                line: None,
542                rc: 3,
543                err: msg,
544            };
545        }
546    };
547
548    if let Err(err) = writeback::write_weekly(target_file, &usage.json) {
549        return AsyncFetchResult {
550            line: None,
551            rc: 4,
552            err: err.to_string(),
553        };
554    }
555
556    if is_auth_file(target_file) {
557        match sync_auth_silent() {
558            Ok((sync_rc, sync_err)) => {
559                if sync_rc != 0 {
560                    return AsyncFetchResult {
561                        line: None,
562                        rc: 5,
563                        err: sync_err.unwrap_or_default(),
564                    };
565                }
566            }
567            Err(_) => {
568                return AsyncFetchResult {
569                    line: None,
570                    rc: 1,
571                    err: String::new(),
572                };
573            }
574        }
575    }
576
577    let usage_data = match render::parse_usage(&usage.json) {
578        Some(value) => value,
579        None => {
580            return AsyncFetchResult {
581                line: None,
582                rc: 3,
583                err: "codex-rate-limits: invalid usage payload".to_string(),
584            };
585        }
586    };
587
588    let values = render::render_values(&usage_data);
589    let weekly = render::weekly_values(&values);
590
591    let fetched_at_epoch = Utc::now().timestamp();
592    if fetched_at_epoch > 0 {
593        let _ = cache::write_starship_cache(
594            target_file,
595            fetched_at_epoch,
596            &weekly.non_weekly_label,
597            weekly.non_weekly_remaining,
598            weekly.weekly_remaining,
599            weekly.weekly_reset_epoch,
600            weekly.non_weekly_reset_epoch,
601        );
602    }
603
604    AsyncFetchResult {
605        line: Some(format_one_line_output(
606            target_file,
607            &weekly.non_weekly_label,
608            weekly.non_weekly_remaining,
609            weekly.weekly_remaining,
610            weekly.weekly_reset_epoch,
611        )),
612        rc: 0,
613        err: String::new(),
614    }
615}
616
617fn fetch_one_line_cached(target_file: &Path) -> AsyncFetchResult {
618    match cache::read_cache_entry(target_file) {
619        Ok(entry) => AsyncFetchResult {
620            line: Some(format_one_line_output(
621                target_file,
622                &entry.non_weekly_label,
623                entry.non_weekly_remaining,
624                entry.weekly_remaining,
625                entry.weekly_reset_epoch,
626            )),
627            rc: 0,
628            err: String::new(),
629        },
630        Err(err) => AsyncFetchResult {
631            line: None,
632            rc: 1,
633            err: err.to_string(),
634        },
635    }
636}
637
638fn format_one_line_output(
639    target_file: &Path,
640    non_weekly_label: &str,
641    non_weekly_remaining: i64,
642    weekly_remaining: i64,
643    weekly_reset_epoch: i64,
644) -> String {
645    let prefix = cache::secret_name_for_target(target_file)
646        .map(|name| format!("{name} "))
647        .unwrap_or_default();
648    let weekly_reset_iso =
649        render::format_epoch_local_datetime(weekly_reset_epoch).unwrap_or_else(|| "?".to_string());
650
651    format!(
652        "{}{}:{}% W:{}% {}",
653        prefix, non_weekly_label, non_weekly_remaining, weekly_remaining, weekly_reset_iso
654    )
655}
656
657fn normalize_one_line(line: String) -> String {
658    line.replace(['\n', '\r', '\t'], " ")
659}
660
661fn sync_auth_silent() -> Result<(i32, Option<String>)> {
662    let auth_file = match crate::paths::resolve_auth_file() {
663        Some(path) => path,
664        None => return Ok((0, None)),
665    };
666
667    if !auth_file.is_file() {
668        return Ok((0, None));
669    }
670
671    let auth_key = match auth::identity_key_from_auth_file(&auth_file) {
672        Ok(Some(key)) => key,
673        _ => return Ok((0, None)),
674    };
675
676    let auth_last_refresh = auth::last_refresh_from_auth_file(&auth_file).unwrap_or(None);
677    let auth_hash = match crate::fs::sha256_file(&auth_file) {
678        Ok(hash) => hash,
679        Err(_) => {
680            return Ok((
681                1,
682                Some(format!("codex: failed to hash {}", auth_file.display())),
683            ));
684        }
685    };
686
687    if let Some(secret_dir) = crate::paths::resolve_secret_dir()
688        && let Ok(entries) = std::fs::read_dir(&secret_dir)
689    {
690        for entry in entries.flatten() {
691            let path = entry.path();
692            if path.extension().and_then(|s| s.to_str()) != Some("json") {
693                continue;
694            }
695            let candidate_key = match auth::identity_key_from_auth_file(&path) {
696                Ok(Some(key)) => key,
697                _ => continue,
698            };
699            if candidate_key != auth_key {
700                continue;
701            }
702
703            let secret_hash = match crate::fs::sha256_file(&path) {
704                Ok(hash) => hash,
705                Err(_) => {
706                    return Ok((1, Some(format!("codex: failed to hash {}", path.display()))));
707                }
708            };
709            if secret_hash == auth_hash {
710                continue;
711            }
712
713            let contents = std::fs::read(&auth_file)?;
714            crate::fs::write_atomic(&path, &contents, crate::fs::SECRET_FILE_MODE)?;
715
716            let timestamp_path = secret_timestamp_path(&path)?;
717            crate::fs::write_timestamp(&timestamp_path, auth_last_refresh.as_deref())?;
718        }
719    }
720
721    let auth_timestamp = secret_timestamp_path(&auth_file)?;
722    crate::fs::write_timestamp(&auth_timestamp, auth_last_refresh.as_deref())?;
723
724    Ok((0, None))
725}
726
727fn secret_timestamp_path(target_file: &Path) -> Result<PathBuf> {
728    let cache_dir = crate::paths::resolve_secret_cache_dir()
729        .ok_or_else(|| anyhow::anyhow!("CODEX_SECRET_CACHE_DIR not resolved"))?;
730    let name = target_file
731        .file_name()
732        .and_then(|name| name.to_str())
733        .unwrap_or("auth.json");
734    Ok(cache_dir.join(format!("{name}.timestamp")))
735}
736
737fn run_all_mode(args: &RateLimitsOptions, cached_mode: bool, debug_mode: bool) -> Result<i32> {
738    let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
739    if !secret_dir.is_dir() {
740        eprintln!(
741            "codex-rate-limits: CODEX_SECRET_DIR not found: {}",
742            secret_dir.display()
743        );
744        return Ok(1);
745    }
746
747    let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
748        .flatten()
749        .map(|entry| entry.path())
750        .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
751        .collect();
752
753    if secret_files.is_empty() {
754        eprintln!(
755            "codex-rate-limits: no secrets found in {}",
756            secret_dir.display()
757        );
758        return Ok(1);
759    }
760
761    secret_files.sort();
762
763    let current_name = current_secret_basename(&secret_files);
764
765    let total = secret_files.len();
766    let progress = if total > 1 {
767        Some(Progress::new(
768            total as u64,
769            ProgressOptions::default()
770                .with_prefix("codex-rate-limits ")
771                .with_finish(ProgressFinish::Clear),
772        ))
773    } else {
774        None
775    };
776
777    let mut rc = 0;
778    let mut rows: Vec<Row> = Vec::new();
779    let mut window_labels = std::collections::HashSet::new();
780
781    for secret_file in secret_files {
782        let secret_name = secret_file
783            .file_name()
784            .and_then(|name| name.to_str())
785            .unwrap_or("")
786            .to_string();
787        if let Some(progress) = &progress {
788            progress.set_message(secret_name.clone());
789        }
790
791        let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
792        let output =
793            match single_one_line(&secret_file, cached_mode, args.no_refresh_auth, debug_mode) {
794                Ok(Some(line)) => line,
795                Ok(None) => String::new(),
796                Err(_) => String::new(),
797            };
798
799        if output.is_empty() {
800            if !cached_mode {
801                rc = 1;
802            }
803            rows.push(row);
804            continue;
805        }
806
807        if let Some(parsed) = parse_one_line_output(&output) {
808            row.window_label = parsed.window_label.clone();
809            row.non_weekly_remaining = parsed.non_weekly_remaining;
810            row.weekly_remaining = parsed.weekly_remaining;
811            row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
812
813            if cached_mode {
814                if let Ok(cache_entry) = cache::read_cache_entry(&secret_file) {
815                    row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
816                    row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
817                }
818            } else {
819                let values = crate::json::read_json(&secret_file).ok();
820                if let Some(values) = values {
821                    row.non_weekly_reset_epoch = crate::json::i64_at(
822                        &values,
823                        &["codex_rate_limits", "non_weekly_reset_at_epoch"],
824                    );
825                    row.weekly_reset_epoch = crate::json::i64_at(
826                        &values,
827                        &["codex_rate_limits", "weekly_reset_at_epoch"],
828                    );
829                }
830            }
831
832            window_labels.insert(row.window_label.clone());
833            rows.push(row);
834        } else {
835            if !cached_mode {
836                rc = 1;
837            }
838            rows.push(row);
839        }
840
841        if let Some(progress) = &progress {
842            progress.inc(1);
843        }
844    }
845
846    if let Some(progress) = progress {
847        progress.finish_and_clear();
848    }
849
850    println!("\n🚦 Codex rate limits for all accounts\n");
851
852    let mut non_weekly_header = "Non-weekly".to_string();
853    let multiple_labels = window_labels.len() != 1;
854    if !multiple_labels && let Some(label) = window_labels.iter().next() {
855        non_weekly_header = label.clone();
856    }
857
858    let now_epoch = Utc::now().timestamp();
859
860    println!(
861        "{:<15}  {:>8}  {:>7}  {:>8}  {:>7}  {:<18}",
862        "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
863    );
864    println!("----------------------------------------------------------------------------");
865
866    rows.sort_by_key(|row| row.sort_key());
867
868    for row in rows {
869        let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
870            if row.non_weekly_remaining >= 0 {
871                format!("{}:{}%", row.window_label, row.non_weekly_remaining)
872            } else {
873                "-".to_string()
874            }
875        } else if row.non_weekly_remaining >= 0 {
876            format!("{}%", row.non_weekly_remaining)
877        } else {
878            "-".to_string()
879        };
880
881        let non_weekly_left = row
882            .non_weekly_reset_epoch
883            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
884            .unwrap_or_else(|| "-".to_string());
885        let weekly_left = row
886            .weekly_reset_epoch
887            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
888            .unwrap_or_else(|| "-".to_string());
889        let reset_display = row
890            .weekly_reset_epoch
891            .and_then(render::format_epoch_local_datetime_with_offset)
892            .unwrap_or_else(|| "-".to_string());
893
894        let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
895        let weekly_display = if row.weekly_remaining >= 0 {
896            ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
897        } else {
898            ansi::format_percent_cell("-", 8, None)
899        };
900
901        let is_current = current_name.as_deref() == Some(row.name.as_str());
902        let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
903
904        println!(
905            "{}  {}  {:>7}  {}  {:>7}  {:<18}",
906            name_display,
907            non_weekly_display,
908            non_weekly_left,
909            weekly_display,
910            weekly_left,
911            reset_display
912        );
913    }
914
915    Ok(rc)
916}
917
918fn current_secret_basename(secret_files: &[PathBuf]) -> Option<String> {
919    let auth_file = crate::paths::resolve_auth_file()?;
920    if !auth_file.is_file() {
921        return None;
922    }
923
924    let auth_key = auth::identity_key_from_auth_file(&auth_file).ok().flatten();
925    let auth_hash = crate::fs::sha256_file(&auth_file).ok();
926
927    if let Some(auth_hash) = auth_hash.as_deref() {
928        for secret_file in secret_files {
929            if let Ok(secret_hash) = crate::fs::sha256_file(secret_file)
930                && secret_hash == auth_hash
931                && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
932            {
933                return Some(name.trim_end_matches(".json").to_string());
934            }
935        }
936    }
937
938    if let Some(auth_key) = auth_key.as_deref() {
939        for secret_file in secret_files {
940            if let Ok(Some(candidate_key)) = auth::identity_key_from_auth_file(secret_file)
941                && candidate_key == auth_key
942                && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
943            {
944                return Some(name.trim_end_matches(".json").to_string());
945            }
946        }
947    }
948
949    None
950}
951
952fn run_single_mode(
953    args: &RateLimitsOptions,
954    cached_mode: bool,
955    one_line: bool,
956    output_json: bool,
957) -> Result<i32> {
958    let target_file = match resolve_target(args.secret.as_deref()) {
959        Ok(path) => path,
960        Err(code) => return Ok(code),
961    };
962
963    if !target_file.is_file() {
964        eprintln!("codex-rate-limits: {} not found", target_file.display());
965        return Ok(1);
966    }
967
968    if cached_mode {
969        match cache::read_cache_entry(&target_file) {
970            Ok(entry) => {
971                let weekly_reset_iso =
972                    render::format_epoch_local_datetime(entry.weekly_reset_epoch)
973                        .unwrap_or_else(|| "?".to_string());
974                let prefix = cache::secret_name_for_target(&target_file)
975                    .map(|name| format!("{name} "))
976                    .unwrap_or_default();
977                println!(
978                    "{}{}:{}% W:{}% {}",
979                    prefix,
980                    entry.non_weekly_label,
981                    entry.non_weekly_remaining,
982                    entry.weekly_remaining,
983                    weekly_reset_iso
984                );
985                return Ok(0);
986            }
987            Err(err) => {
988                eprintln!("{err}");
989                return Ok(1);
990            }
991        }
992    }
993
994    let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
995        .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
996    let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
997    let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
998
999    let usage_request = UsageRequest {
1000        target_file: target_file.clone(),
1001        refresh_on_401: !args.no_refresh_auth,
1002        base_url,
1003        connect_timeout_seconds: connect_timeout,
1004        max_time_seconds: max_time,
1005    };
1006
1007    let usage = match fetch_usage(&usage_request) {
1008        Ok(value) => value,
1009        Err(err) => {
1010            let msg = err.to_string();
1011            if msg.contains("missing access_token") {
1012                eprintln!(
1013                    "codex-rate-limits: missing access_token in {}",
1014                    target_file.display()
1015                );
1016                return Ok(2);
1017            }
1018            eprintln!("{msg}");
1019            return Ok(3);
1020        }
1021    };
1022
1023    if let Err(err) = writeback::write_weekly(&target_file, &usage.json) {
1024        eprintln!("{err}");
1025        return Ok(4);
1026    }
1027
1028    if is_auth_file(&target_file) {
1029        let sync_rc = auth::sync::run()?;
1030        if sync_rc != 0 {
1031            return Ok(5);
1032        }
1033    }
1034
1035    if output_json {
1036        println!("{}", usage.body);
1037        return Ok(0);
1038    }
1039
1040    let usage_data = match render::parse_usage(&usage.json) {
1041        Some(value) => value,
1042        None => {
1043            eprintln!("codex-rate-limits: invalid usage payload");
1044            return Ok(3);
1045        }
1046    };
1047
1048    let values = render::render_values(&usage_data);
1049    let weekly = render::weekly_values(&values);
1050
1051    let fetched_at_epoch = Utc::now().timestamp();
1052    if fetched_at_epoch > 0 {
1053        let _ = cache::write_starship_cache(
1054            &target_file,
1055            fetched_at_epoch,
1056            &weekly.non_weekly_label,
1057            weekly.non_weekly_remaining,
1058            weekly.weekly_remaining,
1059            weekly.weekly_reset_epoch,
1060            weekly.non_weekly_reset_epoch,
1061        );
1062    }
1063
1064    if one_line {
1065        let prefix = cache::secret_name_for_target(&target_file)
1066            .map(|name| format!("{name} "))
1067            .unwrap_or_default();
1068        let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
1069            .unwrap_or_else(|| "?".to_string());
1070
1071        println!(
1072            "{}{}:{}% W:{}% {}",
1073            prefix,
1074            weekly.non_weekly_label,
1075            weekly.non_weekly_remaining,
1076            weekly.weekly_remaining,
1077            weekly_reset_iso
1078        );
1079        return Ok(0);
1080    }
1081
1082    println!("Rate limits remaining");
1083    let primary_reset = render::format_epoch_local_datetime(values.primary_reset_epoch)
1084        .unwrap_or_else(|| "?".to_string());
1085    let secondary_reset = render::format_epoch_local_datetime(values.secondary_reset_epoch)
1086        .unwrap_or_else(|| "?".to_string());
1087
1088    println!(
1089        "{} {}% • {}",
1090        values.primary_label, values.primary_remaining, primary_reset
1091    );
1092    println!(
1093        "{} {}% • {}",
1094        values.secondary_label, values.secondary_remaining, secondary_reset
1095    );
1096
1097    Ok(0)
1098}
1099
1100fn single_one_line(
1101    target_file: &Path,
1102    cached_mode: bool,
1103    no_refresh_auth: bool,
1104    debug_mode: bool,
1105) -> Result<Option<String>> {
1106    if !target_file.is_file() {
1107        if debug_mode {
1108            eprintln!("codex-rate-limits: {} not found", target_file.display());
1109        }
1110        return Ok(None);
1111    }
1112
1113    if cached_mode {
1114        return match cache::read_cache_entry(target_file) {
1115            Ok(entry) => {
1116                let weekly_reset_iso =
1117                    render::format_epoch_local_datetime(entry.weekly_reset_epoch)
1118                        .unwrap_or_else(|| "?".to_string());
1119                let prefix = cache::secret_name_for_target(target_file)
1120                    .map(|name| format!("{name} "))
1121                    .unwrap_or_default();
1122                Ok(Some(format!(
1123                    "{}{}:{}% W:{}% {}",
1124                    prefix,
1125                    entry.non_weekly_label,
1126                    entry.non_weekly_remaining,
1127                    entry.weekly_remaining,
1128                    weekly_reset_iso
1129                )))
1130            }
1131            Err(err) => {
1132                if debug_mode {
1133                    eprintln!("{err}");
1134                }
1135                Ok(None)
1136            }
1137        };
1138    }
1139
1140    let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1141        .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1142    let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1143    let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1144
1145    let usage_request = UsageRequest {
1146        target_file: target_file.to_path_buf(),
1147        refresh_on_401: !no_refresh_auth,
1148        base_url,
1149        connect_timeout_seconds: connect_timeout,
1150        max_time_seconds: max_time,
1151    };
1152
1153    let usage = match fetch_usage(&usage_request) {
1154        Ok(value) => value,
1155        Err(err) => {
1156            if debug_mode {
1157                eprintln!("{err}");
1158            }
1159            return Ok(None);
1160        }
1161    };
1162
1163    let _ = writeback::write_weekly(target_file, &usage.json);
1164    if is_auth_file(target_file) {
1165        let _ = auth::sync::run();
1166    }
1167
1168    let usage_data = match render::parse_usage(&usage.json) {
1169        Some(value) => value,
1170        None => return Ok(None),
1171    };
1172    let values = render::render_values(&usage_data);
1173    let weekly = render::weekly_values(&values);
1174    let prefix = cache::secret_name_for_target(target_file)
1175        .map(|name| format!("{name} "))
1176        .unwrap_or_default();
1177    let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
1178        .unwrap_or_else(|| "?".to_string());
1179
1180    Ok(Some(format!(
1181        "{}{}:{}% W:{}% {}",
1182        prefix,
1183        weekly.non_weekly_label,
1184        weekly.non_weekly_remaining,
1185        weekly.weekly_remaining,
1186        weekly_reset_iso
1187    )))
1188}
1189
1190fn resolve_target(secret: Option<&str>) -> std::result::Result<PathBuf, i32> {
1191    if let Some(secret_name) = secret {
1192        if secret_name.is_empty() || secret_name.contains('/') || secret_name.contains("..") {
1193            eprintln!("codex-rate-limits: invalid secret file name: {secret_name}");
1194            return Err(64);
1195        }
1196        let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
1197        return Ok(secret_dir.join(secret_name));
1198    }
1199
1200    if let Some(auth_file) = crate::paths::resolve_auth_file() {
1201        return Ok(auth_file);
1202    }
1203
1204    Err(1)
1205}
1206
1207fn is_auth_file(target_file: &Path) -> bool {
1208    if let Some(auth_file) = crate::paths::resolve_auth_file() {
1209        return auth_file == target_file;
1210    }
1211    false
1212}
1213
1214fn env_timeout(key: &str, default: u64) -> u64 {
1215    std::env::var(key)
1216        .ok()
1217        .and_then(|raw| raw.parse::<u64>().ok())
1218        .unwrap_or(default)
1219}
1220
1221struct Row {
1222    name: String,
1223    window_label: String,
1224    non_weekly_remaining: i64,
1225    non_weekly_reset_epoch: Option<i64>,
1226    weekly_remaining: i64,
1227    weekly_reset_epoch: Option<i64>,
1228    weekly_reset_iso: String,
1229}
1230
1231impl Row {
1232    fn empty(name: String) -> Self {
1233        Self {
1234            name,
1235            window_label: String::new(),
1236            non_weekly_remaining: -1,
1237            non_weekly_reset_epoch: None,
1238            weekly_remaining: -1,
1239            weekly_reset_epoch: None,
1240            weekly_reset_iso: String::new(),
1241        }
1242    }
1243
1244    fn sort_key(&self) -> (i32, i64, String) {
1245        if let Some(epoch) = self.weekly_reset_epoch {
1246            (0, epoch, self.name.clone())
1247        } else {
1248            (1, i64::MAX, self.name.clone())
1249        }
1250    }
1251}
1252
1253struct ParsedOneLine {
1254    window_label: String,
1255    non_weekly_remaining: i64,
1256    weekly_remaining: i64,
1257    weekly_reset_iso: String,
1258}
1259
1260fn parse_one_line_output(line: &str) -> Option<ParsedOneLine> {
1261    let parts: Vec<&str> = line.split_whitespace().collect();
1262    if parts.len() < 3 {
1263        return None;
1264    }
1265
1266    fn parse_fields(
1267        window_field: &str,
1268        weekly_field: &str,
1269        reset_iso: String,
1270    ) -> Option<ParsedOneLine> {
1271        let window_label = window_field
1272            .split(':')
1273            .next()?
1274            .trim_matches('"')
1275            .to_string();
1276        let non_weekly_remaining = window_field.split(':').nth(1)?;
1277        let non_weekly_remaining = non_weekly_remaining
1278            .trim_end_matches('%')
1279            .parse::<i64>()
1280            .ok()?;
1281
1282        let weekly_remaining = weekly_field.trim_start_matches("W:").trim_end_matches('%');
1283        let weekly_remaining = weekly_remaining.parse::<i64>().ok()?;
1284
1285        Some(ParsedOneLine {
1286            window_label,
1287            non_weekly_remaining,
1288            weekly_remaining,
1289            weekly_reset_iso: reset_iso,
1290        })
1291    }
1292
1293    let len = parts.len();
1294    let window_field = parts[len - 3];
1295    let weekly_field = parts[len - 2];
1296    let reset_iso = parts[len - 1].to_string();
1297
1298    if let Some(parsed) = parse_fields(window_field, weekly_field, reset_iso) {
1299        return Some(parsed);
1300    }
1301
1302    if len < 4 {
1303        return None;
1304    }
1305
1306    parse_fields(
1307        parts[len - 4],
1308        parts[len - 3],
1309        format!("{} {}", parts[len - 2], parts[len - 1]),
1310    )
1311}