1#![expect(
2 clippy::let_underscore_must_use,
3 reason = "The category formatter intentionally ignores infallible formatting results."
4)]
5
6use std::borrow::Cow;
27use std::fmt;
28use std::fmt::Write;
29use std::time::Duration;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
34pub enum ErrorCategory {
35 Network,
38 Timeout,
40 RateLimit,
42 ServiceUnavailable,
44 CircuitOpen,
46
47 Authentication,
50 InvalidParameters,
52 ToolNotFound,
54 ResourceNotFound,
56 PermissionDenied,
58 PolicyViolation,
60 PlanningPolicyViolation,
62 SandboxFailure,
64 ResourceExhausted,
66 Cancelled,
68 ExecutionError,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum Retryability {
75 Retryable {
77 max_attempts: u32,
79 backoff: BackoffStrategy,
81 },
82 NonRetryable,
84 RequiresIntervention,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum BackoffStrategy {
91 Exponential { base: Duration, max: Duration },
93 Fixed(Duration),
95}
96
97impl ErrorCategory {
98 #[inline]
103 #[must_use]
104 pub const fn as_str(&self) -> &'static str {
105 self.user_label()
106 }
107
108 #[inline]
110 #[must_use]
111 pub const fn is_retryable(&self) -> bool {
112 matches!(
113 self,
114 ErrorCategory::Network
115 | ErrorCategory::Timeout
116 | ErrorCategory::RateLimit
117 | ErrorCategory::ServiceUnavailable
118 | ErrorCategory::CircuitOpen
119 )
120 }
121
122 #[inline]
124 #[must_use]
125 pub const fn should_trip_circuit_breaker(&self) -> bool {
126 matches!(
127 self,
128 ErrorCategory::Network
129 | ErrorCategory::Timeout
130 | ErrorCategory::RateLimit
131 | ErrorCategory::ServiceUnavailable
132 | ErrorCategory::ExecutionError
133 )
134 }
135
136 #[inline]
139 #[must_use]
140 const fn is_llm_mistake(&self) -> bool {
141 matches!(self, ErrorCategory::InvalidParameters)
142 }
143
144 #[inline]
146 #[must_use]
147 pub const fn is_permanent(&self) -> bool {
148 matches!(
149 self,
150 ErrorCategory::Authentication
151 | ErrorCategory::PolicyViolation
152 | ErrorCategory::PlanningPolicyViolation
153 | ErrorCategory::ResourceExhausted
154 )
155 }
156
157 #[must_use]
159 pub fn retryability(&self) -> Retryability {
160 match self {
161 ErrorCategory::Network | ErrorCategory::ServiceUnavailable => Retryability::Retryable {
162 max_attempts: 3,
163 backoff: BackoffStrategy::Exponential {
164 base: Duration::from_millis(500),
165 max: Duration::from_secs(10),
166 },
167 },
168 ErrorCategory::Timeout => Retryability::Retryable {
169 max_attempts: 2,
170 backoff: BackoffStrategy::Exponential {
171 base: Duration::from_millis(1000),
172 max: Duration::from_secs(15),
173 },
174 },
175 ErrorCategory::RateLimit => Retryability::Retryable {
176 max_attempts: 3,
177 backoff: BackoffStrategy::Exponential {
178 base: Duration::from_secs(1),
179 max: Duration::from_secs(30),
180 },
181 },
182 ErrorCategory::CircuitOpen => Retryability::Retryable {
183 max_attempts: 1,
184 backoff: BackoffStrategy::Fixed(Duration::from_secs(10)),
185 },
186 ErrorCategory::PermissionDenied => Retryability::RequiresIntervention,
187 _ => Retryability::NonRetryable,
188 }
189 }
190
191 #[must_use]
194 pub fn recovery_suggestions(&self) -> Vec<Cow<'static, str>> {
195 match self {
196 ErrorCategory::Network => vec![
197 Cow::Borrowed("Check network connectivity"),
198 Cow::Borrowed("Retry the operation after a brief delay"),
199 Cow::Borrowed("Verify external service availability"),
200 ],
201 ErrorCategory::Timeout => vec![
202 Cow::Borrowed("Increase timeout values if appropriate"),
203 Cow::Borrowed("Break large operations into smaller chunks"),
204 Cow::Borrowed("Check system resources and performance"),
205 ],
206 ErrorCategory::RateLimit => vec![
207 Cow::Borrowed("Wait before retrying the request"),
208 Cow::Borrowed("Reduce request frequency"),
209 Cow::Borrowed("Check provider rate limit documentation"),
210 ],
211 ErrorCategory::ServiceUnavailable => vec![
212 Cow::Borrowed("The service is temporarily unavailable"),
213 Cow::Borrowed("Retry after a brief delay"),
214 Cow::Borrowed("Check service status page if available"),
215 ],
216 ErrorCategory::CircuitOpen => vec![
217 Cow::Borrowed("This tool has been temporarily disabled due to repeated failures"),
218 Cow::Borrowed("Wait for the circuit breaker cooldown period"),
219 Cow::Borrowed("Try an alternative approach"),
220 ],
221 ErrorCategory::Authentication => vec![
222 Cow::Borrowed("Verify your API key or credentials"),
223 Cow::Borrowed("Check that your account is active and has sufficient permissions"),
224 Cow::Borrowed("Ensure environment variables for API keys are set correctly"),
225 ],
226 ErrorCategory::InvalidParameters => vec![
227 Cow::Borrowed("Check parameter names and types against the tool schema"),
228 Cow::Borrowed("Ensure required parameters are provided"),
229 Cow::Borrowed("Verify parameter values are within acceptable ranges"),
230 ],
231 ErrorCategory::ToolNotFound => vec![
232 Cow::Borrowed("Verify the tool name is spelled correctly"),
233 Cow::Borrowed("Check if the tool is available in the current context"),
234 ],
235 ErrorCategory::ResourceNotFound => vec![
236 Cow::Borrowed("Verify file paths and resource locations"),
237 Cow::Borrowed("Check if files exist and are accessible"),
238 Cow::Borrowed("Use list_dir to explore available resources"),
239 ],
240 ErrorCategory::PermissionDenied => vec![
241 Cow::Borrowed("Check file permissions and access rights"),
242 Cow::Borrowed("Ensure workspace boundaries are respected"),
243 ],
244 ErrorCategory::PolicyViolation => vec![
245 Cow::Borrowed("Review workspace policies and restrictions"),
246 Cow::Borrowed("Use alternative tools that comply with policies"),
247 ],
248 ErrorCategory::PlanningPolicyViolation => vec![
249 Cow::Borrowed("This operation is not allowed in the Planning workflow with read-only permissions"),
250 Cow::Borrowed("Exit the Planning workflow to perform mutating operations"),
251 ],
252 ErrorCategory::SandboxFailure => vec![
253 Cow::Borrowed("The sandbox denied this operation"),
254 Cow::Borrowed("Check sandbox configuration and permissions"),
255 ],
256 ErrorCategory::ResourceExhausted => vec![
257 Cow::Borrowed("Check your account usage limits and billing status"),
258 Cow::Borrowed("Review resource consumption and optimize if possible"),
259 ],
260 ErrorCategory::Cancelled => vec![Cow::Borrowed("The operation was cancelled")],
261 ErrorCategory::ExecutionError => vec![
262 Cow::Borrowed("Review error details for specific issues"),
263 Cow::Borrowed("Check tool documentation for known limitations"),
264 ],
265 }
266 }
267
268 #[must_use]
270 pub const fn user_label(&self) -> &'static str {
271 match self {
272 ErrorCategory::Network => "Network error",
273 ErrorCategory::Timeout => "Request timed out",
274 ErrorCategory::RateLimit => "Rate limit exceeded",
275 ErrorCategory::ServiceUnavailable => "Service temporarily unavailable",
276 ErrorCategory::CircuitOpen => "Tool temporarily disabled",
277 ErrorCategory::Authentication => "Authentication failed",
278 ErrorCategory::InvalidParameters => "Invalid parameters",
279 ErrorCategory::ToolNotFound => "Tool not found",
280 ErrorCategory::ResourceNotFound => "Resource not found",
281 ErrorCategory::PermissionDenied => "Permission denied",
282 ErrorCategory::PolicyViolation => "Blocked by policy",
283 ErrorCategory::PlanningPolicyViolation => "Not allowed in planning workflow",
284 ErrorCategory::SandboxFailure => "Sandbox denied",
285 ErrorCategory::ResourceExhausted => "Resource limit reached",
286 ErrorCategory::Cancelled => "Operation cancelled",
287 ErrorCategory::ExecutionError => "Execution failed",
288 }
289 }
290
291 #[must_use]
301 pub fn auth_recovery_guidance(
302 &self,
303 provider_label: &str,
304 provider_key: &str,
305 is_managed_auth: bool,
306 has_stored_credential: bool,
307 ) -> Vec<String> {
308 if !matches!(self, ErrorCategory::Authentication) {
309 return vec![];
310 }
311
312 if is_managed_auth {
313 vec![format!(
314 "Authentication failed for {provider_label}. Run /login {provider_key} to re-authenticate."
315 )]
316 } else if has_stored_credential {
317 vec![format!(
318 "Authentication failed for {provider_label}. The stored API key was rejected — run /secret add {provider_key} to replace it with a valid key."
319 )]
320 } else {
321 vec![format!(
322 "Authentication failed for {provider_label}. Run /secret add {provider_key} to store your API key in secure storage (OS keyring or encrypted file)."
323 )]
324 }
325 }
326}
327
328impl fmt::Display for ErrorCategory {
329 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330 f.write_str(self.user_label())
331 }
332}
333
334#[must_use]
344pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
345 let msg = err.to_string().to_ascii_lowercase();
346 classify_error_message(&msg)
347}
348
349#[must_use]
358pub fn is_context_capacity_error(err: &anyhow::Error) -> bool {
359 err.chain().any(|cause| is_context_capacity_message(&cause.to_string()))
360}
361
362#[must_use]
364pub fn is_context_capacity_message(message: &str) -> bool {
365 let message = message.to_ascii_lowercase();
366 contains_any(
367 &message,
368 &[
369 "context_length_exceeded",
370 "context length exceeded",
371 "maximum context length",
372 "maximum context window",
373 "context window exceeded",
374 "context window is too small",
375 "exceeds the model's maximum context",
376 "exceeds model context",
377 "exceeds the maximum context",
378 "prompt is too long",
379 "input is too long",
380 "input token count exceeds",
381 "exceeds the maximum number of tokens",
382 "maximum input tokens",
383 "too many tokens in the prompt",
384 "request exceeds the context",
385 ],
386 )
387}
388
389#[inline]
394#[must_use]
395pub fn classify_error_message(msg: &str) -> ErrorCategory {
396 let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
397 Cow::Owned(msg.to_ascii_lowercase())
398 } else {
399 Cow::Borrowed(msg)
400 };
401
402 if contains_any(
404 &msg,
405 &[
406 "policy violation",
407 "denied by policy",
408 "tool permission denied",
409 "safety validation failed",
410 "not allowed in planning workflow",
411 "only available when planning workflow is active",
412 "workspace boundary",
413 "blocked by policy",
414 ],
415 ) {
416 return ErrorCategory::PolicyViolation;
417 }
418
419 if contains_any(
421 &msg,
422 &[
423 "planning workflow",
424 "read-only permissions",
425 concat!("read-only ", "mode"),
426 "planning_policy_violation",
427 ],
428 ) {
429 return ErrorCategory::PlanningPolicyViolation;
430 }
431
432 if contains_any(
434 &msg,
435 &[
436 "invalid api key",
437 "authentication failed",
438 "unauthorized",
439 "401",
440 "invalid credentials",
441 ],
442 ) {
443 return ErrorCategory::Authentication;
444 }
445
446 if contains_any(
448 &msg,
449 &[
450 "weekly usage limit",
451 "daily usage limit",
452 "monthly spending limit",
453 "insufficient credits",
454 "quota exceeded",
455 "billing",
456 "payment required",
457 ],
458 ) {
459 return ErrorCategory::ResourceExhausted;
460 }
461
462 if contains_any(
464 &msg,
465 &[
466 "invalid argument",
467 "invalid parameters",
468 "invalid type",
469 "malformed",
470 "failed to parse arguments",
471 "failed to parse argument",
472 "missing required",
473 "at least one item is required",
474 "is required for",
475 "schema validation",
476 "argument validation failed",
477 "unknown field",
478 "unknown variant",
479 "expected struct",
480 "expected enum",
481 "type mismatch",
482 "must be an absolute path",
483 "not parseable",
484 "parseable as",
485 "invalid patch format",
493 "invalid patch hunk",
494 "invalid patch operation",
495 "cannot parse empty patch",
496 "patch does not contain",
497 "semantic patch anchor",
498 ],
499 ) {
500 return ErrorCategory::InvalidParameters;
501 }
502
503 if contains_any(&msg, &["tool not found", "unknown tool", "unsupported tool", "no such tool"]) {
505 return ErrorCategory::ToolNotFound;
506 }
507
508 if contains_any(
510 &msg,
511 &[
512 "no such file",
513 "no such directory",
514 "file not found",
515 "directory not found",
516 "resource not found",
517 "path not found",
518 "does not exist",
519 "enoent",
520 ],
521 ) {
522 return ErrorCategory::ResourceNotFound;
523 }
524
525 if contains_any(
527 &msg,
528 &[
529 "permission denied",
530 "access denied",
531 "operation not permitted",
532 "eacces",
533 "eperm",
534 "forbidden",
535 "403",
536 ],
537 ) {
538 return ErrorCategory::PermissionDenied;
539 }
540
541 if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
543 return ErrorCategory::Cancelled;
544 }
545
546 if contains_any(&msg, &["circuit breaker", "circuit open"]) {
548 return ErrorCategory::CircuitOpen;
549 }
550
551 if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
553 return ErrorCategory::SandboxFailure;
554 }
555
556 if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
558 return ErrorCategory::RateLimit;
559 }
560
561 if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
563 return ErrorCategory::Timeout;
564 }
565
566 if contains_any(
568 &msg,
569 &[
570 "invalid response format: missing choices",
571 "invalid response format: missing message",
572 "missing choices in response",
573 "missing message in choice",
574 "no choices in response",
575 "invalid response from ",
576 "empty response body",
577 "response did not contain",
578 "unexpected response format",
579 "failed to parse response",
580 ],
581 ) {
582 return ErrorCategory::ServiceUnavailable;
583 }
584
585 if contains_any(
587 &msg,
588 &[
589 "service unavailable",
590 "temporarily unavailable",
591 "internal server error",
592 "bad gateway",
593 "gateway timeout",
594 "overloaded",
595 "500",
596 "502",
597 "503",
598 "504",
599 ],
600 ) {
601 return ErrorCategory::ServiceUnavailable;
602 }
603
604 if contains_any(
606 &msg,
607 &[
608 "network",
609 "connection reset",
610 "connection refused",
611 "broken pipe",
612 "dns",
613 "name resolution",
614 "try again",
615 "retry later",
616 "upstream connect error",
617 "tls handshake",
618 "socket hang up",
619 "econnreset",
620 "etimedout",
621 "error decoding response body",
625 ],
626 ) {
627 return ErrorCategory::Network;
628 }
629
630 if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
632 return ErrorCategory::ResourceExhausted;
633 }
634
635 ErrorCategory::ExecutionError
637}
638
639#[inline]
644#[must_use]
645pub fn is_retryable_llm_error_message(msg: &str) -> bool {
646 let category = classify_error_message(msg);
647 category.is_retryable()
648}
649
650#[inline]
651fn contains_any(message: &str, markers: &[&str]) -> bool {
652 markers.iter().any(|marker| message.contains(marker))
653}
654
655impl From<&crate::llm::LLMError> for ErrorCategory {
660 fn from(err: &crate::llm::LLMError) -> Self {
661 match err {
662 crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
663 crate::llm::LLMError::RateLimit { metadata } => {
664 classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
665 }
666 crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
667 crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
668 crate::llm::LLMError::Provider { message, metadata } => {
669 let metadata_category = classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
670 if metadata_category != ErrorCategory::ExecutionError {
671 return metadata_category;
672 }
673
674 if let Some(meta) = metadata
676 && let Some(status) = meta.status
677 {
678 return match status {
679 401 => ErrorCategory::Authentication,
680 403 => ErrorCategory::PermissionDenied,
681 404 => ErrorCategory::ResourceNotFound,
682 429 => ErrorCategory::RateLimit,
683 400 => ErrorCategory::InvalidParameters,
684 500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
685 408 => ErrorCategory::Timeout,
686 _ => classify_error_message(message),
687 };
688 }
689 classify_error_message(message)
691 }
692 }
693 }
694}
695
696fn classify_llm_metadata(metadata: Option<&crate::llm::LLMErrorMetadata>, fallback: ErrorCategory) -> ErrorCategory {
697 let Some(metadata) = metadata else {
698 return fallback;
699 };
700
701 let mut hint = String::new();
702 if let Some(code) = &metadata.code {
703 hint.push_str(code);
704 hint.push(' ');
705 }
706 if let Some(message) = &metadata.message {
707 hint.push_str(message);
708 hint.push(' ');
709 }
710 if let Some(status) = metadata.status {
711 let _ = write!(&mut hint, "{status}");
712 }
713
714 let classified = classify_error_message(&hint);
715 if classified == ErrorCategory::ExecutionError {
716 fallback
717 } else {
718 classified
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 #[test]
729 fn policy_violation_takes_priority_over_permission() {
730 assert_eq!(classify_error_message("tool permission denied by policy"), ErrorCategory::PolicyViolation);
731 }
732
733 #[test]
734 fn rate_limit_classified_correctly() {
735 assert_eq!(classify_error_message("provider returned 429 Too Many Requests"), ErrorCategory::RateLimit);
736 assert_eq!(classify_error_message("rate limit exceeded"), ErrorCategory::RateLimit);
737 }
738
739 #[test]
740 fn service_unavailable_is_classified() {
741 assert_eq!(classify_error_message("503 service unavailable"), ErrorCategory::ServiceUnavailable);
742 }
743
744 #[test]
745 fn authentication_errors() {
746 assert_eq!(classify_error_message("invalid api key provided"), ErrorCategory::Authentication);
747 assert_eq!(classify_error_message("401 unauthorized"), ErrorCategory::Authentication);
748 }
749
750 #[test]
751 fn billing_errors_are_resource_exhausted() {
752 assert_eq!(
753 classify_error_message("you have reached your weekly usage limit"),
754 ErrorCategory::ResourceExhausted
755 );
756 assert_eq!(classify_error_message("quota exceeded for this model"), ErrorCategory::ResourceExhausted);
757 }
758
759 #[test]
760 fn timeout_errors() {
761 assert_eq!(classify_error_message("connection timeout"), ErrorCategory::Timeout);
762 assert_eq!(classify_error_message("request timed out after 30s"), ErrorCategory::Timeout);
763 }
764
765 #[test]
766 fn network_errors() {
767 assert_eq!(classify_error_message("connection reset by peer"), ErrorCategory::Network);
768 assert_eq!(classify_error_message("dns name resolution failed"), ErrorCategory::Network);
769 }
770
771 #[test]
772 fn tool_not_found() {
773 assert_eq!(classify_error_message("unknown tool: ask_questions"), ErrorCategory::ToolNotFound);
774 }
775
776 #[test]
777 fn resource_not_found() {
778 assert_eq!(classify_error_message("no such file or directory: /tmp/missing"), ErrorCategory::ResourceNotFound);
779 assert_eq!(
780 classify_error_message("Path 'crates/codegen/vtcode-core/src/agent' does not exist"),
781 ErrorCategory::ResourceNotFound
782 );
783 }
784
785 #[test]
786 fn patch_format_errors_are_invalid_parameters() {
787 assert_eq!(
792 classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
793 ErrorCategory::InvalidParameters
794 );
795 assert_eq!(
796 classify_error_message("invalid patch format: input looks like a standard unified diff (---/+++ format)"),
797 ErrorCategory::InvalidParameters
798 );
799 assert_eq!(
800 classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
801 ErrorCategory::InvalidParameters
802 );
803 assert_eq!(classify_error_message("cannot parse empty patch input"), ErrorCategory::InvalidParameters);
804 assert_eq!(classify_error_message("patch does not contain any operations"), ErrorCategory::InvalidParameters);
805 assert_eq!(
806 classify_error_message("semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"),
807 ErrorCategory::InvalidParameters
808 );
809 }
810
811 #[test]
812 fn permission_denied() {
813 assert_eq!(classify_error_message("permission denied: /etc/shadow"), ErrorCategory::PermissionDenied);
814 }
815
816 #[test]
817 fn cancelled_operations() {
818 assert_eq!(classify_error_message("operation cancelled by user"), ErrorCategory::Cancelled);
819 }
820
821 #[test]
822 fn planning_policy_violation() {
823 assert_eq!(classify_error_message("not allowed in planning workflow"), ErrorCategory::PolicyViolation);
824 }
825
826 #[test]
827 fn sandbox_failure() {
828 assert_eq!(classify_error_message("sandbox denied this operation"), ErrorCategory::SandboxFailure);
829 }
830
831 #[test]
832 fn unknown_error_is_execution_error() {
833 assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
834 }
835
836 #[test]
837 fn invalid_parameters() {
838 assert_eq!(classify_error_message("invalid argument: missing path field"), ErrorCategory::InvalidParameters);
839 assert_eq!(
840 classify_error_message("Failed to parse arguments for read_file handler: invalid type: boolean `false`"),
841 ErrorCategory::InvalidParameters
842 );
843 assert_eq!(
844 classify_error_message("at least one item is required for 'create'"),
845 ErrorCategory::InvalidParameters
846 );
847 assert_eq!(
848 classify_error_message("structural pattern preflight failed: pattern is not parseable as Rust syntax"),
849 ErrorCategory::InvalidParameters
850 );
851 }
852
853 #[test]
856 fn retryable_categories() {
857 assert!(ErrorCategory::Network.is_retryable());
858 assert!(ErrorCategory::Timeout.is_retryable());
859 assert!(ErrorCategory::RateLimit.is_retryable());
860 assert!(ErrorCategory::ServiceUnavailable.is_retryable());
861 assert!(ErrorCategory::CircuitOpen.is_retryable());
862 }
863
864 #[test]
865 fn non_retryable_categories() {
866 assert!(!ErrorCategory::Authentication.is_retryable());
867 assert!(!ErrorCategory::InvalidParameters.is_retryable());
868 assert!(!ErrorCategory::PolicyViolation.is_retryable());
869 assert!(!ErrorCategory::ResourceExhausted.is_retryable());
870 assert!(!ErrorCategory::Cancelled.is_retryable());
871 }
872
873 #[test]
874 fn permanent_error_detection() {
875 assert!(ErrorCategory::Authentication.is_permanent());
876 assert!(ErrorCategory::PolicyViolation.is_permanent());
877 assert!(!ErrorCategory::Network.is_permanent());
878 assert!(!ErrorCategory::Timeout.is_permanent());
879 }
880
881 #[test]
882 fn llm_mistake_detection() {
883 assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
884 assert!(!ErrorCategory::Network.is_llm_mistake());
885 assert!(!ErrorCategory::Timeout.is_llm_mistake());
886 }
887
888 #[test]
891 fn llm_error_authentication_converts() {
892 let err = crate::llm::LLMError::Authentication { message: "bad key".to_string(), metadata: None };
893 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
894 }
895
896 #[test]
897 fn llm_error_rate_limit_converts() {
898 let err = crate::llm::LLMError::RateLimit { metadata: None };
899 assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
900 }
901
902 #[test]
903 fn llm_error_quota_exhaustion_converts() {
904 let err = crate::llm::LLMError::RateLimit {
905 metadata: Some(crate::llm::LLMErrorMetadata::new(
906 "openai",
907 Some(429),
908 Some("insufficient_quota".to_string()),
909 None,
910 None,
911 None,
912 Some("quota exceeded".to_string()),
913 )),
914 };
915
916 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
917 }
918
919 #[test]
920 fn llm_error_network_converts() {
921 let err = crate::llm::LLMError::Network {
922 message: "connection refused".to_string(),
923 metadata: None,
924 };
925 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
926 }
927
928 #[test]
929 fn llm_error_provider_with_status_code() {
930 use crate::llm::LLMErrorMetadata;
931 let err = crate::llm::LLMError::Provider {
932 message: "error".to_string(),
933 metadata: Some(LLMErrorMetadata::new("openai", Some(503), None, None, None, None, None)),
934 };
935 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
936 }
937
938 #[test]
939 fn minimax_invalid_response_is_service_unavailable() {
940 assert_eq!(
941 classify_error_message("Invalid response from MiniMax: missing choices"),
942 ErrorCategory::ServiceUnavailable
943 );
944 assert_eq!(
945 classify_error_message("Invalid response format: missing message"),
946 ErrorCategory::ServiceUnavailable
947 );
948 }
949
950 #[test]
953 fn retryable_llm_messages() {
954 assert!(is_retryable_llm_error_message("429 too many requests"));
955 assert!(is_retryable_llm_error_message("500 internal server error"));
956 assert!(is_retryable_llm_error_message("connection timeout"));
957 assert!(is_retryable_llm_error_message("network error"));
958 }
959
960 #[test]
961 fn non_retryable_llm_messages() {
962 assert!(!is_retryable_llm_error_message("invalid api key"));
963 assert!(!is_retryable_llm_error_message("weekly usage limit reached"));
964 assert!(!is_retryable_llm_error_message("permission denied"));
965 }
966
967 #[test]
968 fn context_capacity_markers_are_specific() {
969 assert!(is_context_capacity_message("invalid request: maximum context length is 114688 tokens"));
970 assert!(is_context_capacity_message("input token count exceeds the maximum number of tokens allowed"));
971 assert!(!is_context_capacity_message("invalid request: context field is missing"));
972 }
973
974 #[test]
977 fn recovery_suggestions_non_empty() {
978 for cat in [
979 ErrorCategory::Network,
980 ErrorCategory::Timeout,
981 ErrorCategory::RateLimit,
982 ErrorCategory::Authentication,
983 ErrorCategory::InvalidParameters,
984 ErrorCategory::ToolNotFound,
985 ErrorCategory::ResourceNotFound,
986 ErrorCategory::PermissionDenied,
987 ErrorCategory::PolicyViolation,
988 ErrorCategory::ExecutionError,
989 ] {
990 assert!(!cat.recovery_suggestions().is_empty(), "Missing recovery suggestions for {cat:?}");
991 }
992 }
993
994 #[test]
997 fn user_labels_are_non_empty() {
998 assert!(!ErrorCategory::Network.user_label().is_empty());
999 assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
1000 }
1001
1002 #[test]
1005 fn auth_recovery_guidance_no_credential_mentions_secret_add() {
1006 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, false);
1007 assert_eq!(guidance.len(), 1);
1008 assert_eq!(
1009 guidance[0],
1010 "Authentication failed for StepFun. Run /secret add stepfun to store your API key in secure storage (OS keyring or encrypted file)."
1011 );
1012 }
1013
1014 #[test]
1015 fn auth_recovery_guidance_credential_stored_mentions_overwrite() {
1016 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, true);
1017 assert_eq!(guidance.len(), 1);
1018 assert_eq!(
1019 guidance[0],
1020 "Authentication failed for StepFun. The stored API key was rejected — run /secret add stepfun to replace it with a valid key."
1021 );
1022 }
1023
1024 #[test]
1025 fn auth_recovery_guidance_managed_auth_provider_mentions_login() {
1026 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("GitHub Copilot", "copilot", true, false);
1027 assert_eq!(guidance.len(), 1);
1028 assert_eq!(guidance[0], "Authentication failed for GitHub Copilot. Run /login copilot to re-authenticate.");
1029 }
1030
1031 #[test]
1032 fn auth_recovery_guidance_non_auth_category_returns_empty() {
1033 assert!(
1034 ErrorCategory::Network
1035 .auth_recovery_guidance("OpenAI", "openai", false, false)
1036 .is_empty()
1037 );
1038 assert!(
1039 ErrorCategory::Timeout
1040 .auth_recovery_guidance("OpenAI", "openai", false, false)
1041 .is_empty()
1042 );
1043 }
1044
1045 #[test]
1048 fn display_matches_user_label() {
1049 assert_eq!(format!("{}", ErrorCategory::RateLimit), ErrorCategory::RateLimit.user_label());
1050 }
1051}