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_UNAVAILABLE: &str = "provider_unavailable";
30 pub const PROCESSING_ERROR: &str = "processing_error";
31 pub const DEPENDENCY_UNAVAILABLE: &str = "dependency_unavailable";
32 pub const INVALID_TOOL_SCHEMA: &str = "invalid_tool_schema";
33 pub const MAX_ITERATIONS: &str = "max_iterations";
34 pub const SOFT_LIMIT_REACHED: &str = "soft_limit_reached";
35 pub const BLOCKED_BY_HOOK: &str = "blocked_by_hook";
37}
38
39pub type UserFacingErrorFields = BTreeMap<String, Value>;
40
41pub mod metadata_keys {
43 pub const ERROR_DISCLOSURE: &str = "error_disclosure";
45 pub const SOURCE_ERROR_CODE: &str = "source_error_code";
49}
50
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58#[cfg_attr(feature = "openapi", derive(ToSchema))]
59pub enum ErrorDisclosure {
60 Generic,
63 #[default]
65 Standard,
66 Detailed,
69}
70
71impl ErrorDisclosure {
72 pub fn parse(value: &str) -> Option<Self> {
73 match value.trim().to_ascii_lowercase().as_str() {
74 "generic" => Some(ErrorDisclosure::Generic),
75 "standard" => Some(ErrorDisclosure::Standard),
76 "detailed" => Some(ErrorDisclosure::Detailed),
77 _ => None,
78 }
79 }
80
81 pub fn as_str(&self) -> &'static str {
82 match self {
83 ErrorDisclosure::Generic => "generic",
84 ErrorDisclosure::Standard => "standard",
85 ErrorDisclosure::Detailed => "detailed",
86 }
87 }
88}
89
90const DETAIL_MAX_CHARS: usize = 1000;
94
95pub fn is_provider_quota_message(message: &str) -> bool {
98 let lower = message.to_ascii_lowercase();
99 lower.contains("insufficient_quota")
100 || lower.contains("insufficient quota")
101 || lower.contains("exceeded your current quota")
102 || lower.contains("credit_balance_exhausted")
103 || lower.contains("credit balance is too low")
104}
105
106pub fn is_usage_limit_message(message: &str) -> bool {
115 let lower = message.to_ascii_lowercase();
116 lower.contains("usage_limit_reached")
117 || lower.contains("usage limit reached")
118 || lower.contains("usage limit has been reached")
119}
120
121pub fn parse_usage_limit_reset_at(message: &str) -> Option<i64> {
126 static RE: OnceLock<Regex> = OnceLock::new();
127 let re = RE.get_or_init(|| {
128 Regex::new(r#""resets_at"\s*:\s*(?P<resets_at>\d{9,})"#).expect("valid resets_at regex")
129 });
130 re.captures(message)?
131 .name("resets_at")?
132 .as_str()
133 .parse::<i64>()
134 .ok()
135}
136
137#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
138#[cfg_attr(feature = "openapi", derive(ToSchema))]
139pub struct UserFacingError {
140 pub code: String,
141 #[serde(default, skip_serializing_if = "UserFacingErrorFields::is_empty")]
142 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
143 pub fields: UserFacingErrorFields,
144}
145
146#[derive(Debug, Clone, Default)]
147pub struct UserFacingErrorContext {
148 pub provider: Option<String>,
149 pub model_id: Option<String>,
150 pub retry_after: Option<u64>,
151}
152
153impl UserFacingErrorContext {
154 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
155 self.provider = Some(provider.into());
156 self
157 }
158
159 pub fn with_model_id(mut self, model_id: impl Into<String>) -> Self {
160 self.model_id = Some(model_id.into());
161 self
162 }
163
164 pub fn with_retry_after(mut self, retry_after: u64) -> Self {
165 self.retry_after = Some(retry_after);
166 self
167 }
168}
169
170impl UserFacingError {
171 pub fn new(code: impl Into<String>) -> Self {
172 Self {
173 code: code.into(),
174 fields: UserFacingErrorFields::new(),
175 }
176 }
177
178 pub fn with_field<T: Serialize>(mut self, key: impl Into<String>, value: T) -> Self {
179 let value = serde_json::to_value(value).unwrap_or(Value::Null);
180 if !value.is_null() {
181 self.fields.insert(key.into(), value);
182 }
183 self
184 }
185
186 pub fn with_optional_field<T: Serialize>(
187 self,
188 key: impl Into<String>,
189 value: Option<T>,
190 ) -> Self {
191 match value {
192 Some(value) => self.with_field(key, value),
193 None => self,
194 }
195 }
196
197 pub fn error_fields(&self) -> Option<UserFacingErrorFields> {
198 (!self.fields.is_empty()).then_some(self.fields.clone())
199 }
200
201 pub fn apply_to_event_fields(
202 &self,
203 error_code: &mut Option<String>,
204 error_fields: &mut Option<UserFacingErrorFields>,
205 ) {
206 *error_code = Some(self.code.clone());
207 *error_fields = self.error_fields();
208 }
209
210 pub fn apply_to_message_metadata(&self, metadata: &mut HashMap<String, Value>) {
211 metadata.insert("error_code".to_string(), Value::String(self.code.clone()));
212 if let Some(fields) = self.error_fields() {
213 metadata.insert(
214 "error_fields".to_string(),
215 serde_json::to_value(fields).unwrap_or(Value::Null),
216 );
217 }
218 }
219
220 pub fn apply_disclosure(&self, mode: ErrorDisclosure, detail: Option<&str>) -> UserFacingError {
229 match mode {
230 ErrorDisclosure::Generic => UserFacingError::new(codes::PROCESSING_ERROR),
231 ErrorDisclosure::Standard => self.clone(),
232 ErrorDisclosure::Detailed => {
233 let detail = detail.map(str::trim).filter(|d| !d.is_empty());
234 match detail {
235 Some(detail) => self
236 .clone()
237 .with_field("detail", truncate_chars(detail, DETAIL_MAX_CHARS)),
238 None => self.clone(),
239 }
240 }
241 }
242 }
243
244 pub fn apply_disclosure_to_message_metadata(
247 metadata: &mut HashMap<String, Value>,
248 mode: ErrorDisclosure,
249 source_code: &str,
250 ) {
251 metadata.insert(
252 metadata_keys::ERROR_DISCLOSURE.to_string(),
253 Value::String(mode.as_str().to_string()),
254 );
255 metadata.insert(
256 metadata_keys::SOURCE_ERROR_CODE.to_string(),
257 Value::String(source_code.to_string()),
258 );
259 }
260
261 pub fn fallback_message(&self) -> String {
262 self.base_fallback_message()
263 }
264
265 fn base_fallback_message(&self) -> String {
266 match self.code.as_str() {
267 codes::BUDGET_EXHAUSTED => budget_exhausted_message(&self.fields),
268 codes::BUDGET_PAUSED => budget_paused_message(&self.fields),
269 codes::SOFT_LIMIT_REACHED => string_field(&self.fields, "message")
270 .unwrap_or("Soft limit reached.")
271 .to_string(),
272 codes::MODEL_UNAVAILABLE => {
273 if let Some(model_id) = string_field(&self.fields, "model_id") {
274 format!(
275 "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.",
276 model_id
277 )
278 } else {
279 "The selected model is not available. Please select a different model."
280 .to_string()
281 }
282 }
283 codes::MODEL_NOT_CONFIGURED => {
284 "No model is configured for this chat. Choose a model or configure a default model, then try again."
285 .to_string()
286 }
287 codes::REQUEST_TOO_LARGE => {
288 "The conversation has become too long for the model to process. Please start a new session or reduce the context size.".to_string()
289 }
290 codes::PROVIDER_RATE_LIMITED => {
291 "Rate limited by the AI provider. Please wait a moment.".to_string()
292 }
293 codes::PROVIDER_USAGE_LIMIT_REACHED => usage_limit_reached_message(&self.fields),
294 codes::PROVIDER_MISCONFIGURED => {
295 "There is a misconfiguration with the AI provider. Please contact support."
296 .to_string()
297 }
298 codes::PROVIDER_QUOTA_EXHAUSTED => {
299 "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
300 .to_string()
301 }
302 codes::PROVIDER_UNAVAILABLE => {
303 "The AI provider is experiencing issues. Please try again shortly.".to_string()
304 }
305 codes::DEPENDENCY_UNAVAILABLE => {
306 "Execution stopped because a required dependency is unavailable.".to_string()
307 }
308 codes::INVALID_TOOL_SCHEMA => {
309 "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."
310 .to_string()
311 }
312 _ => "I encountered an error while processing your request. Please try again later."
313 .to_string(),
314 }
315 }
316}
317
318pub fn classify_runtime_error_message(
319 error: &str,
320 context: &UserFacingErrorContext,
321) -> UserFacingError {
322 let normalized = trim_error_chain_prefixes(error).trim();
323 let lower = normalized.to_ascii_lowercase();
324
325 if let Some(fields) = parse_budget_exhausted_fields(normalized) {
326 return UserFacingError {
327 code: codes::BUDGET_EXHAUSTED.to_string(),
328 fields,
329 };
330 }
331
332 if normalized.starts_with("Budget exhausted.") {
333 return UserFacingError::new(codes::BUDGET_EXHAUSTED);
334 }
335
336 if normalized.starts_with("Budget exhausted (") {
337 return UserFacingError::new(codes::BUDGET_EXHAUSTED);
338 }
339
340 if let Some(fields) = parse_budget_paused_fields(normalized) {
341 return UserFacingError {
342 code: codes::BUDGET_PAUSED.to_string(),
343 fields,
344 };
345 }
346
347 if normalized.starts_with("Budget paused.") || normalized.starts_with("Budget paused with ") {
348 return UserFacingError::new(codes::BUDGET_PAUSED);
349 }
350
351 if normalized.starts_with("Budget paused (") || normalized.starts_with("Soft limit reached.") {
352 return if normalized.starts_with("Soft limit reached.") {
353 UserFacingError::new(codes::SOFT_LIMIT_REACHED).with_field("message", normalized)
354 } else {
355 UserFacingError::new(codes::BUDGET_PAUSED)
356 };
357 }
358
359 if let Some(model_id) = normalized.strip_prefix("Model not available: ") {
360 return UserFacingError::new(codes::MODEL_UNAVAILABLE).with_field("model_id", model_id);
361 }
362
363 if normalized.starts_with("Model not configured") || lower.contains("no model configured") {
364 return UserFacingError::new(codes::MODEL_NOT_CONFIGURED);
365 }
366
367 if normalized.starts_with("Request too large:")
368 || lower.contains("context length")
369 || lower.contains("maximum context length")
370 {
371 return UserFacingError::new(codes::REQUEST_TOO_LARGE)
372 .with_optional_field("provider", context.provider.clone())
373 .with_optional_field("model_id", context.model_id.clone());
374 }
375
376 if is_invalid_tool_schema_message(&lower) {
377 return UserFacingError::new(codes::INVALID_TOOL_SCHEMA)
378 .with_optional_field("provider", context.provider.clone())
379 .with_optional_field("model_id", context.model_id.clone())
380 .with_optional_field("schema_path", extract_schema_path(normalized));
381 }
382
383 if is_provider_quota_message(normalized) {
389 return UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
390 .with_optional_field("provider", context.provider.clone())
391 .with_optional_field("model_id", context.model_id.clone());
392 }
393
394 if is_usage_limit_message(normalized) {
400 return UserFacingError::new(codes::PROVIDER_USAGE_LIMIT_REACHED)
401 .with_optional_field("provider", context.provider.clone())
402 .with_optional_field("model_id", context.model_id.clone())
403 .with_optional_field("resets_at", parse_usage_limit_reset_at(normalized));
404 }
405
406 if lower.contains("(429)")
407 || lower.contains("rate limit")
408 || lower.contains("too many requests")
409 {
410 return UserFacingError::new(codes::PROVIDER_RATE_LIMITED)
411 .with_optional_field("provider", context.provider.clone())
412 .with_optional_field("model_id", context.model_id.clone())
413 .with_optional_field("retry_after", context.retry_after);
414 }
415
416 if lower.contains("(401)") || lower.contains("(403)") {
417 return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
418 .with_optional_field("provider", context.provider.clone())
419 .with_optional_field("model_id", context.model_id.clone());
420 }
421
422 if lower.contains("api key is required")
423 || lower.contains("configure the api key")
424 || lower.contains("api key missing")
425 || lower.contains("missing api key")
426 || lower.contains("invalid api key")
427 {
428 return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
429 .with_optional_field("provider", context.provider.clone())
430 .with_optional_field("model_id", context.model_id.clone());
431 }
432
433 if ["(500)", "(502)", "(503)", "(504)", "(529)"]
434 .iter()
435 .any(|code| lower.contains(code))
436 {
437 return UserFacingError::new(codes::PROVIDER_UNAVAILABLE)
438 .with_optional_field("provider", context.provider.clone())
439 .with_optional_field("model_id", context.model_id.clone());
440 }
441
442 UserFacingError::new(codes::PROCESSING_ERROR)
443 .with_optional_field("provider", context.provider.clone())
444 .with_optional_field("model_id", context.model_id.clone())
445}
446
447fn is_invalid_tool_schema_message(lower: &str) -> bool {
448 lower.contains("invalid_function_parameters")
449 || lower.contains("invalid function parameters")
450 || (lower.contains("invalid json schema") && lower.contains("$.properties"))
451 || lower.contains("invalid tool schema")
452}
453
454fn extract_schema_path(message: &str) -> Option<String> {
455 let path = message.split_once("Found at ")?.1;
456 let path = path
457 .split(|character: char| character.is_whitespace() || character == '`')
458 .next()?
459 .trim_end_matches(['.', ',', ';', ':']);
460 (path.starts_with('$')
461 && path.len() <= 200
462 && path.chars().all(|character| {
463 character.is_ascii_alphanumeric()
464 || matches!(character, '$' | '.' | '_' | '-' | '[' | ']')
465 }))
466 .then(|| path.to_string())
467}
468
469pub fn trim_error_chain_prefixes(error_chain: &str) -> &str {
470 error_chain
471 .trim()
472 .trim_start_matches("InputAtom execution failed: ")
473 .trim_start_matches("ReasonAtom execution failed: ")
474 .trim_start_matches("ActAtom execution failed: ")
475}
476
477fn usage_limit_reached_message(fields: &UserFacingErrorFields) -> String {
483 let mut message = String::from("You're out of LLM usage limits.");
484
485 if let Some(resets_at) = number_field(fields, "resets_at")
486 && let Some(reset) = chrono::DateTime::from_timestamp(resets_at as i64, 0)
487 {
488 message.push_str(&format!(
489 " Your usage limit resets at {}.",
490 reset.format("%H:%M UTC on %b %-d")
491 ));
492 }
493
494 if bool_field(fields, "auto_continue") {
495 message.push_str(" We'll continue work automatically once it resets.");
496 }
497
498 message
499}
500
501fn budget_exhausted_message(fields: &UserFacingErrorFields) -> String {
502 if let (Some(spent), Some(limit), Some(currency)) = (
503 number_field(fields, "spent"),
504 number_field(fields, "limit"),
505 string_field(fields, "currency"),
506 ) {
507 let comparison = if spent > limit { "exceeded" } else { "reached" };
508 return format!(
509 "Budget exhausted. {:.2} {} spent {} the {:.2} {} limit. Increase the budget to continue.",
510 spent, currency, comparison, limit, currency
511 );
512 }
513
514 "Budget exhausted. Increase the budget to continue.".to_string()
515}
516
517fn budget_paused_message(fields: &UserFacingErrorFields) -> String {
518 let spent = number_field(fields, "spent");
519 let currency = string_field(fields, "currency");
520 let soft_limit = number_field(fields, "soft_limit");
521
522 match (spent, currency, soft_limit) {
523 (Some(spent), Some(currency), Some(soft_limit)) => {
524 let comparison = if spent > soft_limit {
525 "exceeded"
526 } else if spent >= soft_limit {
527 "reached"
528 } else {
529 "with"
530 };
531 if comparison == "with" {
532 format!(
533 "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
534 spent, currency
535 )
536 } else {
537 format!(
538 "Budget paused. {:.2} {} spent {} the {:.2} {} soft limit. Increase or resume the budget to continue.",
539 spent, currency, comparison, soft_limit, currency
540 )
541 }
542 }
543 (Some(spent), Some(currency), None) => format!(
544 "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
545 spent, currency
546 ),
547 _ => "Budget paused. Increase or resume the budget to continue.".to_string(),
548 }
549}
550
551fn parse_budget_exhausted_fields(message: &str) -> Option<UserFacingErrorFields> {
552 static RE: OnceLock<Regex> = OnceLock::new();
553 let re = RE.get_or_init(|| {
554 Regex::new(
555 r"^Budget exhausted\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<limit>\d+(?:\.\d+)?) \S+ limit\.",
556 )
557 .expect("valid budget exhausted regex")
558 });
559 let caps = re.captures(message)?;
560 Some(
561 UserFacingErrorFields::new()
562 .with_number("spent", caps.name("spent")?.as_str())
563 .with_number("limit", caps.name("limit")?.as_str())
564 .with_string("currency", caps.name("currency")?.as_str()),
565 )
566}
567
568fn parse_budget_paused_fields(message: &str) -> Option<UserFacingErrorFields> {
569 static SOFT_LIMIT_RE: OnceLock<Regex> = OnceLock::new();
570 static SIMPLE_RE: OnceLock<Regex> = OnceLock::new();
571
572 let soft_limit_re = SOFT_LIMIT_RE.get_or_init(|| {
573 Regex::new(
574 r"^Budget paused\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<soft_limit>\d+(?:\.\d+)?) \S+ soft limit\.",
575 )
576 .expect("valid budget paused regex")
577 });
578 if let Some(caps) = soft_limit_re.captures(message) {
579 return Some(
580 UserFacingErrorFields::new()
581 .with_number("spent", caps.name("spent")?.as_str())
582 .with_number("soft_limit", caps.name("soft_limit")?.as_str())
583 .with_string("currency", caps.name("currency")?.as_str()),
584 );
585 }
586
587 let simple_re = SIMPLE_RE.get_or_init(|| {
588 Regex::new(r"^Budget paused with (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent\.")
589 .expect("valid budget paused simple regex")
590 });
591 let caps = simple_re.captures(message)?;
592 Some(
593 UserFacingErrorFields::new()
594 .with_number("spent", caps.name("spent")?.as_str())
595 .with_string("currency", caps.name("currency")?.as_str()),
596 )
597}
598
599fn string_field<'a>(fields: &'a UserFacingErrorFields, key: &str) -> Option<&'a str> {
600 fields.get(key)?.as_str()
601}
602
603fn bool_field(fields: &UserFacingErrorFields, key: &str) -> bool {
604 fields.get(key).and_then(Value::as_bool).unwrap_or(false)
605}
606
607fn truncate_chars(value: &str, max_chars: usize) -> String {
608 if value.chars().count() <= max_chars {
609 return value.to_string();
610 }
611 let truncated: String = value.chars().take(max_chars).collect();
612 format!("{truncated}\u{2026}")
613}
614
615fn number_field(fields: &UserFacingErrorFields, key: &str) -> Option<f64> {
616 match fields.get(key)? {
617 Value::Number(number) => number.as_f64(),
618 Value::String(value) => value.parse().ok(),
619 _ => None,
620 }
621}
622
623trait ErrorFieldsExt {
624 fn with_string(self, key: &str, value: &str) -> Self;
625 fn with_number(self, key: &str, value: &str) -> Self;
626}
627
628impl ErrorFieldsExt for UserFacingErrorFields {
629 fn with_string(mut self, key: &str, value: &str) -> Self {
630 self.insert(key.to_string(), Value::String(value.to_string()));
631 self
632 }
633
634 fn with_number(mut self, key: &str, value: &str) -> Self {
635 if let Ok(number) = value.parse::<f64>()
636 && let Some(json_number) = serde_json::Number::from_f64(number)
637 {
638 self.insert(key.to_string(), Value::Number(json_number));
639 }
640 self
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 #[test]
649 fn classify_budget_exhausted_parses_fields() {
650 let error = classify_runtime_error_message(
651 "ReasonAtom execution failed: Budget exhausted. 12.50 usd spent exceeded the 10.00 usd limit. Increase the budget to continue.",
652 &UserFacingErrorContext::default(),
653 );
654
655 assert_eq!(error.code, codes::BUDGET_EXHAUSTED);
656 assert_eq!(number_field(&error.fields, "spent"), Some(12.5));
657 assert_eq!(number_field(&error.fields, "limit"), Some(10.0));
658 assert_eq!(string_field(&error.fields, "currency"), Some("usd"));
659 }
660
661 #[test]
662 fn classify_provider_rate_limit_keeps_context() {
663 let error = classify_runtime_error_message(
664 "OpenAI API error (429): rate limit exceeded",
665 &UserFacingErrorContext::default()
666 .with_provider("openai")
667 .with_model_id("gpt-5")
668 .with_retry_after(7),
669 );
670
671 assert_eq!(error.code, codes::PROVIDER_RATE_LIMITED);
672 assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
673 assert_eq!(string_field(&error.fields, "model_id"), Some("gpt-5"));
674 assert_eq!(number_field(&error.fields, "retry_after"), Some(7.0));
675 }
676
677 #[test]
678 fn classifies_openai_tool_schema_rejection_without_exposing_provider_payload() {
679 let error = classify_runtime_error_message(
680 "OpenAI Responses API error (400 Bad Request): Invalid JSON schema: regex lookaround is not supported. Found at $.properties.email.pattern.",
681 &UserFacingErrorContext::default()
682 .with_provider("openai")
683 .with_model_id("gpt-5.6-terra"),
684 );
685
686 assert_eq!(error.code, codes::INVALID_TOOL_SCHEMA);
687 assert_eq!(
688 error.fields.get("schema_path").and_then(Value::as_str),
689 Some("$.properties.email.pattern")
690 );
691 assert_eq!(
692 error.fallback_message(),
693 "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."
694 );
695 assert!(!error.fallback_message().contains("regex lookaround"));
696 }
697
698 #[test]
699 fn invalid_tool_schema_drops_unsafe_provider_schema_path() {
700 let error = classify_runtime_error_message(
701 "Invalid JSON schema at $.properties: Found at $.properties.email.pattern?<token>.",
702 &UserFacingErrorContext::default(),
703 );
704
705 assert_eq!(error.code, codes::INVALID_TOOL_SCHEMA);
706 assert!(!error.fields.contains_key("schema_path"));
707 }
708
709 #[test]
710 fn classify_openai_insufficient_quota_as_provider_quota_exhausted() {
711 let error = classify_runtime_error_message(
715 "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\"}}",
716 &UserFacingErrorContext::default()
717 .with_provider("openai")
718 .with_model_id("gpt-4.1-mini"),
719 );
720
721 assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
722 assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
723 assert_eq!(
724 string_field(&error.fields, "model_id"),
725 Some("gpt-4.1-mini")
726 );
727 }
728
729 #[test]
730 fn classify_insufficient_quota_without_status_prefix() {
731 let error = classify_runtime_error_message(
735 "LLM error: insufficient_quota: You exceeded your current quota.",
736 &UserFacingErrorContext::default(),
737 );
738
739 assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
740 }
741
742 #[test]
743 fn classify_credit_balance_exhausted_as_provider_quota_exhausted() {
744 let error = classify_runtime_error_message(
745 "LLM error: credit_balance_exhausted: You have no credits remaining. secret=hidden",
746 &UserFacingErrorContext::default(),
747 );
748
749 assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
750 assert_eq!(
751 error.fallback_message(),
752 "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
753 );
754 assert!(!error.fallback_message().contains("secret"));
755 }
756
757 #[test]
758 fn classify_codex_usage_limit_reached_as_usage_limit() {
759 let error = classify_runtime_error_message(
763 "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}}",
764 &UserFacingErrorContext::default()
765 .with_provider("openai-codex")
766 .with_model_id("gpt-5-codex"),
767 );
768
769 assert_eq!(error.code, codes::PROVIDER_USAGE_LIMIT_REACHED);
770 assert_eq!(
771 string_field(&error.fields, "provider"),
772 Some("openai-codex")
773 );
774 assert_eq!(number_field(&error.fields, "resets_at"), Some(1783767823.0));
775
776 let message = error.fallback_message();
779 assert!(
780 message.starts_with("You're out of LLM usage limits."),
781 "unexpected copy: {message}"
782 );
783 assert!(
784 message.contains("resets at"),
785 "missing reset time: {message}"
786 );
787 assert!(
788 !message.contains("automatically"),
789 "unexpected promise: {message}"
790 );
791 }
792
793 #[test]
794 fn usage_limit_message_without_reset_time_stays_generic() {
795 let error = classify_runtime_error_message(
796 "Some Provider API error (429): usage limit reached",
797 &UserFacingErrorContext::default(),
798 );
799
800 assert_eq!(error.code, codes::PROVIDER_USAGE_LIMIT_REACHED);
801 assert_eq!(number_field(&error.fields, "resets_at"), None);
802 assert_eq!(error.fallback_message(), "You're out of LLM usage limits.");
803 }
804
805 #[test]
806 fn usage_limit_message_appends_auto_continue_suffix_when_flagged() {
807 let error = UserFacingError::new(codes::PROVIDER_USAGE_LIMIT_REACHED)
810 .with_field("resets_at", 1783767823)
811 .with_field("auto_continue", true);
812
813 let message = error.fallback_message();
814 assert!(
815 message.contains("resets at"),
816 "missing reset time: {message}"
817 );
818 assert!(
819 message.contains("We'll continue work automatically once it resets."),
820 "missing auto-continue promise: {message}"
821 );
822 }
823
824 #[test]
825 fn classify_anthropic_low_credit_balance_as_provider_quota_exhausted() {
826 let error = classify_runtime_error_message(
827 "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.\"}}",
828 &UserFacingErrorContext::default().with_provider("anthropic"),
829 );
830
831 assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
832 }
833
834 #[test]
835 fn disclosure_generic_collapses_code_and_fields() {
836 let error = UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
837 .with_field("provider", "openai")
838 .with_field("model_id", "gpt-4.1-mini");
839
840 let disclosed = error.apply_disclosure(ErrorDisclosure::Generic, Some("raw detail"));
841
842 assert_eq!(disclosed.code, codes::PROCESSING_ERROR);
843 assert!(disclosed.fields.is_empty());
844 assert_eq!(
845 disclosed.fallback_message(),
846 "I encountered an error while processing your request. Please try again later."
847 );
848 }
849
850 #[test]
851 fn disclosure_standard_is_identity() {
852 let error = UserFacingError::new(codes::PROVIDER_RATE_LIMITED).with_field("retry_after", 7);
853 let disclosed = error.apply_disclosure(ErrorDisclosure::Standard, Some("raw detail"));
854 assert_eq!(disclosed, error);
855 }
856
857 #[test]
858 fn disclosure_detailed_attaches_detail_without_rendering_it() {
859 let error = UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED);
860 let disclosed = error.apply_disclosure(
861 ErrorDisclosure::Detailed,
862 Some("OpenAI API error (429): insufficient_quota Authorization: Bearer sk-secret"),
863 );
864
865 assert_eq!(disclosed.code, codes::PROVIDER_QUOTA_EXHAUSTED);
866 assert_eq!(
867 string_field(&disclosed.fields, "detail"),
868 Some("OpenAI API error (429): insufficient_quota Authorization: Bearer sk-secret")
869 );
870 let message = disclosed.fallback_message();
871 assert!(message.contains("out of credits or quota"));
872 assert!(!message.contains("insufficient_quota"));
873 assert!(!message.contains("sk-secret"));
874 }
875
876 #[test]
877 fn disclosure_detailed_truncates_long_detail() {
878 let error = UserFacingError::new(codes::PROCESSING_ERROR);
879 let long_detail = "x".repeat(5000);
880 let disclosed = error.apply_disclosure(ErrorDisclosure::Detailed, Some(&long_detail));
881 let detail = string_field(&disclosed.fields, "detail").unwrap();
882 assert!(detail.chars().count() <= 1001); }
884
885 #[test]
886 fn disclosure_parse_and_ordering() {
887 assert_eq!(
888 ErrorDisclosure::parse("Generic"),
889 Some(ErrorDisclosure::Generic)
890 );
891 assert_eq!(
892 ErrorDisclosure::parse("detailed"),
893 Some(ErrorDisclosure::Detailed)
894 );
895 assert_eq!(ErrorDisclosure::parse("nope"), None);
896 assert!(ErrorDisclosure::Generic < ErrorDisclosure::Standard);
897 assert!(ErrorDisclosure::Standard < ErrorDisclosure::Detailed);
898 assert_eq!(ErrorDisclosure::default(), ErrorDisclosure::Standard);
899 }
900
901 #[test]
902 fn classify_missing_api_key_as_provider_misconfigured() {
903 let error = classify_runtime_error_message(
904 "LLM error: API key is required. Configure the API key in provider settings.",
905 &UserFacingErrorContext::default().with_provider("openai"),
906 );
907
908 assert_eq!(error.code, codes::PROVIDER_MISCONFIGURED);
909 assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
910 }
911
912 #[test]
913 fn classify_missing_model_as_model_not_configured() {
914 let error = classify_runtime_error_message(
915 "ReasonAtom execution failed: Model not configured",
916 &UserFacingErrorContext::default(),
917 );
918
919 assert_eq!(error.code, codes::MODEL_NOT_CONFIGURED);
920 assert!(error.fallback_message().contains("Choose a model"));
921 }
922
923 #[test]
924 fn fallback_message_reuses_budget_fields() {
925 let error = UserFacingError::new(codes::BUDGET_PAUSED)
926 .with_field("spent", 5.0)
927 .with_field("soft_limit", 5.0)
928 .with_field("currency", "tokens");
929
930 assert_eq!(
931 error.fallback_message(),
932 "Budget paused. 5.00 tokens spent reached the 5.00 tokens soft limit. Increase or resume the budget to continue."
933 );
934 }
935}