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 pub const PROVIDER_USAGE_LIMIT_REACHED: &str = "provider_usage_limit_reached";
24 pub const PROVIDER_MISCONFIGURED: &str = "provider_misconfigured";
25 pub const PROVIDER_QUOTA_EXHAUSTED: &str = "provider_quota_exhausted";
29 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 pub const BLOCKED_BY_HOOK: &str = "blocked_by_hook";
44}
45
46pub type UserFacingErrorFields = BTreeMap<String, Value>;
47
48pub mod metadata_keys {
50 pub const ERROR_DISCLOSURE: &str = "error_disclosure";
52 pub const SOURCE_ERROR_CODE: &str = "source_error_code";
56}
57
58#[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 Generic,
70 #[default]
72 Standard,
73 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
97const DETAIL_MAX_CHARS: usize = 1000;
101
102pub 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
113pub 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
128pub 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
144const ATTESTATION_GATE_SENTENCE: &str = "requires you to complete the following before use";
148
149const ATTESTATION_CONFIRM_URL_FALLBACK: &str = "https://openrouter.ai/settings/preferences";
153
154const MAX_CONFIRM_URL_CHARS: usize = 300;
161const MAX_ATTESTATION_TYPES: usize = 8;
162const MAX_TYPE_CHARS: usize = 64;
163
164#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct AttestationRequirement {
168 pub missing_types: Vec<String>,
172 pub confirm_url: String,
174}
175
176impl AttestationRequirement {
177 pub fn fallback() -> Self {
180 Self {
181 missing_types: Vec::new(),
182 confirm_url: ATTESTATION_CONFIRM_URL_FALLBACK.to_string(),
183 }
184 }
185
186 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
199pub fn parse_attestation_requirement(message: &str) -> Option<AttestationRequirement> {
215 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
232pub 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 .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
261fn attestation_confirm_url(message: &str, lower: &str) -> Option<String> {
266 static RE: OnceLock<Regex> = OnceLock::new();
267 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 metadata.remove("error_fields");
365 }
366 }
367
368 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 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 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 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 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
641fn 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}