Skip to main content

everruns_contracts/
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    /// The provider account has not completed a confirmation the model
30    /// requires (OpenRouter's 18+ age verification is the canonical case).
31    /// Distinct from `provider_misconfigured` (the key is fine) and from
32    /// `provider_quota_exhausted` (nothing is owed): it clears only when the
33    /// account holder visits the provider's settings page, so the error
34    /// carries that URL rather than pointing at support.
35    pub const PROVIDER_ATTESTATION_REQUIRED: &str = "provider_attestation_required";
36    pub const PROVIDER_UNAVAILABLE: &str = "provider_unavailable";
37    pub const PROCESSING_ERROR: &str = "processing_error";
38    /// Provider-held session state (OpenAI Agents API) is gone: deleted,
39    /// expired, or out of reach of the current credentials. The next turn
40    /// starts a new provider session from the Everruns record.
41    pub const PROVIDER_SESSION_UNAVAILABLE: &str = "provider_session_unavailable";
42    pub const DEPENDENCY_UNAVAILABLE: &str = "dependency_unavailable";
43    pub const INVALID_TOOL_SCHEMA: &str = "invalid_tool_schema";
44    pub const MAX_ITERATIONS: &str = "max_iterations";
45    pub const SOFT_LIMIT_REACHED: &str = "soft_limit_reached";
46    /// A `user_prompt_submit` hook rejected the inbound user message.
47    pub const BLOCKED_BY_HOOK: &str = "blocked_by_hook";
48}
49
50pub type UserFacingErrorFields = BTreeMap<String, Value>;
51
52/// Message/event metadata keys used to track error disclosure decisions.
53pub mod metadata_keys {
54    /// Disclosure mode applied when the error surfaced ("generic" | "standard" | "detailed").
55    pub const ERROR_DISCLOSURE: &str = "error_disclosure";
56    /// The classified error code before disclosure was applied. Differs from
57    /// `error_code` only in `generic` mode, where the displayed code collapses
58    /// to `processing_error`.
59    pub const SOURCE_ERROR_CODE: &str = "source_error_code";
60}
61
62/// How much detail about a run-blocking error is shown to session viewers.
63///
64/// Ordering matters: variants are declared least → most disclosing so that
65/// per-message control overrides can be clamped with `min` against the
66/// capability-configured ceiling.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69#[cfg_attr(feature = "openapi", derive(ToSchema))]
70pub enum ErrorDisclosure {
71    /// Collapse every blocking error into one generic, localizable message
72    /// (`processing_error`, no fields). For public-facing agents.
73    Generic,
74    /// Stable error code + structured interpolation fields. Current default.
75    #[default]
76    Standard,
77    /// Standard plus a `detail` field carrying the underlying driver error
78    /// text. For trusted surfaces such as coding-agent harnesses.
79    Detailed,
80}
81
82impl ErrorDisclosure {
83    pub fn parse(value: &str) -> Option<Self> {
84        match value.trim().to_ascii_lowercase().as_str() {
85            "generic" => Some(ErrorDisclosure::Generic),
86            "standard" => Some(ErrorDisclosure::Standard),
87            "detailed" => Some(ErrorDisclosure::Detailed),
88            _ => None,
89        }
90    }
91
92    pub fn as_str(&self) -> &'static str {
93        match self {
94            ErrorDisclosure::Generic => "generic",
95            ErrorDisclosure::Standard => "standard",
96            ErrorDisclosure::Detailed => "detailed",
97        }
98    }
99}
100
101/// Maximum length of the `detail` field attached in `Detailed` mode. Provider
102/// error bodies are normally short; this guards against pathological payloads
103/// bloating messages and events.
104const DETAIL_MAX_CHARS: usize = 1000;
105
106/// Provider quota/billing-exhaustion patterns shared by the string classifier
107/// and the driver-boundary semantic classifier (`LlmErrorKind`).
108pub fn is_provider_quota_message(message: &str) -> bool {
109    let lower = message.to_ascii_lowercase();
110    lower.contains("insufficient_quota")
111        || lower.contains("insufficient quota")
112        || lower.contains("exceeded your current quota")
113        || lower.contains("credit_balance_exhausted")
114        || lower.contains("credit balance is too low")
115}
116
117/// Subscription/plan usage-limit patterns shared by the string classifier and
118/// the transient-retry gate. These recover only at a future reset time (hours
119/// away), so unlike an ordinary 429 they must not be retried within the driver
120/// backoff window nor collapsed into the "wait a moment" rate-limit copy.
121///
122/// The canonical shape is the ChatGPT/Codex `429` body
123/// (`{"error":{"type":"usage_limit_reached", ...}}`), but the match is kept
124/// provider-agnostic so any driver surfacing the same wording is covered.
125pub fn is_usage_limit_message(message: &str) -> bool {
126    let lower = message.to_ascii_lowercase();
127    lower.contains("subscription_sharing_usage_limit_exceeded")
128        || lower.contains("usage_limit_reached")
129        || lower.contains("usage limit reached")
130        || lower.contains("usage limit has been reached")
131}
132
133/// Extract the absolute reset time (`resets_at`, unix seconds) from a usage-limit
134/// error body when present. Prefers the absolute `resets_at` field over the
135/// relative `resets_in_seconds` because this classifier is clock-free and callers
136/// want a stable timestamp they can render in the viewer's timezone.
137#[expect(
138    clippy::expect_used,
139    reason = "Built-in constant regexes must compile; invalid syntax is a programming error"
140)]
141pub fn parse_usage_limit_reset_at(message: &str) -> Option<i64> {
142    static RE: OnceLock<Regex> = OnceLock::new();
143    let re = RE.get_or_init(|| {
144        Regex::new(r#""resets_at"\s*:\s*(?P<resets_at>\d{9,})"#).expect("valid resets_at regex")
145    });
146    re.captures(message)?
147        .name("resets_at")?
148        .as_str()
149        .parse::<i64>()
150        .ok()
151}
152
153/// Sentence OpenRouter puts in the human-readable half of an attestation-gate
154/// refusal. Matched case-insensitively as the second detection signal, so a
155/// gate reported without the `metadata` block is still recognized.
156const ATTESTATION_GATE_SENTENCE: &str = "requires you to complete the following before use";
157
158/// Where the account holder clears OpenRouter attestations. Used only when the
159/// provider's own message carries no URL — every observed payload does, but the
160/// error is worth nothing to a reader without somewhere to go.
161const ATTESTATION_CONFIRM_URL_FALLBACK: &str = "https://openrouter.ai/settings/preferences";
162
163/// Bounds on the provider-supplied halves of an attestation gate. Both values
164/// are rendered verbatim into every session viewer's transcript, so the payload
165/// does not get to decide how much of it lands there. A URL longer than this,
166/// or a gate type longer than `MAX_TYPE_CHARS`, is dropped rather than
167/// truncated: half a URL is worse than the fallback, and a truncated gate name
168/// is not a gate name.
169const MAX_CONFIRM_URL_CHARS: usize = 300;
170const MAX_ATTESTATION_TYPES: usize = 8;
171const MAX_TYPE_CHARS: usize = 64;
172
173/// A provider account attestation gate: the request is refused until the
174/// account completes one or more confirmations.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct AttestationRequirement {
177    /// One entry per missing confirmation (e.g. `age_18plus`). Deliberately
178    /// `String` rather than an enum: providers add gates without notice, and
179    /// an unknown safe identifier must still reach the reader verbatim.
180    pub missing_types: Vec<String>,
181    /// Page where the account holder completes the confirmations.
182    pub confirm_url: String,
183}
184
185impl AttestationRequirement {
186    /// A requirement with nothing parsed out of the body — the reader still
187    /// gets the confirmation page, which is the actionable half.
188    pub fn fallback() -> Self {
189        Self {
190            missing_types: Vec::new(),
191            confirm_url: ATTESTATION_CONFIRM_URL_FALLBACK.to_string(),
192        }
193    }
194
195    /// Attach this requirement's interpolation fields to a user-facing error.
196    /// `missing_types` is omitted rather than sent empty, so a consumer can
197    /// tell "no list in the payload" from "an empty list".
198    pub fn apply_fields(self, error: UserFacingError) -> UserFacingError {
199        let error = error.with_field("confirm_url", self.confirm_url);
200        if self.missing_types.is_empty() {
201            error
202        } else {
203            error.with_field("missing_types", self.missing_types)
204        }
205    }
206}
207
208/// Parse an attestation gate out of a provider error body.
209///
210/// The canonical shape is OpenRouter's `403`:
211///
212/// ```json
213/// {"error":{"message":"This model requires you to complete the following before
214///   use: 18+ age confirmation. Confirm at https://openrouter.ai/settings/preferences.",
215///   "code":403,"metadata":{"missing_attestation_types":["age_18plus"], …}}}
216/// ```
217///
218/// Matching is driven by the body rather than the HTTP status, the same way
219/// [`is_provider_quota_message`] is: a status alone cannot tell this gate apart
220/// from an ordinary `403`, and a provider that reports the same gate under a
221/// different status should still be recognized. A `403` carrying neither the
222/// `missing_attestation_types` list nor the gate sentence does not match.
223pub fn parse_attestation_requirement(message: &str) -> Option<AttestationRequirement> {
224    // Bodies reach this classifier both raw and JSON-escaped, because a
225    // provider error nested inside another JSON envelope arrives with `\"` and
226    // `\/` intact. Undoing those two escapes first lets one parser cover both
227    // shapes; text without escapes is unchanged by it.
228    let message = message.replace("\\\"", "\"").replace("\\/", "/");
229    let missing_types = attestation_missing_types(&message);
230    let lower = message.to_ascii_lowercase();
231    if !missing_types
232        .as_ref()
233        .is_some_and(|(_, has_declared_types)| *has_declared_types)
234        && !lower.contains(ATTESTATION_GATE_SENTENCE)
235    {
236        return None;
237    }
238    Some(AttestationRequirement {
239        missing_types: missing_types.map(|(types, _)| types).unwrap_or_default(),
240        confirm_url: attestation_confirm_url(&message, &lower)
241            .unwrap_or_else(|| ATTESTATION_CONFIRM_URL_FALLBACK.to_string()),
242    })
243}
244
245/// Whether a provider error body reports an account attestation gate.
246pub fn is_attestation_required_message(message: &str) -> bool {
247    parse_attestation_requirement(message).is_some()
248}
249
250#[expect(
251    clippy::expect_used,
252    reason = "Built-in constant regexes must compile; invalid syntax is a programming error"
253)]
254fn attestation_missing_types(message: &str) -> Option<(Vec<String>, bool)> {
255    static LIST: OnceLock<Regex> = OnceLock::new();
256    static ITEM: OnceLock<Regex> = OnceLock::new();
257    let list = LIST.get_or_init(|| {
258        Regex::new(r#""missing_attestation_types"\s*:\s*\[(?P<types>[^\]]*)\]"#)
259            .expect("valid missing_attestation_types regex")
260    });
261    let item =
262        ITEM.get_or_init(|| Regex::new(r#""([^"]*)""#).expect("valid attestation type regex"));
263    let types = list.captures(message)?.name("types")?.as_str();
264    let mut declared_types = item
265        .captures_iter(types)
266        .map(|captures| captures[1].to_string())
267        .filter(|attestation_type| !attestation_type.is_empty())
268        .peekable();
269    let has_declared_types = declared_types.peek().is_some();
270    let safe_types = declared_types
271        // THREAT[TM-WEB-018] These strings come from the provider and are
272        // rendered into every session viewer's transcript, so the payload
273        // decides neither their markup nor how much content arrives.
274        .filter(|attestation_type| {
275            attestation_type.chars().count() <= MAX_TYPE_CHARS
276                && attestation_type.chars().all(|character| {
277                    character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | ':')
278                })
279        })
280        .take(MAX_ATTESTATION_TYPES)
281        .collect();
282    Some((safe_types, has_declared_types))
283}
284
285/// The confirmation page URL, searched from the gate sentence onward so a URL
286/// in the driver's own error prefix (an endpoint, a docs link) can never be
287/// mistaken for it. `lower` is the caller's ASCII-lowercased `message`, whose
288/// byte offsets line up with it exactly.
289#[expect(
290    clippy::expect_used,
291    reason = "Built-in constant regexes must compile; invalid syntax is a programming error"
292)]
293fn attestation_confirm_url(message: &str, lower: &str) -> Option<String> {
294    static RE: OnceLock<Regex> = OnceLock::new();
295    // THREAT[TM-WEB-018] The scheme is pinned to http(s) here, not just where
296    // the UI renders it: this URL is provider-controlled and this is the point
297    // at which it stops being an opaque blob and becomes something a reader is
298    // told to visit.
299    let re = RE
300        .get_or_init(|| Regex::new(r#"https?://[^\s"'\\<>)]+"#).expect("valid confirm url regex"));
301    let from = lower.find(ATTESTATION_GATE_SENTENCE).unwrap_or(0);
302    let url = re
303        .find(&message[from..])?
304        .as_str()
305        .trim_end_matches(['.', ',', ';', ':']);
306    (!url.is_empty() && url.chars().count() <= MAX_CONFIRM_URL_CHARS).then(|| url.to_string())
307}
308
309#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
310#[cfg_attr(feature = "openapi", derive(ToSchema))]
311pub struct UserFacingError {
312    pub code: String,
313    #[serde(default, skip_serializing_if = "UserFacingErrorFields::is_empty")]
314    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
315    pub fields: UserFacingErrorFields,
316}
317
318#[derive(Debug, Clone, Default)]
319pub struct UserFacingErrorContext {
320    pub provider: Option<String>,
321    pub model_id: Option<String>,
322    pub retry_after: Option<u64>,
323}
324
325impl UserFacingErrorContext {
326    pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
327        self.provider = Some(provider.into());
328        self
329    }
330
331    pub fn with_model_id(mut self, model_id: impl Into<String>) -> Self {
332        self.model_id = Some(model_id.into());
333        self
334    }
335
336    pub fn with_retry_after(mut self, retry_after: u64) -> Self {
337        self.retry_after = Some(retry_after);
338        self
339    }
340}
341
342impl UserFacingError {
343    pub fn new(code: impl Into<String>) -> Self {
344        Self {
345            code: code.into(),
346            fields: UserFacingErrorFields::new(),
347        }
348    }
349
350    pub fn with_field<T: Serialize>(mut self, key: impl Into<String>, value: T) -> Self {
351        let value = serde_json::to_value(value).unwrap_or(Value::Null);
352        if !value.is_null() {
353            self.fields.insert(key.into(), value);
354        }
355        self
356    }
357
358    pub fn with_optional_field<T: Serialize>(
359        self,
360        key: impl Into<String>,
361        value: Option<T>,
362    ) -> Self {
363        match value {
364            Some(value) => self.with_field(key, value),
365            None => self,
366        }
367    }
368
369    pub fn error_fields(&self) -> Option<UserFacingErrorFields> {
370        (!self.fields.is_empty()).then_some(self.fields.clone())
371    }
372
373    pub fn apply_to_event_fields(
374        &self,
375        error_code: &mut Option<String>,
376        error_fields: &mut Option<UserFacingErrorFields>,
377    ) {
378        *error_code = Some(self.code.clone());
379        *error_fields = self.error_fields();
380    }
381
382    pub fn apply_to_message_metadata(&self, metadata: &mut HashMap<String, Value>) {
383        metadata.insert("error_code".to_string(), Value::String(self.code.clone()));
384        if let Some(fields) = self.error_fields() {
385            metadata.insert(
386                "error_fields".to_string(),
387                serde_json::to_value(fields).unwrap_or(Value::Null),
388            );
389        } else {
390            // Reusing a metadata map must not retain fields from an older,
391            // more detailed error after disclosure has removed them.
392            metadata.remove("error_fields");
393        }
394    }
395
396    /// Apply an error-disclosure mode, returning the error as it should be
397    /// shown to session viewers. The original (source) error stays available
398    /// to the caller for tracking metadata.
399    ///
400    /// - `Generic` collapses to `processing_error` with no fields.
401    /// - `Standard` returns the error unchanged.
402    /// - `Detailed` attaches `detail` (the underlying driver error text,
403    ///   truncated) as an extra interpolation field.
404    pub fn apply_disclosure(&self, mode: ErrorDisclosure, detail: Option<&str>) -> UserFacingError {
405        match mode {
406            ErrorDisclosure::Generic => UserFacingError::new(codes::PROCESSING_ERROR),
407            ErrorDisclosure::Standard => self.clone(),
408            ErrorDisclosure::Detailed => {
409                let detail = detail.map(str::trim).filter(|d| !d.is_empty());
410                match detail {
411                    Some(detail) => self
412                        .clone()
413                        .with_field("detail", truncate_chars(detail, DETAIL_MAX_CHARS)),
414                    None => self.clone(),
415                }
416            }
417        }
418    }
419
420    /// Record disclosure tracking metadata on a message: the mode that was
421    /// applied and the pre-disclosure (source) error code.
422    pub fn apply_disclosure_to_message_metadata(
423        metadata: &mut HashMap<String, Value>,
424        mode: ErrorDisclosure,
425        source_code: &str,
426    ) {
427        metadata.insert(
428            metadata_keys::ERROR_DISCLOSURE.to_string(),
429            Value::String(mode.as_str().to_string()),
430        );
431        metadata.insert(
432            metadata_keys::SOURCE_ERROR_CODE.to_string(),
433            Value::String(source_code.to_string()),
434        );
435    }
436
437    pub fn fallback_message(&self) -> String {
438        self.base_fallback_message()
439    }
440
441    fn base_fallback_message(&self) -> String {
442        match self.code.as_str() {
443            codes::BUDGET_EXHAUSTED => budget_exhausted_message(&self.fields),
444            codes::BUDGET_PAUSED => budget_paused_message(&self.fields),
445            codes::SOFT_LIMIT_REACHED => string_field(&self.fields, "message")
446                .unwrap_or("Soft limit reached.")
447                .to_string(),
448            codes::MODEL_UNAVAILABLE => {
449                if let Some(model_id) = string_field(&self.fields, "model_id") {
450                    format!(
451                        "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.",
452                        model_id
453                    )
454                } else {
455                    "The selected model is not available. Please select a different model."
456                        .to_string()
457                }
458            }
459            codes::MODEL_NOT_CONFIGURED => {
460                "No model is configured for this chat. Choose a model or configure a default model, then try again."
461                    .to_string()
462            }
463            codes::REQUEST_TOO_LARGE => {
464                "The conversation has become too long for the model to process. Please start a new session or reduce the context size.".to_string()
465            }
466            codes::PROVIDER_RATE_LIMITED => {
467                "Rate limited by the AI provider. Please wait a moment.".to_string()
468            }
469            codes::PROVIDER_USAGE_LIMIT_REACHED => usage_limit_reached_message(&self.fields),
470            codes::PROVIDER_MISCONFIGURED => {
471                "There is a misconfiguration with the AI provider. Please contact support."
472                    .to_string()
473            }
474            codes::PROVIDER_QUOTA_EXHAUSTED => {
475                "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
476                    .to_string()
477            }
478            codes::PROVIDER_ATTESTATION_REQUIRED => attestation_required_message(&self.fields),
479            codes::PROVIDER_UNAVAILABLE => {
480                "The AI provider is experiencing issues. Please try again shortly.".to_string()
481            }
482            codes::PROVIDER_SESSION_UNAVAILABLE => {
483                "The AI provider no longer holds this session's remote state. Send your message again to continue in a new provider session, which starts from the recent conversation in this session's record."
484                    .to_string()
485            }
486            codes::DEPENDENCY_UNAVAILABLE => {
487                "Execution stopped because a required dependency is unavailable.".to_string()
488            }
489            codes::INVALID_TOOL_SCHEMA => {
490                "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."
491                    .to_string()
492            }
493            _ => "I encountered an error while processing your request. Please try again later."
494                .to_string(),
495        }
496    }
497}
498
499pub fn classify_runtime_error_message(
500    error: &str,
501    context: &UserFacingErrorContext,
502) -> UserFacingError {
503    let normalized = trim_error_chain_prefixes(error).trim();
504    let lower = normalized.to_ascii_lowercase();
505
506    if let Some(fields) = parse_budget_exhausted_fields(normalized) {
507        return UserFacingError {
508            code: codes::BUDGET_EXHAUSTED.to_string(),
509            fields,
510        };
511    }
512
513    if normalized.starts_with("Budget exhausted.") {
514        return UserFacingError::new(codes::BUDGET_EXHAUSTED);
515    }
516
517    if normalized.starts_with("Budget exhausted (") {
518        return UserFacingError::new(codes::BUDGET_EXHAUSTED);
519    }
520
521    if let Some(fields) = parse_budget_paused_fields(normalized) {
522        return UserFacingError {
523            code: codes::BUDGET_PAUSED.to_string(),
524            fields,
525        };
526    }
527
528    if normalized.starts_with("Budget paused.") || normalized.starts_with("Budget paused with ") {
529        return UserFacingError::new(codes::BUDGET_PAUSED);
530    }
531
532    if normalized.starts_with("Budget paused (") || normalized.starts_with("Soft limit reached.") {
533        return if normalized.starts_with("Soft limit reached.") {
534            UserFacingError::new(codes::SOFT_LIMIT_REACHED).with_field("message", normalized)
535        } else {
536            UserFacingError::new(codes::BUDGET_PAUSED)
537        };
538    }
539
540    if let Some(model_id) = normalized.strip_prefix("Model not available: ") {
541        return UserFacingError::new(codes::MODEL_UNAVAILABLE).with_field("model_id", model_id);
542    }
543
544    if normalized.starts_with("Model not configured") || lower.contains("no model configured") {
545        return UserFacingError::new(codes::MODEL_NOT_CONFIGURED);
546    }
547
548    if normalized.starts_with("Request too large:")
549        || lower.contains("context length")
550        || lower.contains("maximum context length")
551    {
552        return UserFacingError::new(codes::REQUEST_TOO_LARGE)
553            .with_optional_field("provider", context.provider.clone())
554            .with_optional_field("model_id", context.model_id.clone());
555    }
556
557    if is_invalid_tool_schema_message(&lower) {
558        return UserFacingError::new(codes::INVALID_TOOL_SCHEMA)
559            .with_optional_field("provider", context.provider.clone())
560            .with_optional_field("model_id", context.model_id.clone())
561            .with_optional_field("schema_path", extract_schema_path(normalized));
562    }
563
564    // Exhausted provider billing (OpenAI: HTTP 429 + `insufficient_quota`,
565    // Anthropic: 400 + "credit balance is too low"). The "(429)" prefix would
566    // otherwise route it to PROVIDER_RATE_LIMITED ("wait a moment"), but the
567    // condition is non-transient and needs operator action (top up the
568    // account or raise limits), so it gets its own code.
569    if is_provider_quota_message(normalized) {
570        return UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
571            .with_optional_field("provider", context.provider.clone())
572            .with_optional_field("model_id", context.model_id.clone());
573    }
574
575    // Subscription/plan usage limit (e.g. ChatGPT/Codex `usage_limit_reached`).
576    // Checked before the generic 429 branch below: the outer error text carries
577    // "429 Too Many Requests", which would otherwise route it to the transient
578    // "wait a moment" rate-limit copy. This condition instead recovers on its
579    // own at `resets_at`, so it gets its own code and carries the reset time.
580    if is_usage_limit_message(normalized) {
581        return UserFacingError::new(codes::PROVIDER_USAGE_LIMIT_REACHED)
582            .with_optional_field("provider", context.provider.clone())
583            .with_optional_field("model_id", context.model_id.clone())
584            .with_optional_field("resets_at", parse_usage_limit_reset_at(normalized));
585    }
586
587    // Provider account attestation gate (OpenRouter: HTTP 403 carrying
588    // `missing_attestation_types`). Checked before the auth branch below: the
589    // outer error text contains "(403)", which would route it to
590    // PROVIDER_MISCONFIGURED — wrong twice over, because the API key is fine
591    // and the only person who can clear the gate is the account holder, not
592    // support. Checked before the 429 branch too, so a provider that reports
593    // the gate under a throttling status still reaches the right copy.
594    if let Some(requirement) = parse_attestation_requirement(normalized) {
595        return requirement.apply_fields(
596            UserFacingError::new(codes::PROVIDER_ATTESTATION_REQUIRED)
597                .with_optional_field("provider", context.provider.clone())
598                .with_optional_field("model_id", context.model_id.clone()),
599        );
600    }
601
602    if lower.contains("(429)")
603        || lower.contains("rate limit")
604        || lower.contains("too many requests")
605    {
606        return UserFacingError::new(codes::PROVIDER_RATE_LIMITED)
607            .with_optional_field("provider", context.provider.clone())
608            .with_optional_field("model_id", context.model_id.clone())
609            .with_optional_field("retry_after", context.retry_after);
610    }
611
612    if lower.contains("(401)") || lower.contains("(403)") {
613        return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
614            .with_optional_field("provider", context.provider.clone())
615            .with_optional_field("model_id", context.model_id.clone());
616    }
617
618    if lower.contains("api key is required")
619        || lower.contains("configure the api key")
620        || lower.contains("api key missing")
621        || lower.contains("missing api key")
622        || lower.contains("invalid api key")
623    {
624        return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
625            .with_optional_field("provider", context.provider.clone())
626            .with_optional_field("model_id", context.model_id.clone());
627    }
628
629    if ["(500)", "(502)", "(503)", "(504)", "(529)"]
630        .iter()
631        .any(|code| lower.contains(code))
632    {
633        return UserFacingError::new(codes::PROVIDER_UNAVAILABLE)
634            .with_optional_field("provider", context.provider.clone())
635            .with_optional_field("model_id", context.model_id.clone());
636    }
637
638    UserFacingError::new(codes::PROCESSING_ERROR)
639        .with_optional_field("provider", context.provider.clone())
640        .with_optional_field("model_id", context.model_id.clone())
641}
642
643fn is_invalid_tool_schema_message(lower: &str) -> bool {
644    lower.contains("invalid_function_parameters")
645        || lower.contains("invalid function parameters")
646        || (lower.contains("invalid json schema") && lower.contains("$.properties"))
647        || lower.contains("invalid tool schema")
648}
649
650fn extract_schema_path(message: &str) -> Option<String> {
651    let path = message.split_once("Found at ")?.1;
652    let path = path
653        .split(|character: char| character.is_whitespace() || character == '`')
654        .next()?
655        .trim_end_matches(['.', ',', ';', ':']);
656    (path.starts_with('$')
657        && path.len() <= 200
658        && path.chars().all(|character| {
659            character.is_ascii_alphanumeric()
660                || matches!(character, '$' | '.' | '_' | '-' | '[' | ']')
661        }))
662    .then(|| path.to_string())
663}
664
665pub fn trim_error_chain_prefixes(error_chain: &str) -> &str {
666    error_chain
667        .trim()
668        .trim_start_matches("InputAtom execution failed: ")
669        .trim_start_matches("ReasonAtom execution failed: ")
670        .trim_start_matches("ActAtom execution failed: ")
671}
672
673/// Render the copy for a subscription/plan usage-limit error. The `resets_at`
674/// field (unix seconds) is rendered as a UTC fallback; clients localize it into
675/// the viewer's timezone from the same raw field. When `auto_continue` is set —
676/// added by the emit site only when an auto-continue capability is active — the
677/// copy promises automatic resumption; otherwise it stays generic.
678fn attestation_required_message(fields: &UserFacingErrorFields) -> String {
679    let confirm_url =
680        string_field(fields, "confirm_url").unwrap_or(ATTESTATION_CONFIRM_URL_FALLBACK);
681    let missing_types = fields
682        .get("missing_types")
683        .and_then(Value::as_array)
684        .map(|types| {
685            types
686                .iter()
687                .filter_map(Value::as_str)
688                .collect::<Vec<_>>()
689                .join(", ")
690        })
691        .filter(|list| !list.is_empty());
692    match missing_types {
693        Some(list) => format!(
694            "The AI provider account has not completed a confirmation this model requires ({list}). Complete it at {confirm_url}, then try again."
695        ),
696        None => format!(
697            "The AI provider account has not completed a confirmation this model requires. Complete it at {confirm_url}, then try again."
698        ),
699    }
700}
701
702fn usage_limit_reached_message(fields: &UserFacingErrorFields) -> String {
703    let mut message = String::from("You're out of LLM usage limits.");
704
705    if let Some(resets_at) = number_field(fields, "resets_at")
706        && let Some(reset) = chrono::DateTime::from_timestamp(resets_at as i64, 0)
707    {
708        message.push_str(&format!(
709            " Your usage limit resets at {}.",
710            reset.format("%H:%M UTC on %b %-d")
711        ));
712    }
713
714    if bool_field(fields, "auto_continue") {
715        message.push_str(" We'll continue work automatically once it resets.");
716    }
717
718    message
719}
720
721fn budget_exhausted_message(fields: &UserFacingErrorFields) -> String {
722    if let (Some(spent), Some(limit), Some(currency)) = (
723        number_field(fields, "spent"),
724        number_field(fields, "limit"),
725        string_field(fields, "currency"),
726    ) {
727        let comparison = if spent > limit { "exceeded" } else { "reached" };
728        return format!(
729            "Budget exhausted. {:.2} {} spent {} the {:.2} {} limit. Increase the budget to continue.",
730            spent, currency, comparison, limit, currency
731        );
732    }
733
734    "Budget exhausted. Increase the budget to continue.".to_string()
735}
736
737fn budget_paused_message(fields: &UserFacingErrorFields) -> String {
738    let spent = number_field(fields, "spent");
739    let currency = string_field(fields, "currency");
740    let soft_limit = number_field(fields, "soft_limit");
741
742    match (spent, currency, soft_limit) {
743        (Some(spent), Some(currency), Some(soft_limit)) => {
744            let comparison = if spent > soft_limit {
745                "exceeded"
746            } else if spent >= soft_limit {
747                "reached"
748            } else {
749                "with"
750            };
751            if comparison == "with" {
752                format!(
753                    "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
754                    spent, currency
755                )
756            } else {
757                format!(
758                    "Budget paused. {:.2} {} spent {} the {:.2} {} soft limit. Increase or resume the budget to continue.",
759                    spent, currency, comparison, soft_limit, currency
760                )
761            }
762        }
763        (Some(spent), Some(currency), None) => format!(
764            "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
765            spent, currency
766        ),
767        _ => "Budget paused. Increase or resume the budget to continue.".to_string(),
768    }
769}
770
771#[expect(
772    clippy::expect_used,
773    reason = "Built-in constant regexes must compile; invalid syntax is a programming error"
774)]
775fn parse_budget_exhausted_fields(message: &str) -> Option<UserFacingErrorFields> {
776    static RE: OnceLock<Regex> = OnceLock::new();
777    let re = RE.get_or_init(|| {
778        Regex::new(
779            r"^Budget exhausted\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<limit>\d+(?:\.\d+)?) \S+ limit\.",
780        )
781        .expect("valid budget exhausted regex")
782    });
783    let caps = re.captures(message)?;
784    Some(
785        UserFacingErrorFields::new()
786            .with_number("spent", caps.name("spent")?.as_str())
787            .with_number("limit", caps.name("limit")?.as_str())
788            .with_string("currency", caps.name("currency")?.as_str()),
789    )
790}
791
792#[expect(
793    clippy::expect_used,
794    reason = "Built-in constant regexes must compile; invalid syntax is a programming error"
795)]
796fn parse_budget_paused_fields(message: &str) -> Option<UserFacingErrorFields> {
797    static SOFT_LIMIT_RE: OnceLock<Regex> = OnceLock::new();
798    static SIMPLE_RE: OnceLock<Regex> = OnceLock::new();
799
800    let soft_limit_re = SOFT_LIMIT_RE.get_or_init(|| {
801        Regex::new(
802            r"^Budget paused\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<soft_limit>\d+(?:\.\d+)?) \S+ soft limit\.",
803        )
804        .expect("valid budget paused regex")
805    });
806    if let Some(caps) = soft_limit_re.captures(message) {
807        return Some(
808            UserFacingErrorFields::new()
809                .with_number("spent", caps.name("spent")?.as_str())
810                .with_number("soft_limit", caps.name("soft_limit")?.as_str())
811                .with_string("currency", caps.name("currency")?.as_str()),
812        );
813    }
814
815    let simple_re = SIMPLE_RE.get_or_init(|| {
816        Regex::new(r"^Budget paused with (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent\.")
817            .expect("valid budget paused simple regex")
818    });
819    let caps = simple_re.captures(message)?;
820    Some(
821        UserFacingErrorFields::new()
822            .with_number("spent", caps.name("spent")?.as_str())
823            .with_string("currency", caps.name("currency")?.as_str()),
824    )
825}
826
827fn string_field<'a>(fields: &'a UserFacingErrorFields, key: &str) -> Option<&'a str> {
828    fields.get(key)?.as_str()
829}
830
831fn bool_field(fields: &UserFacingErrorFields, key: &str) -> bool {
832    fields.get(key).and_then(Value::as_bool).unwrap_or(false)
833}
834
835fn truncate_chars(value: &str, max_chars: usize) -> String {
836    if value.chars().count() <= max_chars {
837        return value.to_string();
838    }
839    let truncated: String = value.chars().take(max_chars).collect();
840    format!("{truncated}\u{2026}")
841}
842
843fn number_field(fields: &UserFacingErrorFields, key: &str) -> Option<f64> {
844    match fields.get(key)? {
845        Value::Number(number) => number.as_f64(),
846        Value::String(value) => value.parse().ok(),
847        _ => None,
848    }
849}
850
851trait ErrorFieldsExt {
852    fn with_string(self, key: &str, value: &str) -> Self;
853    fn with_number(self, key: &str, value: &str) -> Self;
854}
855
856impl ErrorFieldsExt for UserFacingErrorFields {
857    fn with_string(mut self, key: &str, value: &str) -> Self {
858        self.insert(key.to_string(), Value::String(value.to_string()));
859        self
860    }
861
862    fn with_number(mut self, key: &str, value: &str) -> Self {
863        if let Ok(number) = value.parse::<f64>()
864            && let Some(json_number) = serde_json::Number::from_f64(number)
865        {
866            self.insert(key.to_string(), Value::Number(json_number));
867        }
868        self
869    }
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875    use serde_json::json;
876
877    fn wire(error: &UserFacingError) -> Value {
878        serde_json::to_value(error).unwrap()
879    }
880    fn context() -> UserFacingErrorContext {
881        UserFacingErrorContext::default()
882            .with_provider("provider")
883            .with_model_id("model")
884            .with_retry_after(7)
885    }
886
887    #[test]
888    fn quota_classification_preserves_context_without_raw_payload_or_retry_delay() {
889        for message in [
890            "ReasonAtom execution failed: OpenAI API error (429): {\"error\":{\"type\":\"insufficient_quota\",\"message\":\"You exceeded your current quota\"}}",
891            "LLM error: insufficient_quota: You exceeded your current quota.",
892            "credit_balance_exhausted: secret=hidden",
893            "Anthropic API error (400): Your credit balance is too low to access the Anthropic API.",
894            "INSUFFICIENT QUOTA",
895        ] {
896            let error = classify_runtime_error_message(message, &context());
897            assert_eq!(
898                wire(&error),
899                json!({"code":"provider_quota_exhausted","fields":{"provider":"provider","model_id":"model"}})
900            );
901            assert_eq!(
902                error.fallback_message(),
903                "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
904            );
905            assert_eq!(
906                wire(&classify_runtime_error_message(
907                    message,
908                    &UserFacingErrorContext::default()
909                )),
910                json!({"code":"provider_quota_exhausted"})
911            );
912        }
913    }
914
915    #[test]
916    fn ordinary_classification_has_exact_code_and_allowed_context_fields() {
917        for (message, expected) in [
918            (
919                "OpenAI API error (429): rate limit exceeded",
920                json!({"code":"provider_rate_limited","fields":{"provider":"provider","model_id":"model","retry_after":7}}),
921            ),
922            (
923                "LLM error: API key is required. Configure the API key in provider settings.",
924                json!({"code":"provider_misconfigured","fields":{"provider":"provider","model_id":"model"}}),
925            ),
926            (
927                "ReasonAtom execution failed: Model not configured",
928                json!({"code":"model_not_configured"}),
929            ),
930            (
931                "ActAtom execution failed: Model not available: retired-model",
932                json!({"code":"model_unavailable","fields":{"model_id":"retired-model"}}),
933            ),
934            (
935                "Request too large: context length",
936                json!({"code":"request_too_large","fields":{"provider":"provider","model_id":"model"}}),
937            ),
938            (
939                "provider error (503)",
940                json!({"code":"provider_unavailable","fields":{"provider":"provider","model_id":"model"}}),
941            ),
942            (
943                "unknown raw error secret=hidden",
944                json!({"code":"processing_error","fields":{"provider":"provider","model_id":"model"}}),
945            ),
946        ] {
947            assert_eq!(
948                wire(&classify_runtime_error_message(message, &context())),
949                expected,
950                "{message}"
951            );
952        }
953        assert_eq!(
954            UserFacingError::new("model_not_configured").fallback_message(),
955            "No model is configured for this chat. Choose a model or configure a default model, then try again."
956        );
957    }
958
959    #[test]
960    fn budget_fields_drive_exact_exhausted_and_paused_copy() {
961        let error = classify_runtime_error_message(
962            "ReasonAtom execution failed: Budget exhausted. 12.50 usd spent exceeded the 10.00 usd limit. Increase the budget to continue.",
963            &context(),
964        );
965        assert_eq!(
966            wire(&error),
967            json!({"code":"budget_exhausted","fields":{"spent":12.5,"limit":10.0,"currency":"usd"}})
968        );
969        assert_eq!(
970            error.fallback_message(),
971            "Budget exhausted. 12.50 usd spent exceeded the 10.00 usd limit. Increase the budget to continue."
972        );
973        for (spent, expected) in [
974            (
975                4.0,
976                "Budget paused with 4.00 tokens spent. Increase or resume the budget to continue.",
977            ),
978            (
979                5.0,
980                "Budget paused. 5.00 tokens spent reached the 5.00 tokens soft limit. Increase or resume the budget to continue.",
981            ),
982            (
983                6.0,
984                "Budget paused. 6.00 tokens spent exceeded the 5.00 tokens soft limit. Increase or resume the budget to continue.",
985            ),
986        ] {
987            let error = UserFacingError::new("budget_paused")
988                .with_field("spent", spent)
989                .with_field("soft_limit", 5.0)
990                .with_field("currency", "tokens");
991            assert_eq!(error.fallback_message(), expected);
992        }
993        assert_eq!(
994            UserFacingError::new("budget_paused").fallback_message(),
995            "Budget paused. Increase or resume the budget to continue."
996        );
997    }
998
999    #[test]
1000    fn schema_rejections_only_expose_safe_bounded_paths() {
1001        let path200 = format!("$.{}", "a".repeat(198));
1002        let path201 = format!("$.{}", "a".repeat(199));
1003        for (path, expected_path) in [
1004            (
1005                "$.properties.email.pattern",
1006                Some("$.properties.email.pattern"),
1007            ),
1008            ("$.properties.email.pattern?<token>", None),
1009            (path200.as_str(), Some(path200.as_str())),
1010            (path201.as_str(), None),
1011        ] {
1012            let error = classify_runtime_error_message(
1013                &format!(
1014                    "Invalid JSON schema at $.properties: regex lookaround is unsupported. Found at {path}."
1015                ),
1016                &context(),
1017            );
1018            let mut fields = json!({"provider":"provider","model_id":"model"});
1019            if let Some(path) = expected_path {
1020                fields["schema_path"] = json!(path);
1021            }
1022            assert_eq!(
1023                wire(&error),
1024                json!({"code":"invalid_tool_schema","fields":fields})
1025            );
1026            assert_eq!(
1027                error.fallback_message(),
1028                "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."
1029            );
1030        }
1031    }
1032
1033    #[test]
1034    fn attestation_types_only_expose_safe_identifiers() {
1035        let message = r#"{"missing_attestation_types":["age_18plus","org-policy.v2:required","<https://evil.example|Verify account>"]}"#;
1036        let requirement = parse_attestation_requirement(message).unwrap();
1037
1038        assert_eq!(
1039            requirement.missing_types,
1040            ["age_18plus", "org-policy.v2:required"]
1041        );
1042
1043        // An otherwise valid attestation response remains classified even when
1044        // every provider-supplied label is unsafe to display.
1045        for unsafe_type in [
1046            "<https://evil.example|Verify>",
1047            "[verify](https://evil.example)",
1048            "<!channel>",
1049            "line\\nbreak",
1050        ] {
1051            let unsafe_only = format!(
1052                r#"{{"message":"This model requires you to complete the following before use","missing_attestation_types":["{unsafe_type}"]}}"#
1053            );
1054            assert!(
1055                parse_attestation_requirement(&unsafe_only)
1056                    .unwrap()
1057                    .missing_types
1058                    .is_empty(),
1059                "{unsafe_type}"
1060            );
1061        }
1062    }
1063
1064    #[test]
1065    fn usage_limits_have_exact_reset_copy_and_explicit_auto_continue_policy() {
1066        let error = classify_runtime_error_message(
1067            "Codex API error (429 Too Many Requests): {\"error\":{\"type\":\"usage_limit_reached\",\"resets_at\":1783767823,\"resets_in_seconds\":12337}}",
1068            &context(),
1069        );
1070        assert_eq!(
1071            wire(&error),
1072            json!({"code":"provider_usage_limit_reached","fields":{"provider":"provider","model_id":"model","resets_at":1783767823}})
1073        );
1074        assert_eq!(
1075            error.fallback_message(),
1076            "You're out of LLM usage limits. Your usage limit resets at 11:03 UTC on Jul 11."
1077        );
1078        assert_eq!(
1079            error
1080                .clone()
1081                .with_field("auto_continue", true)
1082                .fallback_message(),
1083            "You're out of LLM usage limits. Your usage limit resets at 11:03 UTC on Jul 11. We'll continue work automatically once it resets."
1084        );
1085        assert_eq!(
1086            error
1087                .clone()
1088                .with_field("auto_continue", false)
1089                .fallback_message(),
1090            error.fallback_message()
1091        );
1092        let no_reset = classify_runtime_error_message(
1093            "Some Provider API error (429): usage limit reached",
1094            &UserFacingErrorContext::default(),
1095        );
1096        assert_eq!(
1097            wire(&no_reset),
1098            json!({"code":"provider_usage_limit_reached"})
1099        );
1100        assert_eq!(
1101            no_reset.fallback_message(),
1102            "You're out of LLM usage limits."
1103        );
1104    }
1105
1106    #[test]
1107    fn disclosure_modes_preserve_only_their_allowed_fields() {
1108        let error = UserFacingError::new("provider_quota_exhausted")
1109            .with_field("provider", "openai")
1110            .with_field("model_id", "model");
1111        let detail = " Authorization: Bearer synthetic-secret ";
1112        let generic = error.apply_disclosure(ErrorDisclosure::Generic, Some(detail));
1113        assert_eq!(wire(&generic), json!({"code":"processing_error"}));
1114        assert_eq!(
1115            generic.fallback_message(),
1116            "I encountered an error while processing your request. Please try again later."
1117        );
1118        assert_eq!(
1119            error.apply_disclosure(ErrorDisclosure::Standard, Some(detail)),
1120            error
1121        );
1122        let detailed = error.apply_disclosure(ErrorDisclosure::Detailed, Some(detail));
1123        assert_eq!(
1124            wire(&detailed),
1125            json!({"code":"provider_quota_exhausted","fields":{"provider":"openai","model_id":"model","detail":"Authorization: Bearer synthetic-secret"}})
1126        );
1127        assert_eq!(
1128            detailed.fallback_message(),
1129            "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
1130        );
1131        for empty in [None, Some(""), Some(" \n\t")] {
1132            assert_eq!(
1133                error.apply_disclosure(ErrorDisclosure::Detailed, empty),
1134                error
1135            );
1136        }
1137    }
1138
1139    #[test]
1140    fn detailed_disclosure_has_literal_unicode_character_boundary() {
1141        let error = UserFacingError::new("processing_error");
1142        for length in [999, 1000, 1001] {
1143            let input = "🦀".repeat(length);
1144            let expected = if length <= 1000 {
1145                input.clone()
1146            } else {
1147                format!("{}…", "🦀".repeat(1000))
1148            };
1149            assert_eq!(
1150                wire(&error.apply_disclosure(ErrorDisclosure::Detailed, Some(&input))),
1151                json!({"code":"processing_error","fields":{"detail":expected}})
1152            );
1153        }
1154    }
1155
1156    #[test]
1157    fn disclosure_parse_and_ordering() {
1158        assert_eq!(
1159            ErrorDisclosure::parse("Generic"),
1160            Some(ErrorDisclosure::Generic)
1161        );
1162        assert_eq!(
1163            ErrorDisclosure::parse("detailed"),
1164            Some(ErrorDisclosure::Detailed)
1165        );
1166        assert_eq!(ErrorDisclosure::parse("nope"), None);
1167        assert!(ErrorDisclosure::Generic < ErrorDisclosure::Standard);
1168        assert!(ErrorDisclosure::Standard < ErrorDisclosure::Detailed);
1169        assert_eq!(ErrorDisclosure::default(), ErrorDisclosure::Standard);
1170    }
1171
1172    #[test]
1173    fn applying_error_replaces_owned_metadata_and_clears_previous_detail() {
1174        let mut metadata = HashMap::from([
1175            ("other".into(), json!("preserve")),
1176            (
1177                "error_fields".into(),
1178                json!({"detail":"old-private-detail"}),
1179            ),
1180            ("error_code".into(), json!("old-code")),
1181        ]);
1182        let error = UserFacingError::new("provider_rate_limited").with_field("retry_after", 7);
1183        error.apply_to_message_metadata(&mut metadata);
1184        assert_eq!(
1185            metadata,
1186            HashMap::from([
1187                ("other".into(), json!("preserve")),
1188                ("error_code".into(), json!("provider_rate_limited")),
1189                ("error_fields".into(), json!({"retry_after":7}))
1190            ])
1191        );
1192        let generic = error.apply_disclosure(ErrorDisclosure::Generic, None);
1193        generic.apply_to_message_metadata(&mut metadata);
1194        assert_eq!(
1195            metadata,
1196            HashMap::from([
1197                ("other".into(), json!("preserve")),
1198                ("error_code".into(), json!("processing_error"))
1199            ])
1200        );
1201        let mut code = Some("old-code".into());
1202        let mut fields = Some(BTreeMap::from([(
1203            "detail".into(),
1204            json!("old-private-detail"),
1205        )]));
1206        generic.apply_to_event_fields(&mut code, &mut fields);
1207        assert_eq!((code, fields), (Some("processing_error".into()), None));
1208        UserFacingError::apply_disclosure_to_message_metadata(
1209            &mut metadata,
1210            ErrorDisclosure::Generic,
1211            "provider_rate_limited",
1212        );
1213        assert_eq!(
1214            metadata,
1215            HashMap::from([
1216                ("other".into(), json!("preserve")),
1217                ("error_code".into(), json!("processing_error")),
1218                ("error_disclosure".into(), json!("generic")),
1219                ("source_error_code".into(), json!("provider_rate_limited"))
1220            ])
1221        );
1222    }
1223}