use crate::usage_limits::{UsageLimit, UsageLimitConfig, UsageLimitScope};
use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc};
use regex::Regex;
use std::sync::OnceLock;
pub const DEFAULT_PATTERNS: &[&str] = &[
r"(?i)you('ve| have)? (?:hit|reached) (?:your |the )?usage limit",
r"(?i)usage limit reset",
];
const TRY_AGAIN_AT_PATTERN: &str =
r"(?i)try again (?:at|after) (?P<when>[^.\n;]+?)(?:\s*[.;\n]|\s*$)";
const DATE_FORMATS: &[&str] = &[
"%b %d, %Y %I:%M %p", "%b %d %Y %I:%M %p",
"%B %d, %Y %I:%M %p", "%B %d %Y %I:%M %p",
"%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%SZ", ];
static COMPILED: OnceLock<Vec<Regex>> = OnceLock::new();
static TRY_AGAIN_RE: OnceLock<Regex> = OnceLock::new();
static STRIP_ORDINAL: OnceLock<Regex> = OnceLock::new();
static PAD_HOUR_RE: OnceLock<Regex> = OnceLock::new();
fn compiled_defaults() -> &'static [Regex] {
COMPILED.get_or_init(|| {
DEFAULT_PATTERNS
.iter()
.map(|src| Regex::new(src).expect("Codex usage-limit default pattern is valid regex"))
.collect()
})
}
fn strip_ordinal() -> &'static Regex {
STRIP_ORDINAL.get_or_init(|| Regex::new(r"(\d+)(st|nd|rd|th)").unwrap())
}
fn pad_hour_regex() -> &'static Regex {
PAD_HOUR_RE.get_or_init(|| Regex::new(r"\b(\d):(\d\d)\b").unwrap())
}
fn try_again_re() -> &'static Regex {
TRY_AGAIN_RE.get_or_init(|| Regex::new(TRY_AGAIN_AT_PATTERN).unwrap())
}
fn parse_reset(when: &str) -> Option<DateTime<Utc>> {
let cleaned = strip_ordinal().replace_all(when.trim(), "$1").into_owned();
let padded = pad_hour_regex()
.replace_all(&cleaned, "0$1:$2")
.into_owned();
let normalized = padded.split_whitespace().collect::<Vec<_>>().join(" ");
for fmt in DATE_FORMATS {
if let Ok(naive) = NaiveDateTime::parse_from_str(&normalized, fmt) {
if let chrono::LocalResult::Single(local) = Local.from_local_datetime(&naive) {
return Some(local.with_timezone(&Utc));
}
}
}
None
}
fn extract_reset_from_line(line: &str) -> Option<DateTime<Utc>> {
try_again_re()
.captures(line)
.and_then(|cap| cap.name("when"))
.and_then(|m| parse_reset(m.as_str()))
}
fn compile_extras(extras: &[String]) -> Vec<Regex> {
extras
.iter()
.filter_map(|src| match Regex::new(src) {
Ok(r) => Some(r),
Err(e) => {
log::warn!("Ignoring invalid codex usage-limit pattern {src:?}: {e}");
None
}
})
.collect()
}
fn match_against(re: &Regex, line: &str) -> Option<UsageLimit> {
let cap = re.captures(line)?;
let raw = cap.get(0)?.as_str().to_string();
let reset_at = extract_reset_from_line(line);
Some(UsageLimit {
provider: "codex",
scope: UsageLimitScope::Session,
reset_at,
raw,
})
}
pub fn detect_text(line: &str, cfg: &UsageLimitConfig) -> Option<UsageLimit> {
if !cfg.enabled_for("codex") {
return None;
}
for re in compiled_defaults() {
if let Some(hit) = match_against(re, line) {
return Some(hit);
}
}
for re in compile_extras(cfg.extra_patterns_for("codex")) {
if let Some(hit) = match_against(&re, line) {
return Some(hit);
}
}
None
}
pub fn detect_json(value: &serde_json::Value, cfg: &UsageLimitConfig) -> Option<UsageLimit> {
if !cfg.enabled_for("codex") {
return None;
}
let kind = value.get("type").and_then(|v| v.as_str())?;
if !matches!(kind, "error" | "turn.failed" | "turn_failed") {
return None;
}
let mut candidates: Vec<&str> = Vec::new();
for k in ["message", "error", "detail", "reason"] {
if let Some(s) = value.get(k).and_then(|v| v.as_str()) {
candidates.push(s);
}
}
if let Some(s) = value
.get("error")
.and_then(|v| v.get("message"))
.and_then(|v| v.as_str())
{
candidates.push(s);
}
for cand in candidates {
if let Some(hit) = detect_text(cand, cfg) {
return Some(hit);
}
}
None
}
#[cfg(test)]
#[path = "codex_usage_limits_tests.rs"]
mod tests;