use crate::usage_limits::{UsageLimit, UsageLimitConfig, UsageLimitScope};
use chrono::{DateTime, Duration, Utc};
use regex::Regex;
use std::sync::OnceLock;
pub const DEFAULT_PATTERNS: &[&str] = &[
r"\[API Error:.*?\bcode\s*:\s*429",
r"RESOURCE_EXHAUSTED",
r"RATE_LIMIT_EXCEEDED",
r"(?i)you've hit the API rate limit",
];
static COMPILED: OnceLock<Vec<Regex>> = OnceLock::new();
static RETRY_DELAY_RE: OnceLock<Regex> = OnceLock::new();
static QUOTA_NAME_RE: OnceLock<Regex> = OnceLock::new();
fn compiled_defaults() -> &'static [Regex] {
COMPILED.get_or_init(|| {
DEFAULT_PATTERNS
.iter()
.map(|src| Regex::new(src).expect("Gemini usage-limit default pattern is valid regex"))
.collect()
})
}
fn retry_delay_regex() -> &'static Regex {
RETRY_DELAY_RE.get_or_init(|| Regex::new(r#""retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s""#).unwrap())
}
fn quota_name_regex() -> &'static Regex {
QUOTA_NAME_RE.get_or_init(|| Regex::new(r#""quota_limit"\s*:\s*"([^"]+)""#).unwrap())
}
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 gemini usage-limit pattern {src:?}: {e}");
None
}
})
.collect()
}
fn parse_retry_delay(blob: &str) -> Option<DateTime<Utc>> {
let cap = retry_delay_regex().captures(blob)?;
let secs: f64 = cap.get(1)?.as_str().parse().ok()?;
Some(Utc::now() + Duration::milliseconds((secs * 1000.0) as i64))
}
fn scope_from_blob(blob: &str) -> UsageLimitScope {
let lower = blob.to_lowercase();
if let Some(cap) = quota_name_regex().captures(blob) {
let name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
if name.to_lowercase().contains("perday") {
return UsageLimitScope::Daily;
}
if name.to_lowercase().contains("perweek") {
return UsageLimitScope::Weekly;
}
}
if lower.contains("per day") || lower.contains("perday") {
UsageLimitScope::Daily
} else if lower.contains("per week") || lower.contains("perweek") {
UsageLimitScope::Weekly
} else {
UsageLimitScope::Session
}
}
fn match_any(line: &str, patterns: &[Regex]) -> Option<usize> {
for (i, re) in patterns.iter().enumerate() {
if re.is_match(line) {
return Some(i);
}
}
None
}
pub fn detect_text(line: &str, cfg: &UsageLimitConfig) -> Option<UsageLimit> {
if !cfg.enabled_for("gemini") {
return None;
}
let matched = match_any(line, compiled_defaults()).is_some()
|| match_any(line, &compile_extras(cfg.extra_patterns_for("gemini"))).is_some();
if !matched {
return None;
}
Some(UsageLimit {
provider: "gemini",
scope: scope_from_blob(line),
reset_at: parse_retry_delay(line),
raw: line.to_string(),
})
}
pub fn detect_json(value: &serde_json::Value, cfg: &UsageLimitConfig) -> Option<UsageLimit> {
if !cfg.enabled_for("gemini") {
return None;
}
let inner = match value {
serde_json::Value::Array(arr) => arr.first()?,
other => other,
};
let error = inner.get("error")?;
let code = error.get("code").and_then(|v| v.as_i64()).unwrap_or(0);
let status = error.get("status").and_then(|v| v.as_str()).unwrap_or("");
if code != 429 && status != "RESOURCE_EXHAUSTED" {
return None;
}
let raw = error.to_string();
let scope = scope_from_blob(&raw);
Some(UsageLimit {
provider: "gemini",
scope,
reset_at: parse_retry_delay(&raw),
raw,
})
}
#[cfg(test)]
#[path = "gemini_usage_limits_tests.rs"]
mod tests;