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    /// Provider account is out of credits/quota (billing). Distinct from
18    /// `provider_misconfigured` (bad/missing API key) so operators can tell
19    /// "top up the account" apart from "fix the key".
20    pub const PROVIDER_QUOTA_EXHAUSTED: &str = "provider_quota_exhausted";
21    pub const PROVIDER_UNAVAILABLE: &str = "provider_unavailable";
22    pub const PROCESSING_ERROR: &str = "processing_error";
23    pub const DEPENDENCY_UNAVAILABLE: &str = "dependency_unavailable";
24    pub const MAX_ITERATIONS: &str = "max_iterations";
25    pub const SOFT_LIMIT_REACHED: &str = "soft_limit_reached";
26    /// A `user_prompt_submit` hook rejected the inbound user message.
27    pub const BLOCKED_BY_HOOK: &str = "blocked_by_hook";
28}
29
30pub type UserFacingErrorFields = BTreeMap<String, Value>;
31
32/// Message/event metadata keys used to track error disclosure decisions.
33pub mod metadata_keys {
34    /// Disclosure mode applied when the error surfaced ("generic" | "standard" | "detailed").
35    pub const ERROR_DISCLOSURE: &str = "error_disclosure";
36    /// The classified error code before disclosure was applied. Differs from
37    /// `error_code` only in `generic` mode, where the displayed code collapses
38    /// to `processing_error`.
39    pub const SOURCE_ERROR_CODE: &str = "source_error_code";
40}
41
42/// How much detail about a run-blocking error is shown to session viewers.
43///
44/// Ordering matters: variants are declared least → most disclosing so that
45/// per-message control overrides can be clamped with `min` against the
46/// capability-configured ceiling.
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49#[cfg_attr(feature = "openapi", derive(ToSchema))]
50pub enum ErrorDisclosure {
51    /// Collapse every blocking error into one generic, localizable message
52    /// (`processing_error`, no fields). For public-facing agents.
53    Generic,
54    /// Stable error code + structured interpolation fields. Current default.
55    #[default]
56    Standard,
57    /// Standard plus a `detail` field carrying the underlying driver error
58    /// text. For trusted surfaces such as coding-agent harnesses.
59    Detailed,
60}
61
62impl ErrorDisclosure {
63    pub fn parse(value: &str) -> Option<Self> {
64        match value.trim().to_ascii_lowercase().as_str() {
65            "generic" => Some(ErrorDisclosure::Generic),
66            "standard" => Some(ErrorDisclosure::Standard),
67            "detailed" => Some(ErrorDisclosure::Detailed),
68            _ => None,
69        }
70    }
71
72    pub fn as_str(&self) -> &'static str {
73        match self {
74            ErrorDisclosure::Generic => "generic",
75            ErrorDisclosure::Standard => "standard",
76            ErrorDisclosure::Detailed => "detailed",
77        }
78    }
79}
80
81/// Maximum length of the `detail` field attached in `Detailed` mode. Provider
82/// error bodies are normally short; this guards against pathological payloads
83/// bloating messages and events.
84const DETAIL_MAX_CHARS: usize = 1000;
85
86/// Provider quota/billing-exhaustion patterns shared by the string classifier
87/// and the driver-boundary semantic classifier (`LlmErrorKind`).
88pub fn is_provider_quota_message(message: &str) -> bool {
89    let lower = message.to_ascii_lowercase();
90    lower.contains("insufficient_quota")
91        || lower.contains("insufficient quota")
92        || lower.contains("exceeded your current quota")
93        || lower.contains("credit balance is too low")
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
97#[cfg_attr(feature = "openapi", derive(ToSchema))]
98pub struct UserFacingError {
99    pub code: String,
100    #[serde(default, skip_serializing_if = "UserFacingErrorFields::is_empty")]
101    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
102    pub fields: UserFacingErrorFields,
103}
104
105#[derive(Debug, Clone, Default)]
106pub struct UserFacingErrorContext {
107    pub provider: Option<String>,
108    pub model_id: Option<String>,
109    pub retry_after: Option<u64>,
110}
111
112impl UserFacingErrorContext {
113    pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
114        self.provider = Some(provider.into());
115        self
116    }
117
118    pub fn with_model_id(mut self, model_id: impl Into<String>) -> Self {
119        self.model_id = Some(model_id.into());
120        self
121    }
122
123    pub fn with_retry_after(mut self, retry_after: u64) -> Self {
124        self.retry_after = Some(retry_after);
125        self
126    }
127}
128
129impl UserFacingError {
130    pub fn new(code: impl Into<String>) -> Self {
131        Self {
132            code: code.into(),
133            fields: UserFacingErrorFields::new(),
134        }
135    }
136
137    pub fn with_field<T: Serialize>(mut self, key: impl Into<String>, value: T) -> Self {
138        let value = serde_json::to_value(value).unwrap_or(Value::Null);
139        if !value.is_null() {
140            self.fields.insert(key.into(), value);
141        }
142        self
143    }
144
145    pub fn with_optional_field<T: Serialize>(
146        self,
147        key: impl Into<String>,
148        value: Option<T>,
149    ) -> Self {
150        match value {
151            Some(value) => self.with_field(key, value),
152            None => self,
153        }
154    }
155
156    pub fn error_fields(&self) -> Option<UserFacingErrorFields> {
157        (!self.fields.is_empty()).then_some(self.fields.clone())
158    }
159
160    pub fn apply_to_event_fields(
161        &self,
162        error_code: &mut Option<String>,
163        error_fields: &mut Option<UserFacingErrorFields>,
164    ) {
165        *error_code = Some(self.code.clone());
166        *error_fields = self.error_fields();
167    }
168
169    pub fn apply_to_message_metadata(&self, metadata: &mut HashMap<String, Value>) {
170        metadata.insert("error_code".to_string(), Value::String(self.code.clone()));
171        if let Some(fields) = self.error_fields() {
172            metadata.insert(
173                "error_fields".to_string(),
174                serde_json::to_value(fields).unwrap_or(Value::Null),
175            );
176        }
177    }
178
179    /// Apply an error-disclosure mode, returning the error as it should be
180    /// shown to session viewers. The original (source) error stays available
181    /// to the caller for tracking metadata.
182    ///
183    /// - `Generic` collapses to `processing_error` with no fields.
184    /// - `Standard` returns the error unchanged.
185    /// - `Detailed` attaches `detail` (the underlying driver error text,
186    ///   truncated) as an extra interpolation field.
187    pub fn apply_disclosure(&self, mode: ErrorDisclosure, detail: Option<&str>) -> UserFacingError {
188        match mode {
189            ErrorDisclosure::Generic => UserFacingError::new(codes::PROCESSING_ERROR),
190            ErrorDisclosure::Standard => self.clone(),
191            ErrorDisclosure::Detailed => {
192                let detail = detail.map(str::trim).filter(|d| !d.is_empty());
193                match detail {
194                    Some(detail) => self
195                        .clone()
196                        .with_field("detail", truncate_chars(detail, DETAIL_MAX_CHARS)),
197                    None => self.clone(),
198                }
199            }
200        }
201    }
202
203    /// Record disclosure tracking metadata on a message: the mode that was
204    /// applied and the pre-disclosure (source) error code.
205    pub fn apply_disclosure_to_message_metadata(
206        metadata: &mut HashMap<String, Value>,
207        mode: ErrorDisclosure,
208        source_code: &str,
209    ) {
210        metadata.insert(
211            metadata_keys::ERROR_DISCLOSURE.to_string(),
212            Value::String(mode.as_str().to_string()),
213        );
214        metadata.insert(
215            metadata_keys::SOURCE_ERROR_CODE.to_string(),
216            Value::String(source_code.to_string()),
217        );
218    }
219
220    pub fn fallback_message(&self) -> String {
221        let base = self.base_fallback_message();
222        match string_field(&self.fields, "detail") {
223            Some(detail) => format!("{base}\n\nDetails: {detail}"),
224            None => base,
225        }
226    }
227
228    fn base_fallback_message(&self) -> String {
229        match self.code.as_str() {
230            codes::BUDGET_EXHAUSTED => budget_exhausted_message(&self.fields),
231            codes::BUDGET_PAUSED => budget_paused_message(&self.fields),
232            codes::SOFT_LIMIT_REACHED => string_field(&self.fields, "message")
233                .unwrap_or("Soft limit reached.")
234                .to_string(),
235            codes::MODEL_UNAVAILABLE => {
236                if let Some(model_id) = string_field(&self.fields, "model_id") {
237                    format!(
238                        "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.",
239                        model_id
240                    )
241                } else {
242                    "The selected model is not available. Please select a different model."
243                        .to_string()
244                }
245            }
246            codes::REQUEST_TOO_LARGE => {
247                "The conversation has become too long for the model to process. Please start a new session or reduce the context size.".to_string()
248            }
249            codes::PROVIDER_RATE_LIMITED => {
250                "Rate limited by the AI provider. Please wait a moment.".to_string()
251            }
252            codes::PROVIDER_MISCONFIGURED => {
253                "There is a misconfiguration with the AI provider. Please contact support."
254                    .to_string()
255            }
256            codes::PROVIDER_QUOTA_EXHAUSTED => {
257                "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
258                    .to_string()
259            }
260            codes::PROVIDER_UNAVAILABLE => {
261                "The AI provider is experiencing issues. Please try again shortly.".to_string()
262            }
263            codes::DEPENDENCY_UNAVAILABLE => {
264                "Execution stopped because a required dependency is unavailable.".to_string()
265            }
266            _ => "I encountered an error while processing your request. Please try again later."
267                .to_string(),
268        }
269    }
270}
271
272pub fn classify_runtime_error_message(
273    error: &str,
274    context: &UserFacingErrorContext,
275) -> UserFacingError {
276    let normalized = trim_error_chain_prefixes(error).trim();
277    let lower = normalized.to_ascii_lowercase();
278
279    if let Some(fields) = parse_budget_exhausted_fields(normalized) {
280        return UserFacingError {
281            code: codes::BUDGET_EXHAUSTED.to_string(),
282            fields,
283        };
284    }
285
286    if normalized.starts_with("Budget exhausted.") {
287        return UserFacingError::new(codes::BUDGET_EXHAUSTED);
288    }
289
290    if normalized.starts_with("Budget exhausted (") {
291        return UserFacingError::new(codes::BUDGET_EXHAUSTED);
292    }
293
294    if let Some(fields) = parse_budget_paused_fields(normalized) {
295        return UserFacingError {
296            code: codes::BUDGET_PAUSED.to_string(),
297            fields,
298        };
299    }
300
301    if normalized.starts_with("Budget paused.") || normalized.starts_with("Budget paused with ") {
302        return UserFacingError::new(codes::BUDGET_PAUSED);
303    }
304
305    if normalized.starts_with("Budget paused (") || normalized.starts_with("Soft limit reached.") {
306        return if normalized.starts_with("Soft limit reached.") {
307            UserFacingError::new(codes::SOFT_LIMIT_REACHED).with_field("message", normalized)
308        } else {
309            UserFacingError::new(codes::BUDGET_PAUSED)
310        };
311    }
312
313    if let Some(model_id) = normalized.strip_prefix("Model not available: ") {
314        return UserFacingError::new(codes::MODEL_UNAVAILABLE).with_field("model_id", model_id);
315    }
316
317    if normalized.starts_with("Request too large:")
318        || lower.contains("context length")
319        || lower.contains("maximum context length")
320    {
321        return UserFacingError::new(codes::REQUEST_TOO_LARGE)
322            .with_optional_field("provider", context.provider.clone())
323            .with_optional_field("model_id", context.model_id.clone());
324    }
325
326    // Exhausted provider billing (OpenAI: HTTP 429 + `insufficient_quota`,
327    // Anthropic: 400 + "credit balance is too low"). The "(429)" prefix would
328    // otherwise route it to PROVIDER_RATE_LIMITED ("wait a moment"), but the
329    // condition is non-transient and needs operator action (top up the
330    // account or raise limits), so it gets its own code.
331    if is_provider_quota_message(normalized) {
332        return UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
333            .with_optional_field("provider", context.provider.clone())
334            .with_optional_field("model_id", context.model_id.clone());
335    }
336
337    if lower.contains("(429)")
338        || lower.contains("rate limit")
339        || lower.contains("too many requests")
340    {
341        return UserFacingError::new(codes::PROVIDER_RATE_LIMITED)
342            .with_optional_field("provider", context.provider.clone())
343            .with_optional_field("model_id", context.model_id.clone())
344            .with_optional_field("retry_after", context.retry_after);
345    }
346
347    if lower.contains("(401)") || lower.contains("(403)") {
348        return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
349            .with_optional_field("provider", context.provider.clone())
350            .with_optional_field("model_id", context.model_id.clone());
351    }
352
353    if lower.contains("api key is required")
354        || lower.contains("configure the api key")
355        || lower.contains("api key missing")
356        || lower.contains("missing api key")
357        || lower.contains("invalid api key")
358    {
359        return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
360            .with_optional_field("provider", context.provider.clone())
361            .with_optional_field("model_id", context.model_id.clone());
362    }
363
364    if ["(500)", "(502)", "(503)", "(504)", "(529)"]
365        .iter()
366        .any(|code| lower.contains(code))
367    {
368        return UserFacingError::new(codes::PROVIDER_UNAVAILABLE)
369            .with_optional_field("provider", context.provider.clone())
370            .with_optional_field("model_id", context.model_id.clone());
371    }
372
373    UserFacingError::new(codes::PROCESSING_ERROR)
374        .with_optional_field("provider", context.provider.clone())
375        .with_optional_field("model_id", context.model_id.clone())
376}
377
378pub fn trim_error_chain_prefixes(error_chain: &str) -> &str {
379    error_chain
380        .trim()
381        .trim_start_matches("InputAtom execution failed: ")
382        .trim_start_matches("ReasonAtom execution failed: ")
383        .trim_start_matches("ActAtom execution failed: ")
384}
385
386fn budget_exhausted_message(fields: &UserFacingErrorFields) -> String {
387    if let (Some(spent), Some(limit), Some(currency)) = (
388        number_field(fields, "spent"),
389        number_field(fields, "limit"),
390        string_field(fields, "currency"),
391    ) {
392        let comparison = if spent > limit { "exceeded" } else { "reached" };
393        return format!(
394            "Budget exhausted. {:.2} {} spent {} the {:.2} {} limit. Increase the budget to continue.",
395            spent, currency, comparison, limit, currency
396        );
397    }
398
399    "Budget exhausted. Increase the budget to continue.".to_string()
400}
401
402fn budget_paused_message(fields: &UserFacingErrorFields) -> String {
403    let spent = number_field(fields, "spent");
404    let currency = string_field(fields, "currency");
405    let soft_limit = number_field(fields, "soft_limit");
406
407    match (spent, currency, soft_limit) {
408        (Some(spent), Some(currency), Some(soft_limit)) => {
409            let comparison = if spent > soft_limit {
410                "exceeded"
411            } else if spent >= soft_limit {
412                "reached"
413            } else {
414                "with"
415            };
416            if comparison == "with" {
417                format!(
418                    "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
419                    spent, currency
420                )
421            } else {
422                format!(
423                    "Budget paused. {:.2} {} spent {} the {:.2} {} soft limit. Increase or resume the budget to continue.",
424                    spent, currency, comparison, soft_limit, currency
425                )
426            }
427        }
428        (Some(spent), Some(currency), None) => format!(
429            "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
430            spent, currency
431        ),
432        _ => "Budget paused. Increase or resume the budget to continue.".to_string(),
433    }
434}
435
436fn parse_budget_exhausted_fields(message: &str) -> Option<UserFacingErrorFields> {
437    static RE: OnceLock<Regex> = OnceLock::new();
438    let re = RE.get_or_init(|| {
439        Regex::new(
440            r"^Budget exhausted\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<limit>\d+(?:\.\d+)?) \S+ limit\.",
441        )
442        .expect("valid budget exhausted regex")
443    });
444    let caps = re.captures(message)?;
445    Some(
446        UserFacingErrorFields::new()
447            .with_number("spent", caps.name("spent")?.as_str())
448            .with_number("limit", caps.name("limit")?.as_str())
449            .with_string("currency", caps.name("currency")?.as_str()),
450    )
451}
452
453fn parse_budget_paused_fields(message: &str) -> Option<UserFacingErrorFields> {
454    static SOFT_LIMIT_RE: OnceLock<Regex> = OnceLock::new();
455    static SIMPLE_RE: OnceLock<Regex> = OnceLock::new();
456
457    let soft_limit_re = SOFT_LIMIT_RE.get_or_init(|| {
458        Regex::new(
459            r"^Budget paused\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<soft_limit>\d+(?:\.\d+)?) \S+ soft limit\.",
460        )
461        .expect("valid budget paused regex")
462    });
463    if let Some(caps) = soft_limit_re.captures(message) {
464        return Some(
465            UserFacingErrorFields::new()
466                .with_number("spent", caps.name("spent")?.as_str())
467                .with_number("soft_limit", caps.name("soft_limit")?.as_str())
468                .with_string("currency", caps.name("currency")?.as_str()),
469        );
470    }
471
472    let simple_re = SIMPLE_RE.get_or_init(|| {
473        Regex::new(r"^Budget paused with (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent\.")
474            .expect("valid budget paused simple regex")
475    });
476    let caps = simple_re.captures(message)?;
477    Some(
478        UserFacingErrorFields::new()
479            .with_number("spent", caps.name("spent")?.as_str())
480            .with_string("currency", caps.name("currency")?.as_str()),
481    )
482}
483
484fn string_field<'a>(fields: &'a UserFacingErrorFields, key: &str) -> Option<&'a str> {
485    fields.get(key)?.as_str()
486}
487
488fn truncate_chars(value: &str, max_chars: usize) -> String {
489    if value.chars().count() <= max_chars {
490        return value.to_string();
491    }
492    let truncated: String = value.chars().take(max_chars).collect();
493    format!("{truncated}\u{2026}")
494}
495
496fn number_field(fields: &UserFacingErrorFields, key: &str) -> Option<f64> {
497    match fields.get(key)? {
498        Value::Number(number) => number.as_f64(),
499        Value::String(value) => value.parse().ok(),
500        _ => None,
501    }
502}
503
504trait ErrorFieldsExt {
505    fn with_string(self, key: &str, value: &str) -> Self;
506    fn with_number(self, key: &str, value: &str) -> Self;
507}
508
509impl ErrorFieldsExt for UserFacingErrorFields {
510    fn with_string(mut self, key: &str, value: &str) -> Self {
511        self.insert(key.to_string(), Value::String(value.to_string()));
512        self
513    }
514
515    fn with_number(mut self, key: &str, value: &str) -> Self {
516        if let Ok(number) = value.parse::<f64>()
517            && let Some(json_number) = serde_json::Number::from_f64(number)
518        {
519            self.insert(key.to_string(), Value::Number(json_number));
520        }
521        self
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn classify_budget_exhausted_parses_fields() {
531        let error = classify_runtime_error_message(
532            "ReasonAtom execution failed: Budget exhausted. 12.50 usd spent exceeded the 10.00 usd limit. Increase the budget to continue.",
533            &UserFacingErrorContext::default(),
534        );
535
536        assert_eq!(error.code, codes::BUDGET_EXHAUSTED);
537        assert_eq!(number_field(&error.fields, "spent"), Some(12.5));
538        assert_eq!(number_field(&error.fields, "limit"), Some(10.0));
539        assert_eq!(string_field(&error.fields, "currency"), Some("usd"));
540    }
541
542    #[test]
543    fn classify_provider_rate_limit_keeps_context() {
544        let error = classify_runtime_error_message(
545            "OpenAI API error (429): rate limit exceeded",
546            &UserFacingErrorContext::default()
547                .with_provider("openai")
548                .with_model_id("gpt-5")
549                .with_retry_after(7),
550        );
551
552        assert_eq!(error.code, codes::PROVIDER_RATE_LIMITED);
553        assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
554        assert_eq!(string_field(&error.fields, "model_id"), Some("gpt-5"));
555        assert_eq!(number_field(&error.fields, "retry_after"), Some(7.0));
556    }
557
558    #[test]
559    fn classify_openai_insufficient_quota_as_provider_quota_exhausted() {
560        // OpenAI's exhausted-billing 429 needs operator action (top up the
561        // account), not the transient "rate limited, wait a moment" copy and
562        // not the "misconfigured" copy used for bad API keys.
563        let error = classify_runtime_error_message(
564            "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\"}}",
565            &UserFacingErrorContext::default()
566                .with_provider("openai")
567                .with_model_id("gpt-4.1-mini"),
568        );
569
570        assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
571        assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
572        assert_eq!(
573            string_field(&error.fields, "model_id"),
574            Some("gpt-4.1-mini")
575        );
576    }
577
578    #[test]
579    fn classify_insufficient_quota_without_status_prefix() {
580        // Even if upstream wrapping drops the "(429)" prefix, the explicit
581        // quota substring must still route to PROVIDER_QUOTA_EXHAUSTED rather
582        // than the canned PROCESSING_ERROR fallback (EVE-472).
583        let error = classify_runtime_error_message(
584            "LLM error: insufficient_quota: You exceeded your current quota.",
585            &UserFacingErrorContext::default(),
586        );
587
588        assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
589    }
590
591    #[test]
592    fn classify_anthropic_low_credit_balance_as_provider_quota_exhausted() {
593        let error = classify_runtime_error_message(
594            "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.\"}}",
595            &UserFacingErrorContext::default().with_provider("anthropic"),
596        );
597
598        assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
599    }
600
601    #[test]
602    fn disclosure_generic_collapses_code_and_fields() {
603        let error = UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
604            .with_field("provider", "openai")
605            .with_field("model_id", "gpt-4.1-mini");
606
607        let disclosed = error.apply_disclosure(ErrorDisclosure::Generic, Some("raw detail"));
608
609        assert_eq!(disclosed.code, codes::PROCESSING_ERROR);
610        assert!(disclosed.fields.is_empty());
611        assert_eq!(
612            disclosed.fallback_message(),
613            "I encountered an error while processing your request. Please try again later."
614        );
615    }
616
617    #[test]
618    fn disclosure_standard_is_identity() {
619        let error = UserFacingError::new(codes::PROVIDER_RATE_LIMITED).with_field("retry_after", 7);
620        let disclosed = error.apply_disclosure(ErrorDisclosure::Standard, Some("raw detail"));
621        assert_eq!(disclosed, error);
622    }
623
624    #[test]
625    fn disclosure_detailed_attaches_detail_and_renders_it() {
626        let error = UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED);
627        let disclosed = error.apply_disclosure(
628            ErrorDisclosure::Detailed,
629            Some("OpenAI API error (429): insufficient_quota"),
630        );
631
632        assert_eq!(disclosed.code, codes::PROVIDER_QUOTA_EXHAUSTED);
633        assert_eq!(
634            string_field(&disclosed.fields, "detail"),
635            Some("OpenAI API error (429): insufficient_quota")
636        );
637        let message = disclosed.fallback_message();
638        assert!(message.contains("out of credits or quota"));
639        assert!(message.contains("Details: OpenAI API error (429): insufficient_quota"));
640    }
641
642    #[test]
643    fn disclosure_detailed_truncates_long_detail() {
644        let error = UserFacingError::new(codes::PROCESSING_ERROR);
645        let long_detail = "x".repeat(5000);
646        let disclosed = error.apply_disclosure(ErrorDisclosure::Detailed, Some(&long_detail));
647        let detail = string_field(&disclosed.fields, "detail").unwrap();
648        assert!(detail.chars().count() <= 1001); // 1000 + ellipsis
649    }
650
651    #[test]
652    fn disclosure_parse_and_ordering() {
653        assert_eq!(
654            ErrorDisclosure::parse("Generic"),
655            Some(ErrorDisclosure::Generic)
656        );
657        assert_eq!(
658            ErrorDisclosure::parse("detailed"),
659            Some(ErrorDisclosure::Detailed)
660        );
661        assert_eq!(ErrorDisclosure::parse("nope"), None);
662        assert!(ErrorDisclosure::Generic < ErrorDisclosure::Standard);
663        assert!(ErrorDisclosure::Standard < ErrorDisclosure::Detailed);
664        assert_eq!(ErrorDisclosure::default(), ErrorDisclosure::Standard);
665    }
666
667    #[test]
668    fn classify_missing_api_key_as_provider_misconfigured() {
669        let error = classify_runtime_error_message(
670            "LLM error: API key is required. Configure the API key in provider settings.",
671            &UserFacingErrorContext::default().with_provider("openai"),
672        );
673
674        assert_eq!(error.code, codes::PROVIDER_MISCONFIGURED);
675        assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
676    }
677
678    #[test]
679    fn fallback_message_reuses_budget_fields() {
680        let error = UserFacingError::new(codes::BUDGET_PAUSED)
681            .with_field("spent", 5.0)
682            .with_field("soft_limit", 5.0)
683            .with_field("currency", "tokens");
684
685        assert_eq!(
686            error.fallback_message(),
687            "Budget paused. 5.00 tokens spent reached the 5.00 tokens soft limit. Increase or resume the budget to continue."
688        );
689    }
690}