1use std::sync::OnceLock;
7
8use regex_lite::{Regex, RegexBuilder};
9use yapi_types::message::{AssistantMessage, StopReason};
10
11const OVERFLOW_PATTERNS: &[&str] = &[
13 r"prompt (?:is )?too long",
14 r"prompt exceeds max length",
15 r"request_too_large",
16 r"input is too long for requested model",
17 r"exceeds the context window",
18 r"exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))",
19 r"input token count.*exceeds the maximum",
20 r"maximum prompt length is \d+",
21 r"reduce the length of the messages",
22 r"maximum context length is \d+ tokens",
23 r"exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?",
24 r"input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)",
25 r"exceeds the limit of \d+",
26 r"exceeds the available context size",
27 r"greater than the context length",
28 r"context window exceeds limit",
29 r"exceeded model token limit",
30 r"too large for model with \d+ maximum context length",
31 r"prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?",
32 r"model_context_window_exceeded",
33 r"prompt too long; exceeded (?:max )?context length",
34 r"range of input length should be",
35 r"context[_ ]length[_ ]exceeded",
36 r"too many tokens",
37 r"token limit exceeded",
38];
39
40const NON_OVERFLOW_PATTERNS: &[&str] = &[
42 r"^(Throttling error|Service unavailable):",
43 r"rate limit",
44 r"too many requests",
45];
46
47const CEREBRAS_BODYLESS_OVERFLOW: &str = r"^4(?:00|13)\s*(?:status code)?\s*\(no body\)";
48
49const NON_RETRYABLE: &[&str] = &[
51 "GoUsageLimitError",
52 "FreeUsageLimitError",
53 "Monthly usage limit reached",
54 "available balance",
55 "insufficient_quota",
56 "out of budget",
57 "quota exceeded",
58 "billing",
59 "subscription_sharing_usage_limit_exceeded",
60];
61
62const RETRYABLE: &[&str] = &[
64 "overloaded",
65 "currently experiencing high demand",
66 "rate.?limit",
67 "too many requests",
68 "429",
69 "500",
70 "502",
71 "503",
72 "504",
73 "520",
74 "524",
75 "service.?unavailable",
76 "server.?error",
77 "internal.?error",
78 "provider.?returned.?error",
79 "exceeded request buffer limit while retrying upstream",
80 "network.?error",
81 "connection.?error",
82 "connection.?refused",
83 "connection.?lost",
84 "other side closed",
85 "fetch failed",
86 "getaddrinfo",
87 "ENOTFOUND",
88 "EAI_AGAIN",
89 "upstream.?connect",
90 "reset before headers",
91 "socket hang up",
92 "socket connection was closed",
93 "timed? out",
94 "timeout",
95 "terminated",
96 "websocket.?closed",
97 "websocket.?error",
98 "ended without",
99 "stream ended before message_stop",
100 "stream ended before a terminal response event",
101 "http2 request did not get a response",
102 "retry delay",
103 "you can retry your request",
104 "try your request again",
105 "please retry your request",
106 "ResourceExhausted",
107 "subscription_sharing_usage_unavailable",
108 "subscription_sharing_user_unavailable",
109];
110
111pub const DEFAULT_MAX_AGENT_RETRY_DELAY_MS: u64 = 60_000;
113
114fn insensitive(pattern: &str) -> Regex {
115 RegexBuilder::new(pattern)
116 .case_insensitive(true)
117 .build()
118 .expect("constant pattern; `patterns_compile` checks each one")
119}
120
121struct Patterns {
122 overflow: Vec<Regex>,
123 non_overflow: Vec<Regex>,
124 cerebras: Regex,
125 non_retryable: Regex,
126 retryable: Regex,
127}
128
129fn patterns() -> &'static Patterns {
130 static PATTERNS: OnceLock<Patterns> = OnceLock::new();
131 PATTERNS.get_or_init(|| Patterns {
132 overflow: OVERFLOW_PATTERNS.iter().map(|p| insensitive(p)).collect(),
133 non_overflow: NON_OVERFLOW_PATTERNS
134 .iter()
135 .map(|p| insensitive(p))
136 .collect(),
137 cerebras: insensitive(CEREBRAS_BODYLESS_OVERFLOW),
138 non_retryable: insensitive(&NON_RETRYABLE.join("|")),
139 retryable: insensitive(&RETRYABLE.join("|")),
140 })
141}
142
143pub fn is_context_overflow(message: &AssistantMessage, context_window: u64) -> bool {
148 let patterns = patterns();
149 if message.stop_reason == StopReason::Error
150 && let Some(error) = message.error_message.as_deref().filter(|e| !e.is_empty())
151 && !patterns.non_overflow.iter().any(|p| p.is_match(error))
152 && (patterns.overflow.iter().any(|p| p.is_match(error))
153 || (message.provider == "cerebras" && patterns.cerebras.is_match(error)))
154 {
155 return true;
156 }
157 let input = message.usage.input + message.usage.cache_read;
158 if context_window > 0 && message.stop_reason == StopReason::Stop && input > context_window {
159 return true;
160 }
161 context_window > 0
162 && message.stop_reason == StopReason::Length
163 && message.usage.output == 0
164 && input as f64 >= context_window as f64 * 0.99
165}
166
167pub fn is_recoverable_length(message: &AssistantMessage, desired_max_output: u64) -> bool {
171 message.stop_reason == StopReason::Length
172 && desired_max_output > 0
173 && message.usage.output < desired_max_output
174}
175
176pub fn is_retryable_assistant_error(message: &AssistantMessage) -> bool {
178 let Some(error) = message
179 .error_message
180 .as_deref()
181 .filter(|error| message.stop_reason == StopReason::Error && !error.is_empty())
182 else {
183 return false;
184 };
185 let patterns = patterns();
186 !patterns.non_retryable.is_match(error) && patterns.retryable.is_match(error)
187}
188
189pub fn retry_delay_ms(base_delay_ms: u64, max_delay_ms: Option<u64>, attempt: u32) -> u64 {
191 let factor = 1u64
192 .checked_shl(attempt.saturating_sub(1))
193 .unwrap_or(u64::MAX);
194 base_delay_ms
195 .saturating_mul(factor)
196 .min(max_delay_ms.unwrap_or(DEFAULT_MAX_AGENT_RETRY_DELAY_MS))
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn failed(error: &str) -> AssistantMessage {
204 let mut message = crate::stream::new_output(
205 &serde_json::from_value(serde_json::json!({
206 "id": "m", "name": "m", "api": "openai-responses", "provider": "openai",
207 "baseUrl": "", "reasoning": false, "input": ["text"],
208 "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
209 "contextWindow": 100, "maxTokens": 10,
210 }))
211 .unwrap(),
212 0,
213 );
214 message.stop_reason = StopReason::Error;
215 message.error_message = Some(error.into());
216 message
217 }
218
219 #[test]
220 fn patterns_compile() {
221 for pattern in OVERFLOW_PATTERNS.iter().chain(NON_OVERFLOW_PATTERNS) {
222 RegexBuilder::new(pattern).build().unwrap();
223 }
224 RegexBuilder::new(CEREBRAS_BODYLESS_OVERFLOW)
225 .build()
226 .unwrap();
227 }
228
229 #[test]
230 fn classifies_errors() {
231 assert!(is_context_overflow(
232 &failed("prompt is too long: 213462 tokens > 200000 maximum"),
233 0
234 ));
235 assert!(!is_context_overflow(
236 &failed("ThrottlingException: Too many tokens, rate limit"),
237 0
238 ));
239 assert!(is_retryable_assistant_error(&failed(
240 "server_error: The server had an error"
241 )));
242 assert!(is_retryable_assistant_error(&failed(
243 "OpenAI API error (500): {}"
244 )));
245 assert!(!is_retryable_assistant_error(&failed(
246 "429 insufficient_quota"
247 )));
248 assert!(!is_retryable_assistant_error(&failed("400 bad request")));
249 assert_eq!(retry_delay_ms(2000, None, 3), 8000);
250 assert_eq!(retry_delay_ms(2000, None, 64), 60_000);
251 }
252}