Skip to main content

everruns_core/
user_facing_error.rs

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