1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::{BTreeMap, HashMap};
5use std::sync::OnceLock;
6
7#[cfg(feature = "openapi")]
8use utoipa::ToSchema;
9
10pub mod codes {
11 pub const BUDGET_EXHAUSTED: &str = "budget_exhausted";
12 pub const BUDGET_PAUSED: &str = "budget_paused";
13 pub const MODEL_UNAVAILABLE: &str = "model_unavailable";
14 pub const REQUEST_TOO_LARGE: &str = "request_too_large";
15 pub const PROVIDER_RATE_LIMITED: &str = "provider_rate_limited";
16 pub const PROVIDER_USAGE_LIMIT_REACHED: &str = "provider_usage_limit_reached";
23 pub const PROVIDER_MISCONFIGURED: &str = "provider_misconfigured";
24 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 pub const BLOCKED_BY_HOOK: &str = "blocked_by_hook";
35}
36
37pub type UserFacingErrorFields = BTreeMap<String, Value>;
38
39pub mod metadata_keys {
41 pub const ERROR_DISCLOSURE: &str = "error_disclosure";
43 pub const SOURCE_ERROR_CODE: &str = "source_error_code";
47}
48
49#[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 Generic,
61 #[default]
63 Standard,
64 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
88const DETAIL_MAX_CHARS: usize = 1000;
92
93pub 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
103pub 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
118pub 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 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 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 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 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
433fn 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 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 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 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 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 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); }
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}