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