Skip to main content

everruns_provider/
user_facing_error.rs

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