Skip to main content

zag_agent/providers/
codex_usage_limits.rs

1//! Codex usage-limit detection.
2//!
3//! Single file to update when the Codex CLI changes its limit output.
4//!
5//! Codex emits this in its `--json` NDJSON stream as the `message` field of an
6//! `error` or `turn.failed` event:
7//!
8//! ```text
9//! You've hit your usage limit. To get more access now, send a request to your admin
10//! or try again at Mar 20th, 2027 3:36 PM.
11//! ```
12//!
13//! The reset timestamp is a local-timezone human date string — we parse it
14//! through several format candidates via chrono and convert to UTC. If parsing
15//! fails, `reset_at` is `None` and the scheduler falls back to
16//! `default_fallback_secs`.
17
18use crate::usage_limits::{UsageLimit, UsageLimitConfig, UsageLimitScope};
19use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc};
20use regex::Regex;
21use std::sync::OnceLock;
22
23/// Detection patterns — these answer "is there a usage limit here?"
24///
25/// Reset-time extraction is decoupled (see `TRY_AGAIN_AT_PATTERN` below) so the
26/// detector still fires even when the exact wording around the date drifts.
27pub const DEFAULT_PATTERNS: &[&str] = &[
28    // Canonical message text.
29    r"(?i)you('ve| have)? (?:hit|reached) (?:your |the )?usage limit",
30    // Variant some Codex versions emit on weekly/global caps.
31    r"(?i)usage limit reset",
32];
33
34/// Pattern for extracting the reset-time phrase. Matches "try again at <when>"
35/// or "try again after <when>" up to the next sentence boundary.
36const TRY_AGAIN_AT_PATTERN: &str =
37    r"(?i)try again (?:at|after) (?P<when>[^.\n;]+?)(?:\s*[.;\n]|\s*$)";
38
39/// Date format candidates tried in order against the captured "when" phrase.
40///
41/// We strip ordinal suffixes (`1st`, `2nd`, `3rd`, `Nth`) and zero-pad
42/// single-digit hours before parsing so plain chrono format strings work.
43const DATE_FORMATS: &[&str] = &[
44    "%b %d, %Y %I:%M %p", // "Mar 20, 2027 03:36 PM"
45    "%b %d %Y %I:%M %p",
46    "%B %d, %Y %I:%M %p", // "March 20, 2027 03:36 PM"
47    "%B %d %Y %I:%M %p",
48    "%Y-%m-%d %H:%M",     // "2027-03-20 15:36"
49    "%Y-%m-%dT%H:%M:%SZ", // RFC3339-ish
50];
51
52static COMPILED: OnceLock<Vec<Regex>> = OnceLock::new();
53static TRY_AGAIN_RE: OnceLock<Regex> = OnceLock::new();
54static STRIP_ORDINAL: OnceLock<Regex> = OnceLock::new();
55static PAD_HOUR_RE: OnceLock<Regex> = OnceLock::new();
56
57fn compiled_defaults() -> &'static [Regex] {
58    COMPILED.get_or_init(|| {
59        DEFAULT_PATTERNS
60            .iter()
61            .map(|src| Regex::new(src).expect("Codex usage-limit default pattern is valid regex"))
62            .collect()
63    })
64}
65
66fn strip_ordinal() -> &'static Regex {
67    STRIP_ORDINAL.get_or_init(|| Regex::new(r"(\d+)(st|nd|rd|th)").unwrap())
68}
69
70fn pad_hour_regex() -> &'static Regex {
71    // Matches a single-digit hour before `:MM` so we can zero-pad it.
72    PAD_HOUR_RE.get_or_init(|| Regex::new(r"\b(\d):(\d\d)\b").unwrap())
73}
74
75fn try_again_re() -> &'static Regex {
76    TRY_AGAIN_RE.get_or_init(|| Regex::new(TRY_AGAIN_AT_PATTERN).unwrap())
77}
78
79fn parse_reset(when: &str) -> Option<DateTime<Utc>> {
80    let cleaned = strip_ordinal().replace_all(when.trim(), "$1").into_owned();
81    // Zero-pad single-digit hour: "3:36 PM" → "03:36 PM".
82    let padded = pad_hour_regex()
83        .replace_all(&cleaned, "0$1:$2")
84        .into_owned();
85    // Collapse runs of whitespace — `chrono` is strict about this.
86    let normalized = padded.split_whitespace().collect::<Vec<_>>().join(" ");
87
88    for fmt in DATE_FORMATS {
89        if let Ok(naive) = NaiveDateTime::parse_from_str(&normalized, fmt) {
90            // Interpret the local-TZ string as the user's local time.
91            if let chrono::LocalResult::Single(local) = Local.from_local_datetime(&naive) {
92                return Some(local.with_timezone(&Utc));
93            }
94        }
95    }
96    None
97}
98
99fn extract_reset_from_line(line: &str) -> Option<DateTime<Utc>> {
100    try_again_re()
101        .captures(line)
102        .and_then(|cap| cap.name("when"))
103        .and_then(|m| parse_reset(m.as_str()))
104}
105
106fn compile_extras(extras: &[String]) -> Vec<Regex> {
107    extras
108        .iter()
109        .filter_map(|src| match Regex::new(src) {
110            Ok(r) => Some(r),
111            Err(e) => {
112                log::warn!("Ignoring invalid codex usage-limit pattern {src:?}: {e}");
113                None
114            }
115        })
116        .collect()
117}
118
119fn match_against(re: &Regex, line: &str) -> Option<UsageLimit> {
120    let cap = re.captures(line)?;
121    let raw = cap.get(0)?.as_str().to_string();
122    // Reset time is parsed from the full line, not just the matched substring,
123    // because the "try again at <date>" phrase often sits after the detection
124    // anchor.
125    let reset_at = extract_reset_from_line(line);
126    Some(UsageLimit {
127        provider: "codex",
128        scope: UsageLimitScope::Session,
129        reset_at,
130        raw,
131    })
132}
133
134/// Scan a single line of text (or message) for a Codex usage-limit signal.
135pub fn detect_text(line: &str, cfg: &UsageLimitConfig) -> Option<UsageLimit> {
136    if !cfg.enabled_for("codex") {
137        return None;
138    }
139
140    for re in compiled_defaults() {
141        if let Some(hit) = match_against(re, line) {
142            return Some(hit);
143        }
144    }
145
146    for re in compile_extras(cfg.extra_patterns_for("codex")) {
147        if let Some(hit) = match_against(&re, line) {
148            return Some(hit);
149        }
150    }
151
152    None
153}
154
155/// Scan a Codex NDJSON event JSON value for a usage-limit signal.
156///
157/// Codex `--json` emits `{"type":"error", ...}` / `{"type":"turn.failed", ...}`
158/// envelopes carrying the limit message in a `message` or `error` field.
159pub fn detect_json(value: &serde_json::Value, cfg: &UsageLimitConfig) -> Option<UsageLimit> {
160    if !cfg.enabled_for("codex") {
161        return None;
162    }
163    let kind = value.get("type").and_then(|v| v.as_str())?;
164    if !matches!(kind, "error" | "turn.failed" | "turn_failed") {
165        return None;
166    }
167
168    // Collect candidate text fields.
169    let mut candidates: Vec<&str> = Vec::new();
170    for k in ["message", "error", "detail", "reason"] {
171        if let Some(s) = value.get(k).and_then(|v| v.as_str()) {
172            candidates.push(s);
173        }
174    }
175    // Also try nested `error.message`.
176    if let Some(s) = value
177        .get("error")
178        .and_then(|v| v.get("message"))
179        .and_then(|v| v.as_str())
180    {
181        candidates.push(s);
182    }
183
184    for cand in candidates {
185        if let Some(hit) = detect_text(cand, cfg) {
186            return Some(hit);
187        }
188    }
189    None
190}
191
192#[cfg(test)]
193#[path = "codex_usage_limits_tests.rs"]
194mod tests;