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 PROVIDER_SESSION_UNAVAILABLE: &str = "provider_session_unavailable";
42 pub const DEPENDENCY_UNAVAILABLE: &str = "dependency_unavailable";
43 pub const INVALID_TOOL_SCHEMA: &str = "invalid_tool_schema";
44 pub const MAX_ITERATIONS: &str = "max_iterations";
45 pub const SOFT_LIMIT_REACHED: &str = "soft_limit_reached";
46 pub const BLOCKED_BY_HOOK: &str = "blocked_by_hook";
48}
49
50pub type UserFacingErrorFields = BTreeMap<String, Value>;
51
52pub mod metadata_keys {
54 pub const ERROR_DISCLOSURE: &str = "error_disclosure";
56 pub const SOURCE_ERROR_CODE: &str = "source_error_code";
60}
61
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69#[cfg_attr(feature = "openapi", derive(ToSchema))]
70pub enum ErrorDisclosure {
71 Generic,
74 #[default]
76 Standard,
77 Detailed,
80}
81
82impl ErrorDisclosure {
83 pub fn parse(value: &str) -> Option<Self> {
84 match value.trim().to_ascii_lowercase().as_str() {
85 "generic" => Some(ErrorDisclosure::Generic),
86 "standard" => Some(ErrorDisclosure::Standard),
87 "detailed" => Some(ErrorDisclosure::Detailed),
88 _ => None,
89 }
90 }
91
92 pub fn as_str(&self) -> &'static str {
93 match self {
94 ErrorDisclosure::Generic => "generic",
95 ErrorDisclosure::Standard => "standard",
96 ErrorDisclosure::Detailed => "detailed",
97 }
98 }
99}
100
101const DETAIL_MAX_CHARS: usize = 1000;
105
106pub fn is_provider_quota_message(message: &str) -> bool {
109 let lower = message.to_ascii_lowercase();
110 lower.contains("insufficient_quota")
111 || lower.contains("insufficient quota")
112 || lower.contains("exceeded your current quota")
113 || lower.contains("credit_balance_exhausted")
114 || lower.contains("credit balance is too low")
115}
116
117pub fn is_usage_limit_message(message: &str) -> bool {
126 let lower = message.to_ascii_lowercase();
127 lower.contains("subscription_sharing_usage_limit_exceeded")
128 || lower.contains("usage_limit_reached")
129 || lower.contains("usage limit reached")
130 || lower.contains("usage limit has been reached")
131}
132
133#[expect(
138 clippy::expect_used,
139 reason = "Built-in constant regexes must compile; invalid syntax is a programming error"
140)]
141pub fn parse_usage_limit_reset_at(message: &str) -> Option<i64> {
142 static RE: OnceLock<Regex> = OnceLock::new();
143 let re = RE.get_or_init(|| {
144 Regex::new(r#""resets_at"\s*:\s*(?P<resets_at>\d{9,})"#).expect("valid resets_at regex")
145 });
146 re.captures(message)?
147 .name("resets_at")?
148 .as_str()
149 .parse::<i64>()
150 .ok()
151}
152
153const ATTESTATION_GATE_SENTENCE: &str = "requires you to complete the following before use";
157
158const ATTESTATION_CONFIRM_URL_FALLBACK: &str = "https://openrouter.ai/settings/preferences";
162
163const MAX_CONFIRM_URL_CHARS: usize = 300;
170const MAX_ATTESTATION_TYPES: usize = 8;
171const MAX_TYPE_CHARS: usize = 64;
172
173#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct AttestationRequirement {
177 pub missing_types: Vec<String>,
181 pub confirm_url: String,
183}
184
185impl AttestationRequirement {
186 pub fn fallback() -> Self {
189 Self {
190 missing_types: Vec::new(),
191 confirm_url: ATTESTATION_CONFIRM_URL_FALLBACK.to_string(),
192 }
193 }
194
195 pub fn apply_fields(self, error: UserFacingError) -> UserFacingError {
199 let error = error.with_field("confirm_url", self.confirm_url);
200 if self.missing_types.is_empty() {
201 error
202 } else {
203 error.with_field("missing_types", self.missing_types)
204 }
205 }
206}
207
208pub fn parse_attestation_requirement(message: &str) -> Option<AttestationRequirement> {
224 let message = message.replace("\\\"", "\"").replace("\\/", "/");
229 let missing_types = attestation_missing_types(&message);
230 let lower = message.to_ascii_lowercase();
231 if !missing_types
232 .as_ref()
233 .is_some_and(|(_, has_declared_types)| *has_declared_types)
234 && !lower.contains(ATTESTATION_GATE_SENTENCE)
235 {
236 return None;
237 }
238 Some(AttestationRequirement {
239 missing_types: missing_types.map(|(types, _)| types).unwrap_or_default(),
240 confirm_url: attestation_confirm_url(&message, &lower)
241 .unwrap_or_else(|| ATTESTATION_CONFIRM_URL_FALLBACK.to_string()),
242 })
243}
244
245pub fn is_attestation_required_message(message: &str) -> bool {
247 parse_attestation_requirement(message).is_some()
248}
249
250#[expect(
251 clippy::expect_used,
252 reason = "Built-in constant regexes must compile; invalid syntax is a programming error"
253)]
254fn attestation_missing_types(message: &str) -> Option<(Vec<String>, bool)> {
255 static LIST: OnceLock<Regex> = OnceLock::new();
256 static ITEM: OnceLock<Regex> = OnceLock::new();
257 let list = LIST.get_or_init(|| {
258 Regex::new(r#""missing_attestation_types"\s*:\s*\[(?P<types>[^\]]*)\]"#)
259 .expect("valid missing_attestation_types regex")
260 });
261 let item =
262 ITEM.get_or_init(|| Regex::new(r#""([^"]*)""#).expect("valid attestation type regex"));
263 let types = list.captures(message)?.name("types")?.as_str();
264 let mut declared_types = item
265 .captures_iter(types)
266 .map(|captures| captures[1].to_string())
267 .filter(|attestation_type| !attestation_type.is_empty())
268 .peekable();
269 let has_declared_types = declared_types.peek().is_some();
270 let safe_types = declared_types
271 .filter(|attestation_type| {
275 attestation_type.chars().count() <= MAX_TYPE_CHARS
276 && attestation_type.chars().all(|character| {
277 character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | ':')
278 })
279 })
280 .take(MAX_ATTESTATION_TYPES)
281 .collect();
282 Some((safe_types, has_declared_types))
283}
284
285#[expect(
290 clippy::expect_used,
291 reason = "Built-in constant regexes must compile; invalid syntax is a programming error"
292)]
293fn attestation_confirm_url(message: &str, lower: &str) -> Option<String> {
294 static RE: OnceLock<Regex> = OnceLock::new();
295 let re = RE
300 .get_or_init(|| Regex::new(r#"https?://[^\s"'\\<>)]+"#).expect("valid confirm url regex"));
301 let from = lower.find(ATTESTATION_GATE_SENTENCE).unwrap_or(0);
302 let url = re
303 .find(&message[from..])?
304 .as_str()
305 .trim_end_matches(['.', ',', ';', ':']);
306 (!url.is_empty() && url.chars().count() <= MAX_CONFIRM_URL_CHARS).then(|| url.to_string())
307}
308
309#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
310#[cfg_attr(feature = "openapi", derive(ToSchema))]
311pub struct UserFacingError {
312 pub code: String,
313 #[serde(default, skip_serializing_if = "UserFacingErrorFields::is_empty")]
314 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
315 pub fields: UserFacingErrorFields,
316}
317
318#[derive(Debug, Clone, Default)]
319pub struct UserFacingErrorContext {
320 pub provider: Option<String>,
321 pub model_id: Option<String>,
322 pub retry_after: Option<u64>,
323}
324
325impl UserFacingErrorContext {
326 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
327 self.provider = Some(provider.into());
328 self
329 }
330
331 pub fn with_model_id(mut self, model_id: impl Into<String>) -> Self {
332 self.model_id = Some(model_id.into());
333 self
334 }
335
336 pub fn with_retry_after(mut self, retry_after: u64) -> Self {
337 self.retry_after = Some(retry_after);
338 self
339 }
340}
341
342impl UserFacingError {
343 pub fn new(code: impl Into<String>) -> Self {
344 Self {
345 code: code.into(),
346 fields: UserFacingErrorFields::new(),
347 }
348 }
349
350 pub fn with_field<T: Serialize>(mut self, key: impl Into<String>, value: T) -> Self {
351 let value = serde_json::to_value(value).unwrap_or(Value::Null);
352 if !value.is_null() {
353 self.fields.insert(key.into(), value);
354 }
355 self
356 }
357
358 pub fn with_optional_field<T: Serialize>(
359 self,
360 key: impl Into<String>,
361 value: Option<T>,
362 ) -> Self {
363 match value {
364 Some(value) => self.with_field(key, value),
365 None => self,
366 }
367 }
368
369 pub fn error_fields(&self) -> Option<UserFacingErrorFields> {
370 (!self.fields.is_empty()).then_some(self.fields.clone())
371 }
372
373 pub fn apply_to_event_fields(
374 &self,
375 error_code: &mut Option<String>,
376 error_fields: &mut Option<UserFacingErrorFields>,
377 ) {
378 *error_code = Some(self.code.clone());
379 *error_fields = self.error_fields();
380 }
381
382 pub fn apply_to_message_metadata(&self, metadata: &mut HashMap<String, Value>) {
383 metadata.insert("error_code".to_string(), Value::String(self.code.clone()));
384 if let Some(fields) = self.error_fields() {
385 metadata.insert(
386 "error_fields".to_string(),
387 serde_json::to_value(fields).unwrap_or(Value::Null),
388 );
389 } else {
390 metadata.remove("error_fields");
393 }
394 }
395
396 pub fn apply_disclosure(&self, mode: ErrorDisclosure, detail: Option<&str>) -> UserFacingError {
405 match mode {
406 ErrorDisclosure::Generic => UserFacingError::new(codes::PROCESSING_ERROR),
407 ErrorDisclosure::Standard => self.clone(),
408 ErrorDisclosure::Detailed => {
409 let detail = detail.map(str::trim).filter(|d| !d.is_empty());
410 match detail {
411 Some(detail) => self
412 .clone()
413 .with_field("detail", truncate_chars(detail, DETAIL_MAX_CHARS)),
414 None => self.clone(),
415 }
416 }
417 }
418 }
419
420 pub fn apply_disclosure_to_message_metadata(
423 metadata: &mut HashMap<String, Value>,
424 mode: ErrorDisclosure,
425 source_code: &str,
426 ) {
427 metadata.insert(
428 metadata_keys::ERROR_DISCLOSURE.to_string(),
429 Value::String(mode.as_str().to_string()),
430 );
431 metadata.insert(
432 metadata_keys::SOURCE_ERROR_CODE.to_string(),
433 Value::String(source_code.to_string()),
434 );
435 }
436
437 pub fn fallback_message(&self) -> String {
438 self.base_fallback_message()
439 }
440
441 fn base_fallback_message(&self) -> String {
442 match self.code.as_str() {
443 codes::BUDGET_EXHAUSTED => budget_exhausted_message(&self.fields),
444 codes::BUDGET_PAUSED => budget_paused_message(&self.fields),
445 codes::SOFT_LIMIT_REACHED => string_field(&self.fields, "message")
446 .unwrap_or("Soft limit reached.")
447 .to_string(),
448 codes::MODEL_UNAVAILABLE => {
449 if let Some(model_id) = string_field(&self.fields, "model_id") {
450 format!(
451 "The model `{}` is not available. It may have been removed, renamed, or your API key may not have access to it. Please select a different model.",
452 model_id
453 )
454 } else {
455 "The selected model is not available. Please select a different model."
456 .to_string()
457 }
458 }
459 codes::MODEL_NOT_CONFIGURED => {
460 "No model is configured for this chat. Choose a model or configure a default model, then try again."
461 .to_string()
462 }
463 codes::REQUEST_TOO_LARGE => {
464 "The conversation has become too long for the model to process. Please start a new session or reduce the context size.".to_string()
465 }
466 codes::PROVIDER_RATE_LIMITED => {
467 "Rate limited by the AI provider. Please wait a moment.".to_string()
468 }
469 codes::PROVIDER_USAGE_LIMIT_REACHED => usage_limit_reached_message(&self.fields),
470 codes::PROVIDER_MISCONFIGURED => {
471 "There is a misconfiguration with the AI provider. Please contact support."
472 .to_string()
473 }
474 codes::PROVIDER_QUOTA_EXHAUSTED => {
475 "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
476 .to_string()
477 }
478 codes::PROVIDER_ATTESTATION_REQUIRED => attestation_required_message(&self.fields),
479 codes::PROVIDER_UNAVAILABLE => {
480 "The AI provider is experiencing issues. Please try again shortly.".to_string()
481 }
482 codes::PROVIDER_SESSION_UNAVAILABLE => {
483 "The AI provider no longer holds this session's remote state. Send your message again to continue in a new provider session, which starts from the recent conversation in this session's record."
484 .to_string()
485 }
486 codes::DEPENDENCY_UNAVAILABLE => {
487 "Execution stopped because a required dependency is unavailable.".to_string()
488 }
489 codes::INVALID_TOOL_SCHEMA => {
490 "A connected tool uses an input schema that this model provider does not support. Update the integration or choose a different model provider, then try again."
491 .to_string()
492 }
493 _ => "I encountered an error while processing your request. Please try again later."
494 .to_string(),
495 }
496 }
497}
498
499pub fn classify_runtime_error_message(
500 error: &str,
501 context: &UserFacingErrorContext,
502) -> UserFacingError {
503 let normalized = trim_error_chain_prefixes(error).trim();
504 let lower = normalized.to_ascii_lowercase();
505
506 if let Some(fields) = parse_budget_exhausted_fields(normalized) {
507 return UserFacingError {
508 code: codes::BUDGET_EXHAUSTED.to_string(),
509 fields,
510 };
511 }
512
513 if normalized.starts_with("Budget exhausted.") {
514 return UserFacingError::new(codes::BUDGET_EXHAUSTED);
515 }
516
517 if normalized.starts_with("Budget exhausted (") {
518 return UserFacingError::new(codes::BUDGET_EXHAUSTED);
519 }
520
521 if let Some(fields) = parse_budget_paused_fields(normalized) {
522 return UserFacingError {
523 code: codes::BUDGET_PAUSED.to_string(),
524 fields,
525 };
526 }
527
528 if normalized.starts_with("Budget paused.") || normalized.starts_with("Budget paused with ") {
529 return UserFacingError::new(codes::BUDGET_PAUSED);
530 }
531
532 if normalized.starts_with("Budget paused (") || normalized.starts_with("Soft limit reached.") {
533 return if normalized.starts_with("Soft limit reached.") {
534 UserFacingError::new(codes::SOFT_LIMIT_REACHED).with_field("message", normalized)
535 } else {
536 UserFacingError::new(codes::BUDGET_PAUSED)
537 };
538 }
539
540 if let Some(model_id) = normalized.strip_prefix("Model not available: ") {
541 return UserFacingError::new(codes::MODEL_UNAVAILABLE).with_field("model_id", model_id);
542 }
543
544 if normalized.starts_with("Model not configured") || lower.contains("no model configured") {
545 return UserFacingError::new(codes::MODEL_NOT_CONFIGURED);
546 }
547
548 if normalized.starts_with("Request too large:")
549 || lower.contains("context length")
550 || lower.contains("maximum context length")
551 {
552 return UserFacingError::new(codes::REQUEST_TOO_LARGE)
553 .with_optional_field("provider", context.provider.clone())
554 .with_optional_field("model_id", context.model_id.clone());
555 }
556
557 if is_invalid_tool_schema_message(&lower) {
558 return UserFacingError::new(codes::INVALID_TOOL_SCHEMA)
559 .with_optional_field("provider", context.provider.clone())
560 .with_optional_field("model_id", context.model_id.clone())
561 .with_optional_field("schema_path", extract_schema_path(normalized));
562 }
563
564 if is_provider_quota_message(normalized) {
570 return UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
571 .with_optional_field("provider", context.provider.clone())
572 .with_optional_field("model_id", context.model_id.clone());
573 }
574
575 if is_usage_limit_message(normalized) {
581 return UserFacingError::new(codes::PROVIDER_USAGE_LIMIT_REACHED)
582 .with_optional_field("provider", context.provider.clone())
583 .with_optional_field("model_id", context.model_id.clone())
584 .with_optional_field("resets_at", parse_usage_limit_reset_at(normalized));
585 }
586
587 if let Some(requirement) = parse_attestation_requirement(normalized) {
595 return requirement.apply_fields(
596 UserFacingError::new(codes::PROVIDER_ATTESTATION_REQUIRED)
597 .with_optional_field("provider", context.provider.clone())
598 .with_optional_field("model_id", context.model_id.clone()),
599 );
600 }
601
602 if lower.contains("(429)")
603 || lower.contains("rate limit")
604 || lower.contains("too many requests")
605 {
606 return UserFacingError::new(codes::PROVIDER_RATE_LIMITED)
607 .with_optional_field("provider", context.provider.clone())
608 .with_optional_field("model_id", context.model_id.clone())
609 .with_optional_field("retry_after", context.retry_after);
610 }
611
612 if lower.contains("(401)") || lower.contains("(403)") {
613 return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
614 .with_optional_field("provider", context.provider.clone())
615 .with_optional_field("model_id", context.model_id.clone());
616 }
617
618 if lower.contains("api key is required")
619 || lower.contains("configure the api key")
620 || lower.contains("api key missing")
621 || lower.contains("missing api key")
622 || lower.contains("invalid api key")
623 {
624 return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
625 .with_optional_field("provider", context.provider.clone())
626 .with_optional_field("model_id", context.model_id.clone());
627 }
628
629 if ["(500)", "(502)", "(503)", "(504)", "(529)"]
630 .iter()
631 .any(|code| lower.contains(code))
632 {
633 return UserFacingError::new(codes::PROVIDER_UNAVAILABLE)
634 .with_optional_field("provider", context.provider.clone())
635 .with_optional_field("model_id", context.model_id.clone());
636 }
637
638 UserFacingError::new(codes::PROCESSING_ERROR)
639 .with_optional_field("provider", context.provider.clone())
640 .with_optional_field("model_id", context.model_id.clone())
641}
642
643fn is_invalid_tool_schema_message(lower: &str) -> bool {
644 lower.contains("invalid_function_parameters")
645 || lower.contains("invalid function parameters")
646 || (lower.contains("invalid json schema") && lower.contains("$.properties"))
647 || lower.contains("invalid tool schema")
648}
649
650fn extract_schema_path(message: &str) -> Option<String> {
651 let path = message.split_once("Found at ")?.1;
652 let path = path
653 .split(|character: char| character.is_whitespace() || character == '`')
654 .next()?
655 .trim_end_matches(['.', ',', ';', ':']);
656 (path.starts_with('$')
657 && path.len() <= 200
658 && path.chars().all(|character| {
659 character.is_ascii_alphanumeric()
660 || matches!(character, '$' | '.' | '_' | '-' | '[' | ']')
661 }))
662 .then(|| path.to_string())
663}
664
665pub fn trim_error_chain_prefixes(error_chain: &str) -> &str {
666 error_chain
667 .trim()
668 .trim_start_matches("InputAtom execution failed: ")
669 .trim_start_matches("ReasonAtom execution failed: ")
670 .trim_start_matches("ActAtom execution failed: ")
671}
672
673fn attestation_required_message(fields: &UserFacingErrorFields) -> String {
679 let confirm_url =
680 string_field(fields, "confirm_url").unwrap_or(ATTESTATION_CONFIRM_URL_FALLBACK);
681 let missing_types = fields
682 .get("missing_types")
683 .and_then(Value::as_array)
684 .map(|types| {
685 types
686 .iter()
687 .filter_map(Value::as_str)
688 .collect::<Vec<_>>()
689 .join(", ")
690 })
691 .filter(|list| !list.is_empty());
692 match missing_types {
693 Some(list) => format!(
694 "The AI provider account has not completed a confirmation this model requires ({list}). Complete it at {confirm_url}, then try again."
695 ),
696 None => format!(
697 "The AI provider account has not completed a confirmation this model requires. Complete it at {confirm_url}, then try again."
698 ),
699 }
700}
701
702fn usage_limit_reached_message(fields: &UserFacingErrorFields) -> String {
703 let mut message = String::from("You're out of LLM usage limits.");
704
705 if let Some(resets_at) = number_field(fields, "resets_at")
706 && let Some(reset) = chrono::DateTime::from_timestamp(resets_at as i64, 0)
707 {
708 message.push_str(&format!(
709 " Your usage limit resets at {}.",
710 reset.format("%H:%M UTC on %b %-d")
711 ));
712 }
713
714 if bool_field(fields, "auto_continue") {
715 message.push_str(" We'll continue work automatically once it resets.");
716 }
717
718 message
719}
720
721fn budget_exhausted_message(fields: &UserFacingErrorFields) -> String {
722 if let (Some(spent), Some(limit), Some(currency)) = (
723 number_field(fields, "spent"),
724 number_field(fields, "limit"),
725 string_field(fields, "currency"),
726 ) {
727 let comparison = if spent > limit { "exceeded" } else { "reached" };
728 return format!(
729 "Budget exhausted. {:.2} {} spent {} the {:.2} {} limit. Increase the budget to continue.",
730 spent, currency, comparison, limit, currency
731 );
732 }
733
734 "Budget exhausted. Increase the budget to continue.".to_string()
735}
736
737fn budget_paused_message(fields: &UserFacingErrorFields) -> String {
738 let spent = number_field(fields, "spent");
739 let currency = string_field(fields, "currency");
740 let soft_limit = number_field(fields, "soft_limit");
741
742 match (spent, currency, soft_limit) {
743 (Some(spent), Some(currency), Some(soft_limit)) => {
744 let comparison = if spent > soft_limit {
745 "exceeded"
746 } else if spent >= soft_limit {
747 "reached"
748 } else {
749 "with"
750 };
751 if comparison == "with" {
752 format!(
753 "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
754 spent, currency
755 )
756 } else {
757 format!(
758 "Budget paused. {:.2} {} spent {} the {:.2} {} soft limit. Increase or resume the budget to continue.",
759 spent, currency, comparison, soft_limit, currency
760 )
761 }
762 }
763 (Some(spent), Some(currency), None) => format!(
764 "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
765 spent, currency
766 ),
767 _ => "Budget paused. Increase or resume the budget to continue.".to_string(),
768 }
769}
770
771#[expect(
772 clippy::expect_used,
773 reason = "Built-in constant regexes must compile; invalid syntax is a programming error"
774)]
775fn parse_budget_exhausted_fields(message: &str) -> Option<UserFacingErrorFields> {
776 static RE: OnceLock<Regex> = OnceLock::new();
777 let re = RE.get_or_init(|| {
778 Regex::new(
779 r"^Budget exhausted\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<limit>\d+(?:\.\d+)?) \S+ limit\.",
780 )
781 .expect("valid budget exhausted regex")
782 });
783 let caps = re.captures(message)?;
784 Some(
785 UserFacingErrorFields::new()
786 .with_number("spent", caps.name("spent")?.as_str())
787 .with_number("limit", caps.name("limit")?.as_str())
788 .with_string("currency", caps.name("currency")?.as_str()),
789 )
790}
791
792#[expect(
793 clippy::expect_used,
794 reason = "Built-in constant regexes must compile; invalid syntax is a programming error"
795)]
796fn parse_budget_paused_fields(message: &str) -> Option<UserFacingErrorFields> {
797 static SOFT_LIMIT_RE: OnceLock<Regex> = OnceLock::new();
798 static SIMPLE_RE: OnceLock<Regex> = OnceLock::new();
799
800 let soft_limit_re = SOFT_LIMIT_RE.get_or_init(|| {
801 Regex::new(
802 r"^Budget paused\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<soft_limit>\d+(?:\.\d+)?) \S+ soft limit\.",
803 )
804 .expect("valid budget paused regex")
805 });
806 if let Some(caps) = soft_limit_re.captures(message) {
807 return Some(
808 UserFacingErrorFields::new()
809 .with_number("spent", caps.name("spent")?.as_str())
810 .with_number("soft_limit", caps.name("soft_limit")?.as_str())
811 .with_string("currency", caps.name("currency")?.as_str()),
812 );
813 }
814
815 let simple_re = SIMPLE_RE.get_or_init(|| {
816 Regex::new(r"^Budget paused with (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent\.")
817 .expect("valid budget paused simple regex")
818 });
819 let caps = simple_re.captures(message)?;
820 Some(
821 UserFacingErrorFields::new()
822 .with_number("spent", caps.name("spent")?.as_str())
823 .with_string("currency", caps.name("currency")?.as_str()),
824 )
825}
826
827fn string_field<'a>(fields: &'a UserFacingErrorFields, key: &str) -> Option<&'a str> {
828 fields.get(key)?.as_str()
829}
830
831fn bool_field(fields: &UserFacingErrorFields, key: &str) -> bool {
832 fields.get(key).and_then(Value::as_bool).unwrap_or(false)
833}
834
835fn truncate_chars(value: &str, max_chars: usize) -> String {
836 if value.chars().count() <= max_chars {
837 return value.to_string();
838 }
839 let truncated: String = value.chars().take(max_chars).collect();
840 format!("{truncated}\u{2026}")
841}
842
843fn number_field(fields: &UserFacingErrorFields, key: &str) -> Option<f64> {
844 match fields.get(key)? {
845 Value::Number(number) => number.as_f64(),
846 Value::String(value) => value.parse().ok(),
847 _ => None,
848 }
849}
850
851trait ErrorFieldsExt {
852 fn with_string(self, key: &str, value: &str) -> Self;
853 fn with_number(self, key: &str, value: &str) -> Self;
854}
855
856impl ErrorFieldsExt for UserFacingErrorFields {
857 fn with_string(mut self, key: &str, value: &str) -> Self {
858 self.insert(key.to_string(), Value::String(value.to_string()));
859 self
860 }
861
862 fn with_number(mut self, key: &str, value: &str) -> Self {
863 if let Ok(number) = value.parse::<f64>()
864 && let Some(json_number) = serde_json::Number::from_f64(number)
865 {
866 self.insert(key.to_string(), Value::Number(json_number));
867 }
868 self
869 }
870}
871
872#[cfg(test)]
873mod tests {
874 use super::*;
875 use serde_json::json;
876
877 fn wire(error: &UserFacingError) -> Value {
878 serde_json::to_value(error).unwrap()
879 }
880 fn context() -> UserFacingErrorContext {
881 UserFacingErrorContext::default()
882 .with_provider("provider")
883 .with_model_id("model")
884 .with_retry_after(7)
885 }
886
887 #[test]
888 fn quota_classification_preserves_context_without_raw_payload_or_retry_delay() {
889 for message in [
890 "ReasonAtom execution failed: OpenAI API error (429): {\"error\":{\"type\":\"insufficient_quota\",\"message\":\"You exceeded your current quota\"}}",
891 "LLM error: insufficient_quota: You exceeded your current quota.",
892 "credit_balance_exhausted: secret=hidden",
893 "Anthropic API error (400): Your credit balance is too low to access the Anthropic API.",
894 "INSUFFICIENT QUOTA",
895 ] {
896 let error = classify_runtime_error_message(message, &context());
897 assert_eq!(
898 wire(&error),
899 json!({"code":"provider_quota_exhausted","fields":{"provider":"provider","model_id":"model"}})
900 );
901 assert_eq!(
902 error.fallback_message(),
903 "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
904 );
905 assert_eq!(
906 wire(&classify_runtime_error_message(
907 message,
908 &UserFacingErrorContext::default()
909 )),
910 json!({"code":"provider_quota_exhausted"})
911 );
912 }
913 }
914
915 #[test]
916 fn ordinary_classification_has_exact_code_and_allowed_context_fields() {
917 for (message, expected) in [
918 (
919 "OpenAI API error (429): rate limit exceeded",
920 json!({"code":"provider_rate_limited","fields":{"provider":"provider","model_id":"model","retry_after":7}}),
921 ),
922 (
923 "LLM error: API key is required. Configure the API key in provider settings.",
924 json!({"code":"provider_misconfigured","fields":{"provider":"provider","model_id":"model"}}),
925 ),
926 (
927 "ReasonAtom execution failed: Model not configured",
928 json!({"code":"model_not_configured"}),
929 ),
930 (
931 "ActAtom execution failed: Model not available: retired-model",
932 json!({"code":"model_unavailable","fields":{"model_id":"retired-model"}}),
933 ),
934 (
935 "Request too large: context length",
936 json!({"code":"request_too_large","fields":{"provider":"provider","model_id":"model"}}),
937 ),
938 (
939 "provider error (503)",
940 json!({"code":"provider_unavailable","fields":{"provider":"provider","model_id":"model"}}),
941 ),
942 (
943 "unknown raw error secret=hidden",
944 json!({"code":"processing_error","fields":{"provider":"provider","model_id":"model"}}),
945 ),
946 ] {
947 assert_eq!(
948 wire(&classify_runtime_error_message(message, &context())),
949 expected,
950 "{message}"
951 );
952 }
953 assert_eq!(
954 UserFacingError::new("model_not_configured").fallback_message(),
955 "No model is configured for this chat. Choose a model or configure a default model, then try again."
956 );
957 }
958
959 #[test]
960 fn budget_fields_drive_exact_exhausted_and_paused_copy() {
961 let error = classify_runtime_error_message(
962 "ReasonAtom execution failed: Budget exhausted. 12.50 usd spent exceeded the 10.00 usd limit. Increase the budget to continue.",
963 &context(),
964 );
965 assert_eq!(
966 wire(&error),
967 json!({"code":"budget_exhausted","fields":{"spent":12.5,"limit":10.0,"currency":"usd"}})
968 );
969 assert_eq!(
970 error.fallback_message(),
971 "Budget exhausted. 12.50 usd spent exceeded the 10.00 usd limit. Increase the budget to continue."
972 );
973 for (spent, expected) in [
974 (
975 4.0,
976 "Budget paused with 4.00 tokens spent. Increase or resume the budget to continue.",
977 ),
978 (
979 5.0,
980 "Budget paused. 5.00 tokens spent reached the 5.00 tokens soft limit. Increase or resume the budget to continue.",
981 ),
982 (
983 6.0,
984 "Budget paused. 6.00 tokens spent exceeded the 5.00 tokens soft limit. Increase or resume the budget to continue.",
985 ),
986 ] {
987 let error = UserFacingError::new("budget_paused")
988 .with_field("spent", spent)
989 .with_field("soft_limit", 5.0)
990 .with_field("currency", "tokens");
991 assert_eq!(error.fallback_message(), expected);
992 }
993 assert_eq!(
994 UserFacingError::new("budget_paused").fallback_message(),
995 "Budget paused. Increase or resume the budget to continue."
996 );
997 }
998
999 #[test]
1000 fn schema_rejections_only_expose_safe_bounded_paths() {
1001 let path200 = format!("$.{}", "a".repeat(198));
1002 let path201 = format!("$.{}", "a".repeat(199));
1003 for (path, expected_path) in [
1004 (
1005 "$.properties.email.pattern",
1006 Some("$.properties.email.pattern"),
1007 ),
1008 ("$.properties.email.pattern?<token>", None),
1009 (path200.as_str(), Some(path200.as_str())),
1010 (path201.as_str(), None),
1011 ] {
1012 let error = classify_runtime_error_message(
1013 &format!(
1014 "Invalid JSON schema at $.properties: regex lookaround is unsupported. Found at {path}."
1015 ),
1016 &context(),
1017 );
1018 let mut fields = json!({"provider":"provider","model_id":"model"});
1019 if let Some(path) = expected_path {
1020 fields["schema_path"] = json!(path);
1021 }
1022 assert_eq!(
1023 wire(&error),
1024 json!({"code":"invalid_tool_schema","fields":fields})
1025 );
1026 assert_eq!(
1027 error.fallback_message(),
1028 "A connected tool uses an input schema that this model provider does not support. Update the integration or choose a different model provider, then try again."
1029 );
1030 }
1031 }
1032
1033 #[test]
1034 fn attestation_types_only_expose_safe_identifiers() {
1035 let message = r#"{"missing_attestation_types":["age_18plus","org-policy.v2:required","<https://evil.example|Verify account>"]}"#;
1036 let requirement = parse_attestation_requirement(message).unwrap();
1037
1038 assert_eq!(
1039 requirement.missing_types,
1040 ["age_18plus", "org-policy.v2:required"]
1041 );
1042
1043 for unsafe_type in [
1046 "<https://evil.example|Verify>",
1047 "[verify](https://evil.example)",
1048 "<!channel>",
1049 "line\\nbreak",
1050 ] {
1051 let unsafe_only = format!(
1052 r#"{{"message":"This model requires you to complete the following before use","missing_attestation_types":["{unsafe_type}"]}}"#
1053 );
1054 assert!(
1055 parse_attestation_requirement(&unsafe_only)
1056 .unwrap()
1057 .missing_types
1058 .is_empty(),
1059 "{unsafe_type}"
1060 );
1061 }
1062 }
1063
1064 #[test]
1065 fn usage_limits_have_exact_reset_copy_and_explicit_auto_continue_policy() {
1066 let error = classify_runtime_error_message(
1067 "Codex API error (429 Too Many Requests): {\"error\":{\"type\":\"usage_limit_reached\",\"resets_at\":1783767823,\"resets_in_seconds\":12337}}",
1068 &context(),
1069 );
1070 assert_eq!(
1071 wire(&error),
1072 json!({"code":"provider_usage_limit_reached","fields":{"provider":"provider","model_id":"model","resets_at":1783767823}})
1073 );
1074 assert_eq!(
1075 error.fallback_message(),
1076 "You're out of LLM usage limits. Your usage limit resets at 11:03 UTC on Jul 11."
1077 );
1078 assert_eq!(
1079 error
1080 .clone()
1081 .with_field("auto_continue", true)
1082 .fallback_message(),
1083 "You're out of LLM usage limits. Your usage limit resets at 11:03 UTC on Jul 11. We'll continue work automatically once it resets."
1084 );
1085 assert_eq!(
1086 error
1087 .clone()
1088 .with_field("auto_continue", false)
1089 .fallback_message(),
1090 error.fallback_message()
1091 );
1092 let no_reset = classify_runtime_error_message(
1093 "Some Provider API error (429): usage limit reached",
1094 &UserFacingErrorContext::default(),
1095 );
1096 assert_eq!(
1097 wire(&no_reset),
1098 json!({"code":"provider_usage_limit_reached"})
1099 );
1100 assert_eq!(
1101 no_reset.fallback_message(),
1102 "You're out of LLM usage limits."
1103 );
1104 }
1105
1106 #[test]
1107 fn disclosure_modes_preserve_only_their_allowed_fields() {
1108 let error = UserFacingError::new("provider_quota_exhausted")
1109 .with_field("provider", "openai")
1110 .with_field("model_id", "model");
1111 let detail = " Authorization: Bearer synthetic-secret ";
1112 let generic = error.apply_disclosure(ErrorDisclosure::Generic, Some(detail));
1113 assert_eq!(wire(&generic), json!({"code":"processing_error"}));
1114 assert_eq!(
1115 generic.fallback_message(),
1116 "I encountered an error while processing your request. Please try again later."
1117 );
1118 assert_eq!(
1119 error.apply_disclosure(ErrorDisclosure::Standard, Some(detail)),
1120 error
1121 );
1122 let detailed = error.apply_disclosure(ErrorDisclosure::Detailed, Some(detail));
1123 assert_eq!(
1124 wire(&detailed),
1125 json!({"code":"provider_quota_exhausted","fields":{"provider":"openai","model_id":"model","detail":"Authorization: Bearer synthetic-secret"}})
1126 );
1127 assert_eq!(
1128 detailed.fallback_message(),
1129 "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
1130 );
1131 for empty in [None, Some(""), Some(" \n\t")] {
1132 assert_eq!(
1133 error.apply_disclosure(ErrorDisclosure::Detailed, empty),
1134 error
1135 );
1136 }
1137 }
1138
1139 #[test]
1140 fn detailed_disclosure_has_literal_unicode_character_boundary() {
1141 let error = UserFacingError::new("processing_error");
1142 for length in [999, 1000, 1001] {
1143 let input = "🦀".repeat(length);
1144 let expected = if length <= 1000 {
1145 input.clone()
1146 } else {
1147 format!("{}…", "🦀".repeat(1000))
1148 };
1149 assert_eq!(
1150 wire(&error.apply_disclosure(ErrorDisclosure::Detailed, Some(&input))),
1151 json!({"code":"processing_error","fields":{"detail":expected}})
1152 );
1153 }
1154 }
1155
1156 #[test]
1157 fn disclosure_parse_and_ordering() {
1158 assert_eq!(
1159 ErrorDisclosure::parse("Generic"),
1160 Some(ErrorDisclosure::Generic)
1161 );
1162 assert_eq!(
1163 ErrorDisclosure::parse("detailed"),
1164 Some(ErrorDisclosure::Detailed)
1165 );
1166 assert_eq!(ErrorDisclosure::parse("nope"), None);
1167 assert!(ErrorDisclosure::Generic < ErrorDisclosure::Standard);
1168 assert!(ErrorDisclosure::Standard < ErrorDisclosure::Detailed);
1169 assert_eq!(ErrorDisclosure::default(), ErrorDisclosure::Standard);
1170 }
1171
1172 #[test]
1173 fn applying_error_replaces_owned_metadata_and_clears_previous_detail() {
1174 let mut metadata = HashMap::from([
1175 ("other".into(), json!("preserve")),
1176 (
1177 "error_fields".into(),
1178 json!({"detail":"old-private-detail"}),
1179 ),
1180 ("error_code".into(), json!("old-code")),
1181 ]);
1182 let error = UserFacingError::new("provider_rate_limited").with_field("retry_after", 7);
1183 error.apply_to_message_metadata(&mut metadata);
1184 assert_eq!(
1185 metadata,
1186 HashMap::from([
1187 ("other".into(), json!("preserve")),
1188 ("error_code".into(), json!("provider_rate_limited")),
1189 ("error_fields".into(), json!({"retry_after":7}))
1190 ])
1191 );
1192 let generic = error.apply_disclosure(ErrorDisclosure::Generic, None);
1193 generic.apply_to_message_metadata(&mut metadata);
1194 assert_eq!(
1195 metadata,
1196 HashMap::from([
1197 ("other".into(), json!("preserve")),
1198 ("error_code".into(), json!("processing_error"))
1199 ])
1200 );
1201 let mut code = Some("old-code".into());
1202 let mut fields = Some(BTreeMap::from([(
1203 "detail".into(),
1204 json!("old-private-detail"),
1205 )]));
1206 generic.apply_to_event_fields(&mut code, &mut fields);
1207 assert_eq!((code, fields), (Some("processing_error".into()), None));
1208 UserFacingError::apply_disclosure_to_message_metadata(
1209 &mut metadata,
1210 ErrorDisclosure::Generic,
1211 "provider_rate_limited",
1212 );
1213 assert_eq!(
1214 metadata,
1215 HashMap::from([
1216 ("other".into(), json!("preserve")),
1217 ("error_code".into(), json!("processing_error")),
1218 ("error_disclosure".into(), json!("generic")),
1219 ("source_error_code".into(), json!("provider_rate_limited"))
1220 ])
1221 );
1222 }
1223}