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        self.base_fallback_message()
222    }
223
224    fn base_fallback_message(&self) -> String {
225        match self.code.as_str() {
226            codes::BUDGET_EXHAUSTED => budget_exhausted_message(&self.fields),
227            codes::BUDGET_PAUSED => budget_paused_message(&self.fields),
228            codes::SOFT_LIMIT_REACHED => string_field(&self.fields, "message")
229                .unwrap_or("Soft limit reached.")
230                .to_string(),
231            codes::MODEL_UNAVAILABLE => {
232                if let Some(model_id) = string_field(&self.fields, "model_id") {
233                    format!(
234                        "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.",
235                        model_id
236                    )
237                } else {
238                    "The selected model is not available. Please select a different model."
239                        .to_string()
240                }
241            }
242            codes::REQUEST_TOO_LARGE => {
243                "The conversation has become too long for the model to process. Please start a new session or reduce the context size.".to_string()
244            }
245            codes::PROVIDER_RATE_LIMITED => {
246                "Rate limited by the AI provider. Please wait a moment.".to_string()
247            }
248            codes::PROVIDER_MISCONFIGURED => {
249                "There is a misconfiguration with the AI provider. Please contact support."
250                    .to_string()
251            }
252            codes::PROVIDER_QUOTA_EXHAUSTED => {
253                "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
254                    .to_string()
255            }
256            codes::PROVIDER_UNAVAILABLE => {
257                "The AI provider is experiencing issues. Please try again shortly.".to_string()
258            }
259            codes::DEPENDENCY_UNAVAILABLE => {
260                "Execution stopped because a required dependency is unavailable.".to_string()
261            }
262            _ => "I encountered an error while processing your request. Please try again later."
263                .to_string(),
264        }
265    }
266}
267
268pub fn classify_runtime_error_message(
269    error: &str,
270    context: &UserFacingErrorContext,
271) -> UserFacingError {
272    let normalized = trim_error_chain_prefixes(error).trim();
273    let lower = normalized.to_ascii_lowercase();
274
275    if let Some(fields) = parse_budget_exhausted_fields(normalized) {
276        return UserFacingError {
277            code: codes::BUDGET_EXHAUSTED.to_string(),
278            fields,
279        };
280    }
281
282    if normalized.starts_with("Budget exhausted.") {
283        return UserFacingError::new(codes::BUDGET_EXHAUSTED);
284    }
285
286    if normalized.starts_with("Budget exhausted (") {
287        return UserFacingError::new(codes::BUDGET_EXHAUSTED);
288    }
289
290    if let Some(fields) = parse_budget_paused_fields(normalized) {
291        return UserFacingError {
292            code: codes::BUDGET_PAUSED.to_string(),
293            fields,
294        };
295    }
296
297    if normalized.starts_with("Budget paused.") || normalized.starts_with("Budget paused with ") {
298        return UserFacingError::new(codes::BUDGET_PAUSED);
299    }
300
301    if normalized.starts_with("Budget paused (") || normalized.starts_with("Soft limit reached.") {
302        return if normalized.starts_with("Soft limit reached.") {
303            UserFacingError::new(codes::SOFT_LIMIT_REACHED).with_field("message", normalized)
304        } else {
305            UserFacingError::new(codes::BUDGET_PAUSED)
306        };
307    }
308
309    if let Some(model_id) = normalized.strip_prefix("Model not available: ") {
310        return UserFacingError::new(codes::MODEL_UNAVAILABLE).with_field("model_id", model_id);
311    }
312
313    if normalized.starts_with("Request too large:")
314        || lower.contains("context length")
315        || lower.contains("maximum context length")
316    {
317        return UserFacingError::new(codes::REQUEST_TOO_LARGE)
318            .with_optional_field("provider", context.provider.clone())
319            .with_optional_field("model_id", context.model_id.clone());
320    }
321
322    // Exhausted provider billing (OpenAI: HTTP 429 + `insufficient_quota`,
323    // Anthropic: 400 + "credit balance is too low"). The "(429)" prefix would
324    // otherwise route it to PROVIDER_RATE_LIMITED ("wait a moment"), but the
325    // condition is non-transient and needs operator action (top up the
326    // account or raise limits), so it gets its own code.
327    if is_provider_quota_message(normalized) {
328        return UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
329            .with_optional_field("provider", context.provider.clone())
330            .with_optional_field("model_id", context.model_id.clone());
331    }
332
333    if lower.contains("(429)")
334        || lower.contains("rate limit")
335        || lower.contains("too many requests")
336    {
337        return UserFacingError::new(codes::PROVIDER_RATE_LIMITED)
338            .with_optional_field("provider", context.provider.clone())
339            .with_optional_field("model_id", context.model_id.clone())
340            .with_optional_field("retry_after", context.retry_after);
341    }
342
343    if lower.contains("(401)") || lower.contains("(403)") {
344        return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
345            .with_optional_field("provider", context.provider.clone())
346            .with_optional_field("model_id", context.model_id.clone());
347    }
348
349    if lower.contains("api key is required")
350        || lower.contains("configure the api key")
351        || lower.contains("api key missing")
352        || lower.contains("missing api key")
353        || lower.contains("invalid api key")
354    {
355        return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
356            .with_optional_field("provider", context.provider.clone())
357            .with_optional_field("model_id", context.model_id.clone());
358    }
359
360    if ["(500)", "(502)", "(503)", "(504)", "(529)"]
361        .iter()
362        .any(|code| lower.contains(code))
363    {
364        return UserFacingError::new(codes::PROVIDER_UNAVAILABLE)
365            .with_optional_field("provider", context.provider.clone())
366            .with_optional_field("model_id", context.model_id.clone());
367    }
368
369    UserFacingError::new(codes::PROCESSING_ERROR)
370        .with_optional_field("provider", context.provider.clone())
371        .with_optional_field("model_id", context.model_id.clone())
372}
373
374pub fn trim_error_chain_prefixes(error_chain: &str) -> &str {
375    error_chain
376        .trim()
377        .trim_start_matches("InputAtom execution failed: ")
378        .trim_start_matches("ReasonAtom execution failed: ")
379        .trim_start_matches("ActAtom execution failed: ")
380}
381
382fn budget_exhausted_message(fields: &UserFacingErrorFields) -> String {
383    if let (Some(spent), Some(limit), Some(currency)) = (
384        number_field(fields, "spent"),
385        number_field(fields, "limit"),
386        string_field(fields, "currency"),
387    ) {
388        let comparison = if spent > limit { "exceeded" } else { "reached" };
389        return format!(
390            "Budget exhausted. {:.2} {} spent {} the {:.2} {} limit. Increase the budget to continue.",
391            spent, currency, comparison, limit, currency
392        );
393    }
394
395    "Budget exhausted. Increase the budget to continue.".to_string()
396}
397
398fn budget_paused_message(fields: &UserFacingErrorFields) -> String {
399    let spent = number_field(fields, "spent");
400    let currency = string_field(fields, "currency");
401    let soft_limit = number_field(fields, "soft_limit");
402
403    match (spent, currency, soft_limit) {
404        (Some(spent), Some(currency), Some(soft_limit)) => {
405            let comparison = if spent > soft_limit {
406                "exceeded"
407            } else if spent >= soft_limit {
408                "reached"
409            } else {
410                "with"
411            };
412            if comparison == "with" {
413                format!(
414                    "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
415                    spent, currency
416                )
417            } else {
418                format!(
419                    "Budget paused. {:.2} {} spent {} the {:.2} {} soft limit. Increase or resume the budget to continue.",
420                    spent, currency, comparison, soft_limit, currency
421                )
422            }
423        }
424        (Some(spent), Some(currency), None) => format!(
425            "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
426            spent, currency
427        ),
428        _ => "Budget paused. Increase or resume the budget to continue.".to_string(),
429    }
430}
431
432fn parse_budget_exhausted_fields(message: &str) -> Option<UserFacingErrorFields> {
433    static RE: OnceLock<Regex> = OnceLock::new();
434    let re = RE.get_or_init(|| {
435        Regex::new(
436            r"^Budget exhausted\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<limit>\d+(?:\.\d+)?) \S+ limit\.",
437        )
438        .expect("valid budget exhausted regex")
439    });
440    let caps = re.captures(message)?;
441    Some(
442        UserFacingErrorFields::new()
443            .with_number("spent", caps.name("spent")?.as_str())
444            .with_number("limit", caps.name("limit")?.as_str())
445            .with_string("currency", caps.name("currency")?.as_str()),
446    )
447}
448
449fn parse_budget_paused_fields(message: &str) -> Option<UserFacingErrorFields> {
450    static SOFT_LIMIT_RE: OnceLock<Regex> = OnceLock::new();
451    static SIMPLE_RE: OnceLock<Regex> = OnceLock::new();
452
453    let soft_limit_re = SOFT_LIMIT_RE.get_or_init(|| {
454        Regex::new(
455            r"^Budget paused\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<soft_limit>\d+(?:\.\d+)?) \S+ soft limit\.",
456        )
457        .expect("valid budget paused regex")
458    });
459    if let Some(caps) = soft_limit_re.captures(message) {
460        return Some(
461            UserFacingErrorFields::new()
462                .with_number("spent", caps.name("spent")?.as_str())
463                .with_number("soft_limit", caps.name("soft_limit")?.as_str())
464                .with_string("currency", caps.name("currency")?.as_str()),
465        );
466    }
467
468    let simple_re = SIMPLE_RE.get_or_init(|| {
469        Regex::new(r"^Budget paused with (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent\.")
470            .expect("valid budget paused simple regex")
471    });
472    let caps = simple_re.captures(message)?;
473    Some(
474        UserFacingErrorFields::new()
475            .with_number("spent", caps.name("spent")?.as_str())
476            .with_string("currency", caps.name("currency")?.as_str()),
477    )
478}
479
480fn string_field<'a>(fields: &'a UserFacingErrorFields, key: &str) -> Option<&'a str> {
481    fields.get(key)?.as_str()
482}
483
484fn truncate_chars(value: &str, max_chars: usize) -> String {
485    if value.chars().count() <= max_chars {
486        return value.to_string();
487    }
488    let truncated: String = value.chars().take(max_chars).collect();
489    format!("{truncated}\u{2026}")
490}
491
492fn number_field(fields: &UserFacingErrorFields, key: &str) -> Option<f64> {
493    match fields.get(key)? {
494        Value::Number(number) => number.as_f64(),
495        Value::String(value) => value.parse().ok(),
496        _ => None,
497    }
498}
499
500trait ErrorFieldsExt {
501    fn with_string(self, key: &str, value: &str) -> Self;
502    fn with_number(self, key: &str, value: &str) -> Self;
503}
504
505impl ErrorFieldsExt for UserFacingErrorFields {
506    fn with_string(mut self, key: &str, value: &str) -> Self {
507        self.insert(key.to_string(), Value::String(value.to_string()));
508        self
509    }
510
511    fn with_number(mut self, key: &str, value: &str) -> Self {
512        if let Ok(number) = value.parse::<f64>()
513            && let Some(json_number) = serde_json::Number::from_f64(number)
514        {
515            self.insert(key.to_string(), Value::Number(json_number));
516        }
517        self
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn classify_budget_exhausted_parses_fields() {
527        let error = classify_runtime_error_message(
528            "ReasonAtom execution failed: Budget exhausted. 12.50 usd spent exceeded the 10.00 usd limit. Increase the budget to continue.",
529            &UserFacingErrorContext::default(),
530        );
531
532        assert_eq!(error.code, codes::BUDGET_EXHAUSTED);
533        assert_eq!(number_field(&error.fields, "spent"), Some(12.5));
534        assert_eq!(number_field(&error.fields, "limit"), Some(10.0));
535        assert_eq!(string_field(&error.fields, "currency"), Some("usd"));
536    }
537
538    #[test]
539    fn classify_provider_rate_limit_keeps_context() {
540        let error = classify_runtime_error_message(
541            "OpenAI API error (429): rate limit exceeded",
542            &UserFacingErrorContext::default()
543                .with_provider("openai")
544                .with_model_id("gpt-5")
545                .with_retry_after(7),
546        );
547
548        assert_eq!(error.code, codes::PROVIDER_RATE_LIMITED);
549        assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
550        assert_eq!(string_field(&error.fields, "model_id"), Some("gpt-5"));
551        assert_eq!(number_field(&error.fields, "retry_after"), Some(7.0));
552    }
553
554    #[test]
555    fn classify_openai_insufficient_quota_as_provider_quota_exhausted() {
556        // OpenAI's exhausted-billing 429 needs operator action (top up the
557        // account), not the transient "rate limited, wait a moment" copy and
558        // not the "misconfigured" copy used for bad API keys.
559        let error = classify_runtime_error_message(
560            "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\"}}",
561            &UserFacingErrorContext::default()
562                .with_provider("openai")
563                .with_model_id("gpt-4.1-mini"),
564        );
565
566        assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
567        assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
568        assert_eq!(
569            string_field(&error.fields, "model_id"),
570            Some("gpt-4.1-mini")
571        );
572    }
573
574    #[test]
575    fn classify_insufficient_quota_without_status_prefix() {
576        // Even if upstream wrapping drops the "(429)" prefix, the explicit
577        // quota substring must still route to PROVIDER_QUOTA_EXHAUSTED rather
578        // than the canned PROCESSING_ERROR fallback (EVE-472).
579        let error = classify_runtime_error_message(
580            "LLM error: insufficient_quota: You exceeded your current quota.",
581            &UserFacingErrorContext::default(),
582        );
583
584        assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
585    }
586
587    #[test]
588    fn classify_anthropic_low_credit_balance_as_provider_quota_exhausted() {
589        let error = classify_runtime_error_message(
590            "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.\"}}",
591            &UserFacingErrorContext::default().with_provider("anthropic"),
592        );
593
594        assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
595    }
596
597    #[test]
598    fn disclosure_generic_collapses_code_and_fields() {
599        let error = UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
600            .with_field("provider", "openai")
601            .with_field("model_id", "gpt-4.1-mini");
602
603        let disclosed = error.apply_disclosure(ErrorDisclosure::Generic, Some("raw detail"));
604
605        assert_eq!(disclosed.code, codes::PROCESSING_ERROR);
606        assert!(disclosed.fields.is_empty());
607        assert_eq!(
608            disclosed.fallback_message(),
609            "I encountered an error while processing your request. Please try again later."
610        );
611    }
612
613    #[test]
614    fn disclosure_standard_is_identity() {
615        let error = UserFacingError::new(codes::PROVIDER_RATE_LIMITED).with_field("retry_after", 7);
616        let disclosed = error.apply_disclosure(ErrorDisclosure::Standard, Some("raw detail"));
617        assert_eq!(disclosed, error);
618    }
619
620    #[test]
621    fn disclosure_detailed_attaches_detail_without_rendering_it() {
622        let error = UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED);
623        let disclosed = error.apply_disclosure(
624            ErrorDisclosure::Detailed,
625            Some("OpenAI API error (429): insufficient_quota Authorization: Bearer sk-secret"),
626        );
627
628        assert_eq!(disclosed.code, codes::PROVIDER_QUOTA_EXHAUSTED);
629        assert_eq!(
630            string_field(&disclosed.fields, "detail"),
631            Some("OpenAI API error (429): insufficient_quota Authorization: Bearer sk-secret")
632        );
633        let message = disclosed.fallback_message();
634        assert!(message.contains("out of credits or quota"));
635        assert!(!message.contains("insufficient_quota"));
636        assert!(!message.contains("sk-secret"));
637    }
638
639    #[test]
640    fn disclosure_detailed_truncates_long_detail() {
641        let error = UserFacingError::new(codes::PROCESSING_ERROR);
642        let long_detail = "x".repeat(5000);
643        let disclosed = error.apply_disclosure(ErrorDisclosure::Detailed, Some(&long_detail));
644        let detail = string_field(&disclosed.fields, "detail").unwrap();
645        assert!(detail.chars().count() <= 1001); // 1000 + ellipsis
646    }
647
648    #[test]
649    fn disclosure_parse_and_ordering() {
650        assert_eq!(
651            ErrorDisclosure::parse("Generic"),
652            Some(ErrorDisclosure::Generic)
653        );
654        assert_eq!(
655            ErrorDisclosure::parse("detailed"),
656            Some(ErrorDisclosure::Detailed)
657        );
658        assert_eq!(ErrorDisclosure::parse("nope"), None);
659        assert!(ErrorDisclosure::Generic < ErrorDisclosure::Standard);
660        assert!(ErrorDisclosure::Standard < ErrorDisclosure::Detailed);
661        assert_eq!(ErrorDisclosure::default(), ErrorDisclosure::Standard);
662    }
663
664    #[test]
665    fn classify_missing_api_key_as_provider_misconfigured() {
666        let error = classify_runtime_error_message(
667            "LLM error: API key is required. Configure the API key in provider settings.",
668            &UserFacingErrorContext::default().with_provider("openai"),
669        );
670
671        assert_eq!(error.code, codes::PROVIDER_MISCONFIGURED);
672        assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
673    }
674
675    #[test]
676    fn fallback_message_reuses_budget_fields() {
677        let error = UserFacingError::new(codes::BUDGET_PAUSED)
678            .with_field("spent", 5.0)
679            .with_field("soft_limit", 5.0)
680            .with_field("currency", "tokens");
681
682        assert_eq!(
683            error.fallback_message(),
684            "Budget paused. 5.00 tokens spent reached the 5.00 tokens soft limit. Increase or resume the budget to continue."
685        );
686    }
687}