zag_agent/providers/
codex_usage_limits.rs1use crate::usage_limits::{UsageLimit, UsageLimitConfig, UsageLimitScope};
19use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc};
20use regex::Regex;
21use std::sync::OnceLock;
22
23pub const DEFAULT_PATTERNS: &[&str] = &[
28 r"(?i)you('ve| have)? (?:hit|reached) (?:your |the )?usage limit",
30 r"(?i)usage limit reset",
32];
33
34const TRY_AGAIN_AT_PATTERN: &str =
37 r"(?i)try again (?:at|after) (?P<when>[^.\n;]+?)(?:\s*[.;\n]|\s*$)";
38
39const DATE_FORMATS: &[&str] = &[
44 "%b %d, %Y %I:%M %p", "%b %d %Y %I:%M %p",
46 "%B %d, %Y %I:%M %p", "%B %d %Y %I:%M %p",
48 "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%SZ", ];
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 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 let padded = pad_hour_regex()
83 .replace_all(&cleaned, "0$1:$2")
84 .into_owned();
85 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 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 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
134pub 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
155pub 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 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 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;