Skip to main content

everruns_provider/
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    /// Subscription/plan usage limit was reached (e.g. ChatGPT/Codex
17    /// `usage_limit_reached`). Distinct from `provider_rate_limited` (a short
18    /// transient throttle) because the reset is far in the future (hours) and
19    /// carries a concrete `resets_at` timestamp, and distinct from
20    /// `provider_quota_exhausted` (billing/credits) because it recovers on its
21    /// own at the reset time without operator action.
22    pub const PROVIDER_USAGE_LIMIT_REACHED: &str = "provider_usage_limit_reached";
23    pub const PROVIDER_MISCONFIGURED: &str = "provider_misconfigured";
24    /// Provider account is out of credits/quota (billing). Distinct from
25    /// `provider_misconfigured` (bad/missing API key) so operators can tell
26    /// "top up the account" apart from "fix the key".
27    pub const PROVIDER_QUOTA_EXHAUSTED: &str = "provider_quota_exhausted";
28    pub const PROVIDER_UNAVAILABLE: &str = "provider_unavailable";
29    pub const PROCESSING_ERROR: &str = "processing_error";
30    pub const DEPENDENCY_UNAVAILABLE: &str = "dependency_unavailable";
31    pub const INVALID_TOOL_SCHEMA: &str = "invalid_tool_schema";
32    pub const MAX_ITERATIONS: &str = "max_iterations";
33    pub const SOFT_LIMIT_REACHED: &str = "soft_limit_reached";
34    /// A `user_prompt_submit` hook rejected the inbound user message.
35    pub const BLOCKED_BY_HOOK: &str = "blocked_by_hook";
36}
37
38pub type UserFacingErrorFields = BTreeMap<String, Value>;
39
40/// Message/event metadata keys used to track error disclosure decisions.
41pub mod metadata_keys {
42    /// Disclosure mode applied when the error surfaced ("generic" | "standard" | "detailed").
43    pub const ERROR_DISCLOSURE: &str = "error_disclosure";
44    /// The classified error code before disclosure was applied. Differs from
45    /// `error_code` only in `generic` mode, where the displayed code collapses
46    /// to `processing_error`.
47    pub const SOURCE_ERROR_CODE: &str = "source_error_code";
48}
49
50/// How much detail about a run-blocking error is shown to session viewers.
51///
52/// Ordering matters: variants are declared least → most disclosing so that
53/// per-message control overrides can be clamped with `min` against the
54/// capability-configured ceiling.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57#[cfg_attr(feature = "openapi", derive(ToSchema))]
58pub enum ErrorDisclosure {
59    /// Collapse every blocking error into one generic, localizable message
60    /// (`processing_error`, no fields). For public-facing agents.
61    Generic,
62    /// Stable error code + structured interpolation fields. Current default.
63    #[default]
64    Standard,
65    /// Standard plus a `detail` field carrying the underlying driver error
66    /// text. For trusted surfaces such as coding-agent harnesses.
67    Detailed,
68}
69
70impl ErrorDisclosure {
71    pub fn parse(value: &str) -> Option<Self> {
72        match value.trim().to_ascii_lowercase().as_str() {
73            "generic" => Some(ErrorDisclosure::Generic),
74            "standard" => Some(ErrorDisclosure::Standard),
75            "detailed" => Some(ErrorDisclosure::Detailed),
76            _ => None,
77        }
78    }
79
80    pub fn as_str(&self) -> &'static str {
81        match self {
82            ErrorDisclosure::Generic => "generic",
83            ErrorDisclosure::Standard => "standard",
84            ErrorDisclosure::Detailed => "detailed",
85        }
86    }
87}
88
89/// Maximum length of the `detail` field attached in `Detailed` mode. Provider
90/// error bodies are normally short; this guards against pathological payloads
91/// bloating messages and events.
92const DETAIL_MAX_CHARS: usize = 1000;
93
94/// Provider quota/billing-exhaustion patterns shared by the string classifier
95/// and the driver-boundary semantic classifier (`LlmErrorKind`).
96pub fn is_provider_quota_message(message: &str) -> bool {
97    let lower = message.to_ascii_lowercase();
98    lower.contains("insufficient_quota")
99        || lower.contains("insufficient quota")
100        || lower.contains("exceeded your current quota")
101        || lower.contains("credit_balance_exhausted")
102        || lower.contains("credit balance is too low")
103}
104
105/// Subscription/plan usage-limit patterns shared by the string classifier and
106/// the transient-retry gate. These recover only at a future reset time (hours
107/// away), so unlike an ordinary 429 they must not be retried within the driver
108/// backoff window nor collapsed into the "wait a moment" rate-limit copy.
109///
110/// The canonical shape is the ChatGPT/Codex `429` body
111/// (`{"error":{"type":"usage_limit_reached", ...}}`), but the match is kept
112/// provider-agnostic so any driver surfacing the same wording is covered.
113pub fn is_usage_limit_message(message: &str) -> bool {
114    let lower = message.to_ascii_lowercase();
115    lower.contains("usage_limit_reached")
116        || lower.contains("usage limit reached")
117        || lower.contains("usage limit has been reached")
118}
119
120/// Extract the absolute reset time (`resets_at`, unix seconds) from a usage-limit
121/// error body when present. Prefers the absolute `resets_at` field over the
122/// relative `resets_in_seconds` because this classifier is clock-free and callers
123/// want a stable timestamp they can render in the viewer's timezone.
124pub fn parse_usage_limit_reset_at(message: &str) -> Option<i64> {
125    static RE: OnceLock<Regex> = OnceLock::new();
126    let re = RE.get_or_init(|| {
127        Regex::new(r#""resets_at"\s*:\s*(?P<resets_at>\d{9,})"#).expect("valid resets_at regex")
128    });
129    re.captures(message)?
130        .name("resets_at")?
131        .as_str()
132        .parse::<i64>()
133        .ok()
134}
135
136#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
137#[cfg_attr(feature = "openapi", derive(ToSchema))]
138pub struct UserFacingError {
139    pub code: String,
140    #[serde(default, skip_serializing_if = "UserFacingErrorFields::is_empty")]
141    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
142    pub fields: UserFacingErrorFields,
143}
144
145#[derive(Debug, Clone, Default)]
146pub struct UserFacingErrorContext {
147    pub provider: Option<String>,
148    pub model_id: Option<String>,
149    pub retry_after: Option<u64>,
150}
151
152impl UserFacingErrorContext {
153    pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
154        self.provider = Some(provider.into());
155        self
156    }
157
158    pub fn with_model_id(mut self, model_id: impl Into<String>) -> Self {
159        self.model_id = Some(model_id.into());
160        self
161    }
162
163    pub fn with_retry_after(mut self, retry_after: u64) -> Self {
164        self.retry_after = Some(retry_after);
165        self
166    }
167}
168
169impl UserFacingError {
170    pub fn new(code: impl Into<String>) -> Self {
171        Self {
172            code: code.into(),
173            fields: UserFacingErrorFields::new(),
174        }
175    }
176
177    pub fn with_field<T: Serialize>(mut self, key: impl Into<String>, value: T) -> Self {
178        let value = serde_json::to_value(value).unwrap_or(Value::Null);
179        if !value.is_null() {
180            self.fields.insert(key.into(), value);
181        }
182        self
183    }
184
185    pub fn with_optional_field<T: Serialize>(
186        self,
187        key: impl Into<String>,
188        value: Option<T>,
189    ) -> Self {
190        match value {
191            Some(value) => self.with_field(key, value),
192            None => self,
193        }
194    }
195
196    pub fn error_fields(&self) -> Option<UserFacingErrorFields> {
197        (!self.fields.is_empty()).then_some(self.fields.clone())
198    }
199
200    pub fn apply_to_event_fields(
201        &self,
202        error_code: &mut Option<String>,
203        error_fields: &mut Option<UserFacingErrorFields>,
204    ) {
205        *error_code = Some(self.code.clone());
206        *error_fields = self.error_fields();
207    }
208
209    pub fn apply_to_message_metadata(&self, metadata: &mut HashMap<String, Value>) {
210        metadata.insert("error_code".to_string(), Value::String(self.code.clone()));
211        if let Some(fields) = self.error_fields() {
212            metadata.insert(
213                "error_fields".to_string(),
214                serde_json::to_value(fields).unwrap_or(Value::Null),
215            );
216        }
217    }
218
219    /// Apply an error-disclosure mode, returning the error as it should be
220    /// shown to session viewers. The original (source) error stays available
221    /// to the caller for tracking metadata.
222    ///
223    /// - `Generic` collapses to `processing_error` with no fields.
224    /// - `Standard` returns the error unchanged.
225    /// - `Detailed` attaches `detail` (the underlying driver error text,
226    ///   truncated) as an extra interpolation field.
227    pub fn apply_disclosure(&self, mode: ErrorDisclosure, detail: Option<&str>) -> UserFacingError {
228        match mode {
229            ErrorDisclosure::Generic => UserFacingError::new(codes::PROCESSING_ERROR),
230            ErrorDisclosure::Standard => self.clone(),
231            ErrorDisclosure::Detailed => {
232                let detail = detail.map(str::trim).filter(|d| !d.is_empty());
233                match detail {
234                    Some(detail) => self
235                        .clone()
236                        .with_field("detail", truncate_chars(detail, DETAIL_MAX_CHARS)),
237                    None => self.clone(),
238                }
239            }
240        }
241    }
242
243    /// Record disclosure tracking metadata on a message: the mode that was
244    /// applied and the pre-disclosure (source) error code.
245    pub fn apply_disclosure_to_message_metadata(
246        metadata: &mut HashMap<String, Value>,
247        mode: ErrorDisclosure,
248        source_code: &str,
249    ) {
250        metadata.insert(
251            metadata_keys::ERROR_DISCLOSURE.to_string(),
252            Value::String(mode.as_str().to_string()),
253        );
254        metadata.insert(
255            metadata_keys::SOURCE_ERROR_CODE.to_string(),
256            Value::String(source_code.to_string()),
257        );
258    }
259
260    pub fn fallback_message(&self) -> String {
261        self.base_fallback_message()
262    }
263
264    fn base_fallback_message(&self) -> String {
265        match self.code.as_str() {
266            codes::BUDGET_EXHAUSTED => budget_exhausted_message(&self.fields),
267            codes::BUDGET_PAUSED => budget_paused_message(&self.fields),
268            codes::SOFT_LIMIT_REACHED => string_field(&self.fields, "message")
269                .unwrap_or("Soft limit reached.")
270                .to_string(),
271            codes::MODEL_UNAVAILABLE => {
272                if let Some(model_id) = string_field(&self.fields, "model_id") {
273                    format!(
274                        "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.",
275                        model_id
276                    )
277                } else {
278                    "The selected model is not available. Please select a different model."
279                        .to_string()
280                }
281            }
282            codes::REQUEST_TOO_LARGE => {
283                "The conversation has become too long for the model to process. Please start a new session or reduce the context size.".to_string()
284            }
285            codes::PROVIDER_RATE_LIMITED => {
286                "Rate limited by the AI provider. Please wait a moment.".to_string()
287            }
288            codes::PROVIDER_USAGE_LIMIT_REACHED => usage_limit_reached_message(&self.fields),
289            codes::PROVIDER_MISCONFIGURED => {
290                "There is a misconfiguration with the AI provider. Please contact support."
291                    .to_string()
292            }
293            codes::PROVIDER_QUOTA_EXHAUSTED => {
294                "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
295                    .to_string()
296            }
297            codes::PROVIDER_UNAVAILABLE => {
298                "The AI provider is experiencing issues. Please try again shortly.".to_string()
299            }
300            codes::DEPENDENCY_UNAVAILABLE => {
301                "Execution stopped because a required dependency is unavailable.".to_string()
302            }
303            codes::INVALID_TOOL_SCHEMA => {
304                "A connected tool uses an input schema that this model provider does not support. Update the integration or choose a different model provider, then try again."
305                    .to_string()
306            }
307            _ => "I encountered an error while processing your request. Please try again later."
308                .to_string(),
309        }
310    }
311}
312
313pub fn classify_runtime_error_message(
314    error: &str,
315    context: &UserFacingErrorContext,
316) -> UserFacingError {
317    let normalized = trim_error_chain_prefixes(error).trim();
318    let lower = normalized.to_ascii_lowercase();
319
320    if let Some(fields) = parse_budget_exhausted_fields(normalized) {
321        return UserFacingError {
322            code: codes::BUDGET_EXHAUSTED.to_string(),
323            fields,
324        };
325    }
326
327    if normalized.starts_with("Budget exhausted.") {
328        return UserFacingError::new(codes::BUDGET_EXHAUSTED);
329    }
330
331    if normalized.starts_with("Budget exhausted (") {
332        return UserFacingError::new(codes::BUDGET_EXHAUSTED);
333    }
334
335    if let Some(fields) = parse_budget_paused_fields(normalized) {
336        return UserFacingError {
337            code: codes::BUDGET_PAUSED.to_string(),
338            fields,
339        };
340    }
341
342    if normalized.starts_with("Budget paused.") || normalized.starts_with("Budget paused with ") {
343        return UserFacingError::new(codes::BUDGET_PAUSED);
344    }
345
346    if normalized.starts_with("Budget paused (") || normalized.starts_with("Soft limit reached.") {
347        return if normalized.starts_with("Soft limit reached.") {
348            UserFacingError::new(codes::SOFT_LIMIT_REACHED).with_field("message", normalized)
349        } else {
350            UserFacingError::new(codes::BUDGET_PAUSED)
351        };
352    }
353
354    if let Some(model_id) = normalized.strip_prefix("Model not available: ") {
355        return UserFacingError::new(codes::MODEL_UNAVAILABLE).with_field("model_id", model_id);
356    }
357
358    if normalized.starts_with("Request too large:")
359        || lower.contains("context length")
360        || lower.contains("maximum context length")
361    {
362        return UserFacingError::new(codes::REQUEST_TOO_LARGE)
363            .with_optional_field("provider", context.provider.clone())
364            .with_optional_field("model_id", context.model_id.clone());
365    }
366
367    if is_invalid_tool_schema_message(&lower) {
368        return UserFacingError::new(codes::INVALID_TOOL_SCHEMA)
369            .with_optional_field("provider", context.provider.clone())
370            .with_optional_field("model_id", context.model_id.clone())
371            .with_optional_field("schema_path", extract_schema_path(normalized));
372    }
373
374    // Exhausted provider billing (OpenAI: HTTP 429 + `insufficient_quota`,
375    // Anthropic: 400 + "credit balance is too low"). The "(429)" prefix would
376    // otherwise route it to PROVIDER_RATE_LIMITED ("wait a moment"), but the
377    // condition is non-transient and needs operator action (top up the
378    // account or raise limits), so it gets its own code.
379    if is_provider_quota_message(normalized) {
380        return UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
381            .with_optional_field("provider", context.provider.clone())
382            .with_optional_field("model_id", context.model_id.clone());
383    }
384
385    // Subscription/plan usage limit (e.g. ChatGPT/Codex `usage_limit_reached`).
386    // Checked before the generic 429 branch below: the outer error text carries
387    // "429 Too Many Requests", which would otherwise route it to the transient
388    // "wait a moment" rate-limit copy. This condition instead recovers on its
389    // own at `resets_at`, so it gets its own code and carries the reset time.
390    if is_usage_limit_message(normalized) {
391        return UserFacingError::new(codes::PROVIDER_USAGE_LIMIT_REACHED)
392            .with_optional_field("provider", context.provider.clone())
393            .with_optional_field("model_id", context.model_id.clone())
394            .with_optional_field("resets_at", parse_usage_limit_reset_at(normalized));
395    }
396
397    if lower.contains("(429)")
398        || lower.contains("rate limit")
399        || lower.contains("too many requests")
400    {
401        return UserFacingError::new(codes::PROVIDER_RATE_LIMITED)
402            .with_optional_field("provider", context.provider.clone())
403            .with_optional_field("model_id", context.model_id.clone())
404            .with_optional_field("retry_after", context.retry_after);
405    }
406
407    if lower.contains("(401)") || lower.contains("(403)") {
408        return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
409            .with_optional_field("provider", context.provider.clone())
410            .with_optional_field("model_id", context.model_id.clone());
411    }
412
413    if lower.contains("api key is required")
414        || lower.contains("configure the api key")
415        || lower.contains("api key missing")
416        || lower.contains("missing api key")
417        || lower.contains("invalid api key")
418    {
419        return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
420            .with_optional_field("provider", context.provider.clone())
421            .with_optional_field("model_id", context.model_id.clone());
422    }
423
424    if ["(500)", "(502)", "(503)", "(504)", "(529)"]
425        .iter()
426        .any(|code| lower.contains(code))
427    {
428        return UserFacingError::new(codes::PROVIDER_UNAVAILABLE)
429            .with_optional_field("provider", context.provider.clone())
430            .with_optional_field("model_id", context.model_id.clone());
431    }
432
433    UserFacingError::new(codes::PROCESSING_ERROR)
434        .with_optional_field("provider", context.provider.clone())
435        .with_optional_field("model_id", context.model_id.clone())
436}
437
438fn is_invalid_tool_schema_message(lower: &str) -> bool {
439    lower.contains("invalid_function_parameters")
440        || lower.contains("invalid function parameters")
441        || (lower.contains("invalid json schema") && lower.contains("$.properties"))
442        || lower.contains("invalid tool schema")
443}
444
445fn extract_schema_path(message: &str) -> Option<String> {
446    let path = message.split_once("Found at ")?.1;
447    let path = path
448        .split(|character: char| character.is_whitespace() || character == '`')
449        .next()?
450        .trim_end_matches(['.', ',', ';', ':']);
451    (path.starts_with('$')
452        && path.len() <= 200
453        && path.chars().all(|character| {
454            character.is_ascii_alphanumeric()
455                || matches!(character, '$' | '.' | '_' | '-' | '[' | ']')
456        }))
457    .then(|| path.to_string())
458}
459
460pub fn trim_error_chain_prefixes(error_chain: &str) -> &str {
461    error_chain
462        .trim()
463        .trim_start_matches("InputAtom execution failed: ")
464        .trim_start_matches("ReasonAtom execution failed: ")
465        .trim_start_matches("ActAtom execution failed: ")
466}
467
468/// Render the copy for a subscription/plan usage-limit error. The `resets_at`
469/// field (unix seconds) is rendered as a UTC fallback; clients localize it into
470/// the viewer's timezone from the same raw field. When `auto_continue` is set —
471/// added by the emit site only when an auto-continue capability is active — the
472/// copy promises automatic resumption; otherwise it stays generic.
473fn usage_limit_reached_message(fields: &UserFacingErrorFields) -> String {
474    let mut message = String::from("You're out of LLM usage limits.");
475
476    if let Some(resets_at) = number_field(fields, "resets_at")
477        && let Some(reset) = chrono::DateTime::from_timestamp(resets_at as i64, 0)
478    {
479        message.push_str(&format!(
480            " Your usage limit resets at {}.",
481            reset.format("%H:%M UTC on %b %-d")
482        ));
483    }
484
485    if bool_field(fields, "auto_continue") {
486        message.push_str(" We'll continue work automatically once it resets.");
487    }
488
489    message
490}
491
492fn budget_exhausted_message(fields: &UserFacingErrorFields) -> String {
493    if let (Some(spent), Some(limit), Some(currency)) = (
494        number_field(fields, "spent"),
495        number_field(fields, "limit"),
496        string_field(fields, "currency"),
497    ) {
498        let comparison = if spent > limit { "exceeded" } else { "reached" };
499        return format!(
500            "Budget exhausted. {:.2} {} spent {} the {:.2} {} limit. Increase the budget to continue.",
501            spent, currency, comparison, limit, currency
502        );
503    }
504
505    "Budget exhausted. Increase the budget to continue.".to_string()
506}
507
508fn budget_paused_message(fields: &UserFacingErrorFields) -> String {
509    let spent = number_field(fields, "spent");
510    let currency = string_field(fields, "currency");
511    let soft_limit = number_field(fields, "soft_limit");
512
513    match (spent, currency, soft_limit) {
514        (Some(spent), Some(currency), Some(soft_limit)) => {
515            let comparison = if spent > soft_limit {
516                "exceeded"
517            } else if spent >= soft_limit {
518                "reached"
519            } else {
520                "with"
521            };
522            if comparison == "with" {
523                format!(
524                    "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
525                    spent, currency
526                )
527            } else {
528                format!(
529                    "Budget paused. {:.2} {} spent {} the {:.2} {} soft limit. Increase or resume the budget to continue.",
530                    spent, currency, comparison, soft_limit, currency
531                )
532            }
533        }
534        (Some(spent), Some(currency), None) => format!(
535            "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
536            spent, currency
537        ),
538        _ => "Budget paused. Increase or resume the budget to continue.".to_string(),
539    }
540}
541
542fn parse_budget_exhausted_fields(message: &str) -> Option<UserFacingErrorFields> {
543    static RE: OnceLock<Regex> = OnceLock::new();
544    let re = RE.get_or_init(|| {
545        Regex::new(
546            r"^Budget exhausted\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<limit>\d+(?:\.\d+)?) \S+ limit\.",
547        )
548        .expect("valid budget exhausted regex")
549    });
550    let caps = re.captures(message)?;
551    Some(
552        UserFacingErrorFields::new()
553            .with_number("spent", caps.name("spent")?.as_str())
554            .with_number("limit", caps.name("limit")?.as_str())
555            .with_string("currency", caps.name("currency")?.as_str()),
556    )
557}
558
559fn parse_budget_paused_fields(message: &str) -> Option<UserFacingErrorFields> {
560    static SOFT_LIMIT_RE: OnceLock<Regex> = OnceLock::new();
561    static SIMPLE_RE: OnceLock<Regex> = OnceLock::new();
562
563    let soft_limit_re = SOFT_LIMIT_RE.get_or_init(|| {
564        Regex::new(
565            r"^Budget paused\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<soft_limit>\d+(?:\.\d+)?) \S+ soft limit\.",
566        )
567        .expect("valid budget paused regex")
568    });
569    if let Some(caps) = soft_limit_re.captures(message) {
570        return Some(
571            UserFacingErrorFields::new()
572                .with_number("spent", caps.name("spent")?.as_str())
573                .with_number("soft_limit", caps.name("soft_limit")?.as_str())
574                .with_string("currency", caps.name("currency")?.as_str()),
575        );
576    }
577
578    let simple_re = SIMPLE_RE.get_or_init(|| {
579        Regex::new(r"^Budget paused with (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent\.")
580            .expect("valid budget paused simple regex")
581    });
582    let caps = simple_re.captures(message)?;
583    Some(
584        UserFacingErrorFields::new()
585            .with_number("spent", caps.name("spent")?.as_str())
586            .with_string("currency", caps.name("currency")?.as_str()),
587    )
588}
589
590fn string_field<'a>(fields: &'a UserFacingErrorFields, key: &str) -> Option<&'a str> {
591    fields.get(key)?.as_str()
592}
593
594fn bool_field(fields: &UserFacingErrorFields, key: &str) -> bool {
595    fields.get(key).and_then(Value::as_bool).unwrap_or(false)
596}
597
598fn truncate_chars(value: &str, max_chars: usize) -> String {
599    if value.chars().count() <= max_chars {
600        return value.to_string();
601    }
602    let truncated: String = value.chars().take(max_chars).collect();
603    format!("{truncated}\u{2026}")
604}
605
606fn number_field(fields: &UserFacingErrorFields, key: &str) -> Option<f64> {
607    match fields.get(key)? {
608        Value::Number(number) => number.as_f64(),
609        Value::String(value) => value.parse().ok(),
610        _ => None,
611    }
612}
613
614trait ErrorFieldsExt {
615    fn with_string(self, key: &str, value: &str) -> Self;
616    fn with_number(self, key: &str, value: &str) -> Self;
617}
618
619impl ErrorFieldsExt for UserFacingErrorFields {
620    fn with_string(mut self, key: &str, value: &str) -> Self {
621        self.insert(key.to_string(), Value::String(value.to_string()));
622        self
623    }
624
625    fn with_number(mut self, key: &str, value: &str) -> Self {
626        if let Ok(number) = value.parse::<f64>()
627            && let Some(json_number) = serde_json::Number::from_f64(number)
628        {
629            self.insert(key.to_string(), Value::Number(json_number));
630        }
631        self
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[test]
640    fn classify_budget_exhausted_parses_fields() {
641        let error = classify_runtime_error_message(
642            "ReasonAtom execution failed: Budget exhausted. 12.50 usd spent exceeded the 10.00 usd limit. Increase the budget to continue.",
643            &UserFacingErrorContext::default(),
644        );
645
646        assert_eq!(error.code, codes::BUDGET_EXHAUSTED);
647        assert_eq!(number_field(&error.fields, "spent"), Some(12.5));
648        assert_eq!(number_field(&error.fields, "limit"), Some(10.0));
649        assert_eq!(string_field(&error.fields, "currency"), Some("usd"));
650    }
651
652    #[test]
653    fn classify_provider_rate_limit_keeps_context() {
654        let error = classify_runtime_error_message(
655            "OpenAI API error (429): rate limit exceeded",
656            &UserFacingErrorContext::default()
657                .with_provider("openai")
658                .with_model_id("gpt-5")
659                .with_retry_after(7),
660        );
661
662        assert_eq!(error.code, codes::PROVIDER_RATE_LIMITED);
663        assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
664        assert_eq!(string_field(&error.fields, "model_id"), Some("gpt-5"));
665        assert_eq!(number_field(&error.fields, "retry_after"), Some(7.0));
666    }
667
668    #[test]
669    fn classifies_openai_tool_schema_rejection_without_exposing_provider_payload() {
670        let error = classify_runtime_error_message(
671            "OpenAI Responses API error (400 Bad Request): Invalid JSON schema: regex lookaround is not supported. Found at $.properties.email.pattern.",
672            &UserFacingErrorContext::default()
673                .with_provider("openai")
674                .with_model_id("gpt-5.6-terra"),
675        );
676
677        assert_eq!(error.code, codes::INVALID_TOOL_SCHEMA);
678        assert_eq!(
679            error.fields.get("schema_path").and_then(Value::as_str),
680            Some("$.properties.email.pattern")
681        );
682        assert_eq!(
683            error.fallback_message(),
684            "A connected tool uses an input schema that this model provider does not support. Update the integration or choose a different model provider, then try again."
685        );
686        assert!(!error.fallback_message().contains("regex lookaround"));
687    }
688
689    #[test]
690    fn invalid_tool_schema_drops_unsafe_provider_schema_path() {
691        let error = classify_runtime_error_message(
692            "Invalid JSON schema at $.properties: Found at $.properties.email.pattern?<token>.",
693            &UserFacingErrorContext::default(),
694        );
695
696        assert_eq!(error.code, codes::INVALID_TOOL_SCHEMA);
697        assert!(!error.fields.contains_key("schema_path"));
698    }
699
700    #[test]
701    fn classify_openai_insufficient_quota_as_provider_quota_exhausted() {
702        // OpenAI's exhausted-billing 429 needs operator action (top up the
703        // account), not the transient "rate limited, wait a moment" copy and
704        // not the "misconfigured" copy used for bad API keys.
705        let error = classify_runtime_error_message(
706            "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\"}}",
707            &UserFacingErrorContext::default()
708                .with_provider("openai")
709                .with_model_id("gpt-4.1-mini"),
710        );
711
712        assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
713        assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
714        assert_eq!(
715            string_field(&error.fields, "model_id"),
716            Some("gpt-4.1-mini")
717        );
718    }
719
720    #[test]
721    fn classify_insufficient_quota_without_status_prefix() {
722        // Even if upstream wrapping drops the "(429)" prefix, the explicit
723        // quota substring must still route to PROVIDER_QUOTA_EXHAUSTED rather
724        // than the canned PROCESSING_ERROR fallback (EVE-472).
725        let error = classify_runtime_error_message(
726            "LLM error: insufficient_quota: You exceeded your current quota.",
727            &UserFacingErrorContext::default(),
728        );
729
730        assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
731    }
732
733    #[test]
734    fn classify_credit_balance_exhausted_as_provider_quota_exhausted() {
735        let error = classify_runtime_error_message(
736            "LLM error: credit_balance_exhausted: You have no credits remaining. secret=hidden",
737            &UserFacingErrorContext::default(),
738        );
739
740        assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
741        assert_eq!(
742            error.fallback_message(),
743            "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
744        );
745        assert!(!error.fallback_message().contains("secret"));
746    }
747
748    #[test]
749    fn classify_codex_usage_limit_reached_as_usage_limit() {
750        // The Codex/ChatGPT 429 usage-limit body must route to its own code
751        // (recovers at `resets_at`) rather than the transient rate-limit copy,
752        // and must capture the absolute reset timestamp for clients to localize.
753        let error = classify_runtime_error_message(
754            "LLM error: Codex API error (429 Too Many Requests): {\"error\":{\"type\":\"usage_limit_reached\",\"message\":\"The usage limit has been reached\",\"plan_type\":\"pro\",\"resets_at\":1783767823,\"eligible_promo\":null,\"resets_in_seconds\":12337}}",
755            &UserFacingErrorContext::default()
756                .with_provider("openai-codex")
757                .with_model_id("gpt-5-codex"),
758        );
759
760        assert_eq!(error.code, codes::PROVIDER_USAGE_LIMIT_REACHED);
761        assert_eq!(
762            string_field(&error.fields, "provider"),
763            Some("openai-codex")
764        );
765        assert_eq!(number_field(&error.fields, "resets_at"), Some(1783767823.0));
766
767        // Base copy is human-readable and names the reset time; without the
768        // capability field it makes no automatic-continuation promise.
769        let message = error.fallback_message();
770        assert!(
771            message.starts_with("You're out of LLM usage limits."),
772            "unexpected copy: {message}"
773        );
774        assert!(
775            message.contains("resets at"),
776            "missing reset time: {message}"
777        );
778        assert!(
779            !message.contains("automatically"),
780            "unexpected promise: {message}"
781        );
782    }
783
784    #[test]
785    fn usage_limit_message_without_reset_time_stays_generic() {
786        let error = classify_runtime_error_message(
787            "Some Provider API error (429): usage limit reached",
788            &UserFacingErrorContext::default(),
789        );
790
791        assert_eq!(error.code, codes::PROVIDER_USAGE_LIMIT_REACHED);
792        assert_eq!(number_field(&error.fields, "resets_at"), None);
793        assert_eq!(error.fallback_message(), "You're out of LLM usage limits.");
794    }
795
796    #[test]
797    fn usage_limit_message_appends_auto_continue_suffix_when_flagged() {
798        // The emit site sets `auto_continue` only when an auto-continue
799        // capability is active; the copy then promises automatic resumption.
800        let error = UserFacingError::new(codes::PROVIDER_USAGE_LIMIT_REACHED)
801            .with_field("resets_at", 1783767823)
802            .with_field("auto_continue", true);
803
804        let message = error.fallback_message();
805        assert!(
806            message.contains("resets at"),
807            "missing reset time: {message}"
808        );
809        assert!(
810            message.contains("We'll continue work automatically once it resets."),
811            "missing auto-continue promise: {message}"
812        );
813    }
814
815    #[test]
816    fn classify_anthropic_low_credit_balance_as_provider_quota_exhausted() {
817        let error = classify_runtime_error_message(
818            "Anthropic API error (400): {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits.\"}}",
819            &UserFacingErrorContext::default().with_provider("anthropic"),
820        );
821
822        assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
823    }
824
825    #[test]
826    fn disclosure_generic_collapses_code_and_fields() {
827        let error = UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
828            .with_field("provider", "openai")
829            .with_field("model_id", "gpt-4.1-mini");
830
831        let disclosed = error.apply_disclosure(ErrorDisclosure::Generic, Some("raw detail"));
832
833        assert_eq!(disclosed.code, codes::PROCESSING_ERROR);
834        assert!(disclosed.fields.is_empty());
835        assert_eq!(
836            disclosed.fallback_message(),
837            "I encountered an error while processing your request. Please try again later."
838        );
839    }
840
841    #[test]
842    fn disclosure_standard_is_identity() {
843        let error = UserFacingError::new(codes::PROVIDER_RATE_LIMITED).with_field("retry_after", 7);
844        let disclosed = error.apply_disclosure(ErrorDisclosure::Standard, Some("raw detail"));
845        assert_eq!(disclosed, error);
846    }
847
848    #[test]
849    fn disclosure_detailed_attaches_detail_without_rendering_it() {
850        let error = UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED);
851        let disclosed = error.apply_disclosure(
852            ErrorDisclosure::Detailed,
853            Some("OpenAI API error (429): insufficient_quota Authorization: Bearer sk-secret"),
854        );
855
856        assert_eq!(disclosed.code, codes::PROVIDER_QUOTA_EXHAUSTED);
857        assert_eq!(
858            string_field(&disclosed.fields, "detail"),
859            Some("OpenAI API error (429): insufficient_quota Authorization: Bearer sk-secret")
860        );
861        let message = disclosed.fallback_message();
862        assert!(message.contains("out of credits or quota"));
863        assert!(!message.contains("insufficient_quota"));
864        assert!(!message.contains("sk-secret"));
865    }
866
867    #[test]
868    fn disclosure_detailed_truncates_long_detail() {
869        let error = UserFacingError::new(codes::PROCESSING_ERROR);
870        let long_detail = "x".repeat(5000);
871        let disclosed = error.apply_disclosure(ErrorDisclosure::Detailed, Some(&long_detail));
872        let detail = string_field(&disclosed.fields, "detail").unwrap();
873        assert!(detail.chars().count() <= 1001); // 1000 + ellipsis
874    }
875
876    #[test]
877    fn disclosure_parse_and_ordering() {
878        assert_eq!(
879            ErrorDisclosure::parse("Generic"),
880            Some(ErrorDisclosure::Generic)
881        );
882        assert_eq!(
883            ErrorDisclosure::parse("detailed"),
884            Some(ErrorDisclosure::Detailed)
885        );
886        assert_eq!(ErrorDisclosure::parse("nope"), None);
887        assert!(ErrorDisclosure::Generic < ErrorDisclosure::Standard);
888        assert!(ErrorDisclosure::Standard < ErrorDisclosure::Detailed);
889        assert_eq!(ErrorDisclosure::default(), ErrorDisclosure::Standard);
890    }
891
892    #[test]
893    fn classify_missing_api_key_as_provider_misconfigured() {
894        let error = classify_runtime_error_message(
895            "LLM error: API key is required. Configure the API key in provider settings.",
896            &UserFacingErrorContext::default().with_provider("openai"),
897        );
898
899        assert_eq!(error.code, codes::PROVIDER_MISCONFIGURED);
900        assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
901    }
902
903    #[test]
904    fn fallback_message_reuses_budget_fields() {
905        let error = UserFacingError::new(codes::BUDGET_PAUSED)
906            .with_field("spent", 5.0)
907            .with_field("soft_limit", 5.0)
908            .with_field("currency", "tokens");
909
910        assert_eq!(
911            error.fallback_message(),
912            "Budget paused. 5.00 tokens spent reached the 5.00 tokens soft limit. Increase or resume the budget to continue."
913        );
914    }
915}