Skip to main content

everruns_core/
user_facing_error.rs

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    // OpenAI surfaces an exhausted-billing state as HTTP 429 + body
204    // `{"error":{"type":"insufficient_quota",...}}`. The "(429)" prefix would
205    // otherwise route it to PROVIDER_RATE_LIMITED ("wait a moment"), but the
206    // condition is non-transient and needs operator action (top up the
207    // account or rotate the key), so classify it as PROVIDER_MISCONFIGURED.
208    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        // OpenAI's exhausted-billing 429 needs operator action (top up or
433        // rotate key), not the transient "rate limited, wait a moment" copy.
434        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        // Even if upstream wrapping drops the "(429)" prefix, the explicit
452        // quota substring must still route to PROVIDER_MISCONFIGURED rather
453        // than the canned PROCESSING_ERROR fallback (EVE-472).
454        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}