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