use crate::types::AgentKind;
use chrono::{Datelike, Duration, Local, NaiveDateTime};
mod cursor;
pub(crate) struct QuotaSignature {
pub(crate) agent: AgentKind,
pub(crate) needle: &'static str,
pub(crate) recovery: QuotaRecovery,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum QuotaRecovery {
After(i64),
NeedsHuman,
Windowed,
}
pub(crate) const QUOTA_SIGNATURES: &[QuotaSignature] = &[
QuotaSignature { agent: AgentKind::Claude, needle: "you've hit your limit", recovery: QuotaRecovery::After(300) },
QuotaSignature { agent: AgentKind::Claude, needle: "claude ai usage limit reached", recovery: QuotaRecovery::After(300) },
QuotaSignature { agent: AgentKind::Qwen, needle: "quota has been exhausted", recovery: QuotaRecovery::After(300) },
QuotaSignature { agent: AgentKind::Qwen, needle: "quota exhausted", recovery: QuotaRecovery::After(300) },
QuotaSignature { agent: AgentKind::Droid, needle: "weekly standard usage limit", recovery: QuotaRecovery::After(1440) },
QuotaSignature { agent: AgentKind::Droid, needle: "standard usage limit", recovery: QuotaRecovery::After(300) },
QuotaSignature {
agent: AgentKind::Droid,
needle: "droid core usage limit",
recovery: QuotaRecovery::After(2880),
},
QuotaSignature { agent: AgentKind::Cursor, needle: cursor::NEEDLE, recovery: QuotaRecovery::After(43_200) },
QuotaSignature { agent: AgentKind::Codex, needle: "hit your usage limit", recovery: QuotaRecovery::After(300) },
QuotaSignature { agent: AgentKind::Oz, needle: "quota limit reached", recovery: QuotaRecovery::After(60) },
QuotaSignature { agent: AgentKind::Oz, needle: "credentials are invalid", recovery: QuotaRecovery::NeedsHuman },
QuotaSignature { agent: AgentKind::Antigravity, needle: "individual quota reached", recovery: QuotaRecovery::After(60) },
QuotaSignature { agent: AgentKind::OpenCode, needle: "insufficient balance", recovery: QuotaRecovery::NeedsHuman },
QuotaSignature { agent: AgentKind::MiMoCode, needle: "insufficient balance", recovery: QuotaRecovery::NeedsHuman },
QuotaSignature { agent: AgentKind::Kilo, needle: "insufficient balance", recovery: QuotaRecovery::NeedsHuman },
QuotaSignature { agent: AgentKind::Droid, needle: "reload your tokens", recovery: QuotaRecovery::NeedsHuman },
QuotaSignature { agent: AgentKind::Gemini, needle: "ineligibletier", recovery: QuotaRecovery::NeedsHuman },
QuotaSignature { agent: AgentKind::Gemini, needle: "resource exhausted", recovery: QuotaRecovery::After(60) },
QuotaSignature { agent: AgentKind::Gemini, needle: "resourceexhausted", recovery: QuotaRecovery::After(60) },
QuotaSignature { agent: AgentKind::Antigravity, needle: "migrate to antigravity", recovery: QuotaRecovery::NeedsHuman },
QuotaSignature { agent: AgentKind::Cursor, needle: "quota exceeded for this workspace", recovery: QuotaRecovery::After(300) },
QuotaSignature { agent: AgentKind::Cursor, needle: "you're out of usage", recovery: QuotaRecovery::Windowed },
QuotaSignature { agent: AgentKind::Copilot, needle: "premium request limit", recovery: QuotaRecovery::NeedsHuman },
QuotaSignature { agent: AgentKind::Copilot, needle: "exceeded your monthly quota", recovery: QuotaRecovery::NeedsHuman },
QuotaSignature { agent: AgentKind::Grok, needle: "usage balance exhausted", recovery: QuotaRecovery::Windowed },
];
pub(crate) fn match_quota_signature_for_agent(
message: &str,
agent: AgentKind,
) -> Option<QuotaRecovery> {
let lower = message.to_lowercase();
QUOTA_SIGNATURES
.iter()
.find(|signature| signature.agent == agent && cursor::matches(&lower, signature.needle))
.map(|signature| signature.recovery)
}
pub(crate) fn match_quota_signature(message: &str) -> Option<(AgentKind, QuotaRecovery)> {
match_quota_signature_with_agent(message, None)
}
pub(crate) fn match_quota_signature_with_agent(
message: &str,
preferred: Option<AgentKind>,
) -> Option<(AgentKind, QuotaRecovery)> {
let lower = message.to_lowercase();
if let Some(agent) = preferred {
if let Some(recovery) = match_quota_signature_for_agent(message, agent) {
return Some((agent, recovery));
}
}
QUOTA_SIGNATURES
.iter()
.find(|signature| cursor::matches(&lower, signature.needle))
.map(|signature| (signature.agent, signature.recovery))
}
pub(crate) fn parse_relative_recovery(message: &str) -> Option<NaiveDateTime> {
let lower = message.to_lowercase();
let now = Local::now().naive_local();
if let Some(at) = crate::routing::rate_limit::parse_claude_reset_clock(&lower) { return Some(at); }
if let Some(at) = cursor::cycle_end(&lower) {
return Some(at);
}
if let Some(duration) = parse_compact_duration(&lower) {
return Some(now + duration);
}
if let Some(duration) = parse_resets_in(&lower) {
return Some(now + duration);
}
if let Some(at) = parse_reset_at_utc(&lower) {
return Some(at);
}
if let Some(hours) = parse_hyphenated_hours(&lower) {
return Some(now + Duration::hours(hours));
}
None
}
fn parse_reset_at_utc(lower: &str) -> Option<NaiveDateTime> {
let idx = lower.find("reset at ")?;
let rest = lower[idx + "reset at ".len()..].trim();
let stamp: String = rest.chars().take(14).collect();
let year = Local::now().naive_local().year();
let candidate =
NaiveDateTime::parse_from_str(&format!("{year}-{stamp}"), "%Y-%m-%d %H:%M:%S").ok()?;
let utc_offset = Local::now().offset().local_minus_utc();
Some(candidate + Duration::seconds(i64::from(utc_offset)))
}
fn parse_compact_duration(lower: &str) -> Option<Duration> {
let idx = lower.find("resets in ").or_else(|| lower.find("try again in "))?;
let rest = lower[idx..].split_once(" in ")?.1.trim();
let token: String = rest
.chars()
.take_while(|c| c.is_ascii_digit() || matches!(c, 'd' | 'h' | 'm' | 's'))
.collect();
if token.is_empty() || !token.chars().any(|c| c.is_ascii_alphabetic()) {
return None;
}
let mut total = Duration::zero();
let mut number = String::new();
for ch in token.chars() {
if ch.is_ascii_digit() {
number.push(ch);
continue;
}
let amount: i64 = number.parse().ok()?;
number.clear();
total += match ch {
'd' => Duration::days(amount),
'h' => Duration::hours(amount),
'm' => Duration::minutes(amount),
's' => Duration::seconds(amount),
_ => return None,
};
}
(total > Duration::zero()).then_some(total)
}
fn parse_resets_in(lower: &str) -> Option<Duration> {
let idx = lower.find("resets in ").or_else(|| lower.find("try again in "))?;
let rest = &lower[idx..];
let rest = rest.split_once(" in ")?.1;
let mut parts = rest.split_whitespace();
let amount: i64 = parts.next()?.parse().ok()?;
let unit = parts.next()?.trim_end_matches([',', '.', ')']);
unit_to_duration(unit, amount)
}
fn parse_hyphenated_hours(lower: &str) -> Option<i64> {
let idx = lower.find("-hour")?;
let head = &lower[..idx];
let digits: String = head.chars().rev().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
return None;
}
digits.chars().rev().collect::<String>().parse().ok()
}
fn unit_to_duration(unit: &str, amount: i64) -> Option<Duration> {
match unit {
u if u.starts_with("minute") || u == "min" || u == "mins" => Some(Duration::minutes(amount)),
u if u.starts_with("hour") || u == "hr" || u == "hrs" => Some(Duration::hours(amount)),
u if u.starts_with("day") => Some(Duration::days(amount)),
u if u.starts_with("week") => Some(Duration::weeks(amount)),
_ => None,
}
}
#[cfg(test)]
#[path = "signatures_tests.rs"]
mod tests;