1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::{BTreeMap, HashMap};
5use std::sync::OnceLock;
6
7#[cfg(feature = "openapi")]
8use utoipa::ToSchema;
9
10pub mod codes {
11 pub const BUDGET_EXHAUSTED: &str = "budget_exhausted";
12 pub const BUDGET_PAUSED: &str = "budget_paused";
13 pub const MODEL_UNAVAILABLE: &str = "model_unavailable";
14 pub const REQUEST_TOO_LARGE: &str = "request_too_large";
15 pub const PROVIDER_RATE_LIMITED: &str = "provider_rate_limited";
16 pub const PROVIDER_MISCONFIGURED: &str = "provider_misconfigured";
17 pub const PROVIDER_UNAVAILABLE: &str = "provider_unavailable";
18 pub const PROCESSING_ERROR: &str = "processing_error";
19 pub const DEPENDENCY_UNAVAILABLE: &str = "dependency_unavailable";
20 pub const MAX_ITERATIONS: &str = "max_iterations";
21 pub const SOFT_LIMIT_REACHED: &str = "soft_limit_reached";
22}
23
24pub type UserFacingErrorFields = BTreeMap<String, Value>;
25
26#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
27#[cfg_attr(feature = "openapi", derive(ToSchema))]
28pub struct UserFacingError {
29 pub code: String,
30 #[serde(default, skip_serializing_if = "UserFacingErrorFields::is_empty")]
31 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
32 pub fields: UserFacingErrorFields,
33}
34
35#[derive(Debug, Clone, Default)]
36pub struct UserFacingErrorContext {
37 pub provider: Option<String>,
38 pub model_id: Option<String>,
39 pub retry_after: Option<u64>,
40}
41
42impl UserFacingErrorContext {
43 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
44 self.provider = Some(provider.into());
45 self
46 }
47
48 pub fn with_model_id(mut self, model_id: impl Into<String>) -> Self {
49 self.model_id = Some(model_id.into());
50 self
51 }
52
53 pub fn with_retry_after(mut self, retry_after: u64) -> Self {
54 self.retry_after = Some(retry_after);
55 self
56 }
57}
58
59impl UserFacingError {
60 pub fn new(code: impl Into<String>) -> Self {
61 Self {
62 code: code.into(),
63 fields: UserFacingErrorFields::new(),
64 }
65 }
66
67 pub fn with_field<T: Serialize>(mut self, key: impl Into<String>, value: T) -> Self {
68 let value = serde_json::to_value(value).unwrap_or(Value::Null);
69 if !value.is_null() {
70 self.fields.insert(key.into(), value);
71 }
72 self
73 }
74
75 pub fn with_optional_field<T: Serialize>(
76 self,
77 key: impl Into<String>,
78 value: Option<T>,
79 ) -> Self {
80 match value {
81 Some(value) => self.with_field(key, value),
82 None => self,
83 }
84 }
85
86 pub fn error_fields(&self) -> Option<UserFacingErrorFields> {
87 (!self.fields.is_empty()).then_some(self.fields.clone())
88 }
89
90 pub fn apply_to_event_fields(
91 &self,
92 error_code: &mut Option<String>,
93 error_fields: &mut Option<UserFacingErrorFields>,
94 ) {
95 *error_code = Some(self.code.clone());
96 *error_fields = self.error_fields();
97 }
98
99 pub fn apply_to_message_metadata(&self, metadata: &mut HashMap<String, Value>) {
100 metadata.insert("error_code".to_string(), Value::String(self.code.clone()));
101 if let Some(fields) = self.error_fields() {
102 metadata.insert(
103 "error_fields".to_string(),
104 serde_json::to_value(fields).unwrap_or(Value::Null),
105 );
106 }
107 }
108
109 pub fn fallback_message(&self) -> String {
110 match self.code.as_str() {
111 codes::BUDGET_EXHAUSTED => budget_exhausted_message(&self.fields),
112 codes::BUDGET_PAUSED => budget_paused_message(&self.fields),
113 codes::SOFT_LIMIT_REACHED => string_field(&self.fields, "message")
114 .unwrap_or("Soft limit reached.")
115 .to_string(),
116 codes::MODEL_UNAVAILABLE => {
117 if let Some(model_id) = string_field(&self.fields, "model_id") {
118 format!(
119 "The model `{}` is not available. It may have been removed, renamed, or your API key may not have access to it. Please select a different model.",
120 model_id
121 )
122 } else {
123 "The selected model is not available. Please select a different model."
124 .to_string()
125 }
126 }
127 codes::REQUEST_TOO_LARGE => {
128 "The conversation has become too long for the model to process. Please start a new session or reduce the context size.".to_string()
129 }
130 codes::PROVIDER_RATE_LIMITED => {
131 "Rate limited by the AI provider. Please wait a moment.".to_string()
132 }
133 codes::PROVIDER_MISCONFIGURED => {
134 "There is a misconfiguration with the AI provider. Please contact support."
135 .to_string()
136 }
137 codes::PROVIDER_UNAVAILABLE => {
138 "The AI provider is experiencing issues. Please try again shortly.".to_string()
139 }
140 codes::DEPENDENCY_UNAVAILABLE => {
141 "Execution stopped because a required dependency is unavailable.".to_string()
142 }
143 _ => "I encountered an error while processing your request. Please try again later."
144 .to_string(),
145 }
146 }
147}
148
149pub fn classify_runtime_error_message(
150 error: &str,
151 context: &UserFacingErrorContext,
152) -> UserFacingError {
153 let normalized = trim_error_chain_prefixes(error).trim();
154 let lower = normalized.to_ascii_lowercase();
155
156 if let Some(fields) = parse_budget_exhausted_fields(normalized) {
157 return UserFacingError {
158 code: codes::BUDGET_EXHAUSTED.to_string(),
159 fields,
160 };
161 }
162
163 if normalized.starts_with("Budget exhausted.") {
164 return UserFacingError::new(codes::BUDGET_EXHAUSTED);
165 }
166
167 if normalized.starts_with("Budget exhausted (") {
168 return UserFacingError::new(codes::BUDGET_EXHAUSTED);
169 }
170
171 if let Some(fields) = parse_budget_paused_fields(normalized) {
172 return UserFacingError {
173 code: codes::BUDGET_PAUSED.to_string(),
174 fields,
175 };
176 }
177
178 if normalized.starts_with("Budget paused.") || normalized.starts_with("Budget paused with ") {
179 return UserFacingError::new(codes::BUDGET_PAUSED);
180 }
181
182 if normalized.starts_with("Budget paused (") || normalized.starts_with("Soft limit reached.") {
183 return if normalized.starts_with("Soft limit reached.") {
184 UserFacingError::new(codes::SOFT_LIMIT_REACHED).with_field("message", normalized)
185 } else {
186 UserFacingError::new(codes::BUDGET_PAUSED)
187 };
188 }
189
190 if let Some(model_id) = normalized.strip_prefix("Model not available: ") {
191 return UserFacingError::new(codes::MODEL_UNAVAILABLE).with_field("model_id", model_id);
192 }
193
194 if normalized.starts_with("Request too large:")
195 || lower.contains("context length")
196 || lower.contains("maximum context length")
197 {
198 return UserFacingError::new(codes::REQUEST_TOO_LARGE)
199 .with_optional_field("provider", context.provider.clone())
200 .with_optional_field("model_id", context.model_id.clone());
201 }
202
203 if lower.contains("insufficient_quota")
209 || lower.contains("insufficient quota")
210 || lower.contains("exceeded your current quota")
211 {
212 return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
213 .with_optional_field("provider", context.provider.clone())
214 .with_optional_field("model_id", context.model_id.clone());
215 }
216
217 if lower.contains("(429)")
218 || lower.contains("rate limit")
219 || lower.contains("too many requests")
220 {
221 return UserFacingError::new(codes::PROVIDER_RATE_LIMITED)
222 .with_optional_field("provider", context.provider.clone())
223 .with_optional_field("model_id", context.model_id.clone())
224 .with_optional_field("retry_after", context.retry_after);
225 }
226
227 if lower.contains("(401)") || lower.contains("(403)") {
228 return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
229 .with_optional_field("provider", context.provider.clone())
230 .with_optional_field("model_id", context.model_id.clone());
231 }
232
233 if lower.contains("api key is required")
234 || lower.contains("configure the api key")
235 || lower.contains("api key missing")
236 || lower.contains("missing api key")
237 || lower.contains("invalid api key")
238 {
239 return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
240 .with_optional_field("provider", context.provider.clone())
241 .with_optional_field("model_id", context.model_id.clone());
242 }
243
244 if ["(500)", "(502)", "(503)", "(504)", "(529)"]
245 .iter()
246 .any(|code| lower.contains(code))
247 {
248 return UserFacingError::new(codes::PROVIDER_UNAVAILABLE)
249 .with_optional_field("provider", context.provider.clone())
250 .with_optional_field("model_id", context.model_id.clone());
251 }
252
253 UserFacingError::new(codes::PROCESSING_ERROR)
254 .with_optional_field("provider", context.provider.clone())
255 .with_optional_field("model_id", context.model_id.clone())
256}
257
258pub fn trim_error_chain_prefixes(error_chain: &str) -> &str {
259 error_chain
260 .trim()
261 .trim_start_matches("InputAtom execution failed: ")
262 .trim_start_matches("ReasonAtom execution failed: ")
263 .trim_start_matches("ActAtom execution failed: ")
264}
265
266fn budget_exhausted_message(fields: &UserFacingErrorFields) -> String {
267 if let (Some(spent), Some(limit), Some(currency)) = (
268 number_field(fields, "spent"),
269 number_field(fields, "limit"),
270 string_field(fields, "currency"),
271 ) {
272 let comparison = if spent > limit { "exceeded" } else { "reached" };
273 return format!(
274 "Budget exhausted. {:.2} {} spent {} the {:.2} {} limit. Increase the budget to continue.",
275 spent, currency, comparison, limit, currency
276 );
277 }
278
279 "Budget exhausted. Increase the budget to continue.".to_string()
280}
281
282fn budget_paused_message(fields: &UserFacingErrorFields) -> String {
283 let spent = number_field(fields, "spent");
284 let currency = string_field(fields, "currency");
285 let soft_limit = number_field(fields, "soft_limit");
286
287 match (spent, currency, soft_limit) {
288 (Some(spent), Some(currency), Some(soft_limit)) => {
289 let comparison = if spent > soft_limit {
290 "exceeded"
291 } else if spent >= soft_limit {
292 "reached"
293 } else {
294 "with"
295 };
296 if comparison == "with" {
297 format!(
298 "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
299 spent, currency
300 )
301 } else {
302 format!(
303 "Budget paused. {:.2} {} spent {} the {:.2} {} soft limit. Increase or resume the budget to continue.",
304 spent, currency, comparison, soft_limit, currency
305 )
306 }
307 }
308 (Some(spent), Some(currency), None) => format!(
309 "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
310 spent, currency
311 ),
312 _ => "Budget paused. Increase or resume the budget to continue.".to_string(),
313 }
314}
315
316fn parse_budget_exhausted_fields(message: &str) -> Option<UserFacingErrorFields> {
317 static RE: OnceLock<Regex> = OnceLock::new();
318 let re = RE.get_or_init(|| {
319 Regex::new(
320 r"^Budget exhausted\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<limit>\d+(?:\.\d+)?) \S+ limit\.",
321 )
322 .expect("valid budget exhausted regex")
323 });
324 let caps = re.captures(message)?;
325 Some(
326 UserFacingErrorFields::new()
327 .with_number("spent", caps.name("spent")?.as_str())
328 .with_number("limit", caps.name("limit")?.as_str())
329 .with_string("currency", caps.name("currency")?.as_str()),
330 )
331}
332
333fn parse_budget_paused_fields(message: &str) -> Option<UserFacingErrorFields> {
334 static SOFT_LIMIT_RE: OnceLock<Regex> = OnceLock::new();
335 static SIMPLE_RE: OnceLock<Regex> = OnceLock::new();
336
337 let soft_limit_re = SOFT_LIMIT_RE.get_or_init(|| {
338 Regex::new(
339 r"^Budget paused\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<soft_limit>\d+(?:\.\d+)?) \S+ soft limit\.",
340 )
341 .expect("valid budget paused regex")
342 });
343 if let Some(caps) = soft_limit_re.captures(message) {
344 return Some(
345 UserFacingErrorFields::new()
346 .with_number("spent", caps.name("spent")?.as_str())
347 .with_number("soft_limit", caps.name("soft_limit")?.as_str())
348 .with_string("currency", caps.name("currency")?.as_str()),
349 );
350 }
351
352 let simple_re = SIMPLE_RE.get_or_init(|| {
353 Regex::new(r"^Budget paused with (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent\.")
354 .expect("valid budget paused simple regex")
355 });
356 let caps = simple_re.captures(message)?;
357 Some(
358 UserFacingErrorFields::new()
359 .with_number("spent", caps.name("spent")?.as_str())
360 .with_string("currency", caps.name("currency")?.as_str()),
361 )
362}
363
364fn string_field<'a>(fields: &'a UserFacingErrorFields, key: &str) -> Option<&'a str> {
365 fields.get(key)?.as_str()
366}
367
368fn number_field(fields: &UserFacingErrorFields, key: &str) -> Option<f64> {
369 match fields.get(key)? {
370 Value::Number(number) => number.as_f64(),
371 Value::String(value) => value.parse().ok(),
372 _ => None,
373 }
374}
375
376trait ErrorFieldsExt {
377 fn with_string(self, key: &str, value: &str) -> Self;
378 fn with_number(self, key: &str, value: &str) -> Self;
379}
380
381impl ErrorFieldsExt for UserFacingErrorFields {
382 fn with_string(mut self, key: &str, value: &str) -> Self {
383 self.insert(key.to_string(), Value::String(value.to_string()));
384 self
385 }
386
387 fn with_number(mut self, key: &str, value: &str) -> Self {
388 if let Ok(number) = value.parse::<f64>()
389 && let Some(json_number) = serde_json::Number::from_f64(number)
390 {
391 self.insert(key.to_string(), Value::Number(json_number));
392 }
393 self
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn classify_budget_exhausted_parses_fields() {
403 let error = classify_runtime_error_message(
404 "ReasonAtom execution failed: Budget exhausted. 12.50 usd spent exceeded the 10.00 usd limit. Increase the budget to continue.",
405 &UserFacingErrorContext::default(),
406 );
407
408 assert_eq!(error.code, codes::BUDGET_EXHAUSTED);
409 assert_eq!(number_field(&error.fields, "spent"), Some(12.5));
410 assert_eq!(number_field(&error.fields, "limit"), Some(10.0));
411 assert_eq!(string_field(&error.fields, "currency"), Some("usd"));
412 }
413
414 #[test]
415 fn classify_provider_rate_limit_keeps_context() {
416 let error = classify_runtime_error_message(
417 "OpenAI API error (429): rate limit exceeded",
418 &UserFacingErrorContext::default()
419 .with_provider("openai")
420 .with_model_id("gpt-5")
421 .with_retry_after(7),
422 );
423
424 assert_eq!(error.code, codes::PROVIDER_RATE_LIMITED);
425 assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
426 assert_eq!(string_field(&error.fields, "model_id"), Some("gpt-5"));
427 assert_eq!(number_field(&error.fields, "retry_after"), Some(7.0));
428 }
429
430 #[test]
431 fn classify_openai_insufficient_quota_as_provider_misconfigured() {
432 let error = classify_runtime_error_message(
435 "ReasonAtom execution failed: OpenAI API error (429): {\"error\":{\"message\":\"You exceeded your current quota, please check your plan and billing details.\",\"type\":\"insufficient_quota\",\"code\":\"insufficient_quota\"}}",
436 &UserFacingErrorContext::default()
437 .with_provider("openai")
438 .with_model_id("gpt-4.1-mini"),
439 );
440
441 assert_eq!(error.code, codes::PROVIDER_MISCONFIGURED);
442 assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
443 assert_eq!(
444 string_field(&error.fields, "model_id"),
445 Some("gpt-4.1-mini")
446 );
447 }
448
449 #[test]
450 fn classify_insufficient_quota_without_status_prefix() {
451 let error = classify_runtime_error_message(
455 "LLM error: insufficient_quota: You exceeded your current quota.",
456 &UserFacingErrorContext::default(),
457 );
458
459 assert_eq!(error.code, codes::PROVIDER_MISCONFIGURED);
460 }
461
462 #[test]
463 fn classify_missing_api_key_as_provider_misconfigured() {
464 let error = classify_runtime_error_message(
465 "LLM error: API key is required. Configure the API key in provider settings.",
466 &UserFacingErrorContext::default().with_provider("openai"),
467 );
468
469 assert_eq!(error.code, codes::PROVIDER_MISCONFIGURED);
470 assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
471 }
472
473 #[test]
474 fn fallback_message_reuses_budget_fields() {
475 let error = UserFacingError::new(codes::BUDGET_PAUSED)
476 .with_field("spent", 5.0)
477 .with_field("soft_limit", 5.0)
478 .with_field("currency", "tokens");
479
480 assert_eq!(
481 error.fallback_message(),
482 "Budget paused. 5.00 tokens spent reached the 5.00 tokens soft limit. Increase or resume the budget to continue."
483 );
484 }
485}