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