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