use super::*;
pub fn quota_signature_agent(message: &str) -> Option<AgentKind> {
crate::routing::rate_limit::signatures::match_quota_signature(message).map(|(agent, _)| agent)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QuotaEvidence {
NonAgentChannel,
AgentProse,
}
pub fn is_rate_limit_error(message: &str) -> bool {
is_rate_limit_error_with_evidence(message, QuotaEvidence::AgentProse)
}
pub fn is_rate_limit_error_with_evidence(message: &str, evidence: QuotaEvidence) -> bool {
if crate::routing::rate_limit::signatures::match_quota_signature(message).is_some() {
return true;
}
evidence == QuotaEvidence::NonAgentChannel && generic_quota_signal(message)
}
pub fn is_rate_limit_error_for_agent(message: &str, agent: &AgentKind) -> bool {
is_rate_limit_error_for_agent_with_evidence(message, agent, QuotaEvidence::NonAgentChannel)
}
pub fn is_rate_limit_error_for_agent_with_evidence(
message: &str,
agent: &AgentKind,
evidence: QuotaEvidence,
) -> bool {
if crate::routing::rate_limit::signatures::match_quota_signature_for_agent(message, *agent).is_some() {
return true;
}
evidence == QuotaEvidence::NonAgentChannel && generic_quota_signal(message)
}
fn generic_quota_signal(message: &str) -> bool {
let lower = message.to_lowercase();
lower.contains("rate limit")
|| contains_status_code(&lower, "429")
|| contains_status_code(&lower, "402")
|| lower.contains("too many requests")
}
fn contains_status_code(s: &str, code: &str) -> bool {
let bytes = s.as_bytes();
let code = code.as_bytes();
if bytes.len() < code.len() {
return false;
}
for i in 0..=bytes.len().saturating_sub(code.len()) {
if &bytes[i..i + code.len()] == code {
let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
let after_ok = i + code.len() >= bytes.len() || !bytes[i + code.len()].is_ascii_digit();
if before_ok && after_ok {
return true;
}
}
}
false
}
pub(crate) fn refusal_on_channel(
raw: &str,
agent: AgentKind,
channel: crate::routing::quota::channel::Channel,
) -> Option<String> {
let kept = crate::routing::quota::channel::provider_attributable(raw, agent, channel);
let signed = (agent != AgentKind::Claude
|| channel == crate::routing::quota::channel::Channel::CliStream)
.then(|| kept.quota_text(agent));
if let Some(refusal) =
signed.and_then(|text| crate::agent::stream_completion::quota_line(&text, agent))
{
return Some(refusal);
}
let generic = kept
.cli_diagnostic
.lines()
.find(|line| generic_quota_signal(line))?;
let refusal: String = generic.chars().take(240).collect();
let refusal = refusal.trim();
(!refusal.is_empty()).then(|| refusal.to_string())
}
pub(super) fn parse_iso_recovery_time(message: &str) -> Option<String> {
for (index, _) in message.match_indices("20") {
let candidate: String = message[index..]
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | ':' | '.' | '+'))
.collect();
let candidate = candidate.trim_end_matches(['.', ',', ';', ')', ']', '}']);
let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(candidate) else {
continue;
};
let local = parsed.with_timezone(&Local).naive_local();
return Some(format_recovery(local));
}
None
}
pub(crate) fn parse_recovery_datetime(s: &str) -> Option<NaiveDateTime> {
let mut parts: Vec<String> = s.split(' ').map(|part| part.to_string()).collect();
if parts.len() < 2 {
return None;
}
let day_token = &parts[1];
let day_without_comma = day_token.strip_suffix(',').unwrap_or(day_token);
let day_without_suffix = day_without_comma
.strip_suffix("st")
.or_else(|| day_without_comma.strip_suffix("nd"))
.or_else(|| day_without_comma.strip_suffix("rd"))
.or_else(|| day_without_comma.strip_suffix("th"))
.unwrap_or(day_without_comma);
let day_number: u32 = day_without_suffix.parse().ok()?;
let day_with_comma = if day_token.ends_with(',') { "," } else { "" };
parts[1] = format!("{:02}{}", day_number, day_with_comma);
let cleaned = parts.join(" ");
NaiveDateTime::parse_from_str(&cleaned, "%b %d, %Y %I:%M %p").ok()
}
#[cfg(test)]
pub(crate) fn test_future_recovery_time() -> String {
let at = Local::now().naive_local() + chrono::Duration::days(1);
format!(
"{} {}{}, {} {}:{:02} {}",
at.format("%b"),
at.day(),
test_ordinal_suffix(at.day()),
at.year(),
at.hour12().1,
at.minute(),
at.format("%p")
)
}
#[cfg(test)]
fn test_ordinal_suffix(day: u32) -> &'static str {
match day % 100 {
11..=13 => "th",
_ => match day % 10 {
1 => "st",
2 => "nd",
3 => "rd",
_ => "th",
},
}
}