1use crate::provider_metadata::{canonical_provider_id, provider_auth_env_keys};
4use std::sync::OnceLock;
5use thiserror::Error;
6
7pub type Result<T> = std::result::Result<T, Error>;
9
10#[derive(Error, Debug)]
12pub enum Error {
13 #[error("Configuration error: {0}")]
15 Config(String),
16
17 #[error("Session error: {0}")]
19 Session(String),
20
21 #[error("Session not found: {path}")]
23 SessionNotFound { path: String },
24
25 #[error("Provider error: {provider}: {message}")]
27 Provider { provider: String, message: String },
28
29 #[error("Authentication error: {0}")]
31 Auth(String),
32
33 #[error("Tool error: {tool}: {message}")]
35 Tool { tool: String, message: String },
36
37 #[error("Validation error: {0}")]
39 Validation(String),
40
41 #[error("Extension error: {0}")]
43 Extension(String),
44
45 #[error("IO error: {0}")]
47 Io(#[from] Box<std::io::Error>),
48
49 #[error("JSON error: {0}")]
51 Json(#[from] Box<serde_json::Error>),
52
53 #[error("SQLite error: {0}")]
55 Sqlite(#[from] Box<sqlmodel_core::Error>),
56
57 #[error("Operation aborted")]
59 Aborted,
60
61 #[error("API error: {0}")]
63 Api(String),
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum AuthDiagnosticCode {
69 MissingApiKey,
70 InvalidApiKey,
71 QuotaExceeded,
72 MissingOAuthAuthorizationCode,
73 OAuthTokenExchangeFailed,
74 OAuthTokenRefreshFailed,
75 MissingAzureDeployment,
76 MissingRegion,
77 MissingProject,
78 MissingProfile,
79 MissingEndpoint,
80 MissingCredentialChain,
81 UnknownAuthFailure,
82}
83
84impl AuthDiagnosticCode {
85 #[must_use]
86 pub const fn as_str(self) -> &'static str {
87 match self {
88 Self::MissingApiKey => "auth.missing_api_key",
89 Self::InvalidApiKey => "auth.invalid_api_key",
90 Self::QuotaExceeded => "auth.quota_exceeded",
91 Self::MissingOAuthAuthorizationCode => "auth.oauth.missing_authorization_code",
92 Self::OAuthTokenExchangeFailed => "auth.oauth.token_exchange_failed",
93 Self::OAuthTokenRefreshFailed => "auth.oauth.token_refresh_failed",
94 Self::MissingAzureDeployment => "config.azure.missing_deployment",
95 Self::MissingRegion => "config.auth.missing_region",
96 Self::MissingProject => "config.auth.missing_project",
97 Self::MissingProfile => "config.auth.missing_profile",
98 Self::MissingEndpoint => "config.auth.missing_endpoint",
99 Self::MissingCredentialChain => "auth.credential_chain.missing",
100 Self::UnknownAuthFailure => "auth.unknown_failure",
101 }
102 }
103
104 #[must_use]
105 pub const fn remediation(self) -> &'static str {
106 match self {
107 Self::MissingApiKey => "Set the provider API key env var or run `/login <provider>`.",
108 Self::InvalidApiKey => "Rotate or replace the API key and verify provider permissions.",
109 Self::QuotaExceeded => {
110 "Verify billing/quota limits for this API key or organization, then retry."
111 }
112 Self::MissingOAuthAuthorizationCode => {
113 "Re-run `/login` and paste a full callback URL or authorization code."
114 }
115 Self::OAuthTokenExchangeFailed => {
116 "Retry login flow and verify token endpoint/client configuration."
117 }
118 Self::OAuthTokenRefreshFailed => {
119 "Re-authenticate with `/login` and confirm refresh-token validity."
120 }
121 Self::MissingAzureDeployment => {
122 "Configure Azure resource+deployment in models.json before dispatch."
123 }
124 Self::MissingRegion => "Set provider region/cluster configuration before retrying.",
125 Self::MissingProject => "Set provider project/workspace identifier before retrying.",
126 Self::MissingProfile => "Set credential profile/source configuration before retrying.",
127 Self::MissingEndpoint => "Configure provider base URL/endpoint in models.json.",
128 Self::MissingCredentialChain => {
129 "Configure credential-chain sources (env/profile/role) before retrying."
130 }
131 Self::UnknownAuthFailure => {
132 "Inspect auth diagnostics and retry with explicit credentials."
133 }
134 }
135 }
136
137 #[must_use]
138 pub const fn redaction_policy(self) -> &'static str {
139 match self {
140 Self::MissingApiKey
141 | Self::InvalidApiKey
142 | Self::QuotaExceeded
143 | Self::MissingOAuthAuthorizationCode
144 | Self::OAuthTokenExchangeFailed
145 | Self::OAuthTokenRefreshFailed
146 | Self::MissingAzureDeployment
147 | Self::MissingRegion
148 | Self::MissingProject
149 | Self::MissingProfile
150 | Self::MissingEndpoint
151 | Self::MissingCredentialChain
152 | Self::UnknownAuthFailure => "redact-secrets",
153 }
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub struct AuthDiagnostic {
160 pub code: AuthDiagnosticCode,
161 pub remediation: &'static str,
162 pub redaction_policy: &'static str,
163}
164
165impl Error {
166 pub fn config(message: impl Into<String>) -> Self {
168 Self::Config(message.into())
169 }
170
171 pub fn session(message: impl Into<String>) -> Self {
173 Self::Session(message.into())
174 }
175
176 pub fn provider(provider: impl Into<String>, message: impl Into<String>) -> Self {
178 Self::Provider {
179 provider: provider.into(),
180 message: message.into(),
181 }
182 }
183
184 pub fn auth(message: impl Into<String>) -> Self {
186 Self::Auth(message.into())
187 }
188
189 pub fn tool(tool: impl Into<String>, message: impl Into<String>) -> Self {
191 Self::Tool {
192 tool: tool.into(),
193 message: message.into(),
194 }
195 }
196
197 pub fn validation(message: impl Into<String>) -> Self {
199 Self::Validation(message.into())
200 }
201
202 pub fn extension(message: impl Into<String>) -> Self {
204 Self::Extension(message.into())
205 }
206
207 pub fn api(message: impl Into<String>) -> Self {
209 Self::Api(message.into())
210 }
211
212 pub fn sse(err: &std::io::Error) -> Self {
224 let base = format!("SSE error: {err}");
225 if io_kind_is_transient(err.kind()) {
226 Self::Api(format!("{base} (transient connection drop)"))
227 } else {
228 Self::Api(base)
229 }
230 }
231
232 #[must_use]
245 pub fn is_transient(&self) -> bool {
246 if let Self::Io(io_err) = self {
248 if io_error_chain_is_transient(io_err) {
249 return true;
250 }
251 }
252 let mut source = std::error::Error::source(self);
255 while let Some(cause) = source {
256 if let Some(io_err) = cause.downcast_ref::<std::io::Error>() {
257 if io_error_chain_is_transient(io_err) {
258 return true;
259 }
260 }
261 source = cause.source();
262 }
263 false
264 }
265
266 pub const fn hostcall_error_code(&self) -> &'static str {
271 match self {
272 Self::Validation(_) => "invalid_request",
273 Self::Io(_) | Self::Session(_) | Self::SessionNotFound { .. } | Self::Sqlite(_) => "io",
274 Self::Auth(_) => "denied",
275 Self::Aborted => "timeout",
276 Self::Json(_)
277 | Self::Extension(_)
278 | Self::Config(_)
279 | Self::Provider { .. }
280 | Self::Tool { .. }
281 | Self::Api(_) => "internal",
282 }
283 }
284
285 #[must_use]
287 pub const fn category_code(&self) -> &'static str {
288 match self {
289 Self::Config(_) => "config",
290 Self::Session(_) | Self::SessionNotFound { .. } => "session",
291 Self::Provider { .. } => "provider",
292 Self::Auth(_) => "auth",
293 Self::Tool { .. } => "tool",
294 Self::Validation(_) => "validation",
295 Self::Extension(_) => "extension",
296 Self::Io(_) => "io",
297 Self::Json(_) => "json",
298 Self::Sqlite(_) => "sqlite",
299 Self::Aborted => "runtime",
300 Self::Api(_) => "api",
301 }
302 }
303
304 #[must_use]
306 pub fn auth_diagnostic(&self) -> Option<AuthDiagnostic> {
307 match self {
308 Self::Auth(message) => classify_auth_diagnostic(None, message),
309 Self::Provider { provider, message } => {
310 classify_auth_diagnostic(Some(provider.as_str()), message)
311 }
312 _ => None,
313 }
314 }
315
316 #[must_use]
318 pub fn hints(&self) -> ErrorHints {
319 let mut hints = match self {
320 Self::Config(message) => config_hints(message),
321 Self::Session(message) => session_hints(message),
322 Self::SessionNotFound { path } => build_hints(
323 "Session file not found.",
324 vec![
325 "Use `pi --continue` to open the most recent session.".to_string(),
326 "Verify the path or move the session back into the sessions directory."
327 .to_string(),
328 ],
329 vec![("path", path.clone())],
330 ),
331 Self::Provider { provider, message } => provider_hints(provider, message),
332 Self::Auth(message) => auth_hints(message),
333 Self::Tool { tool, message } => tool_hints(tool, message),
334 Self::Validation(message) => build_hints(
335 "Validation failed for input or config.",
336 vec![
337 "Check the specific fields mentioned in the error.".to_string(),
338 "Review CLI flags or settings for typos.".to_string(),
339 ],
340 vec![("details", message.clone())],
341 ),
342 Self::Extension(message) => build_hints(
343 "Extension failed to load or run.",
344 vec![
345 "Try `--no-extensions` to isolate the issue.".to_string(),
346 "Check the extension manifest and dependencies.".to_string(),
347 ],
348 vec![("details", message.clone())],
349 ),
350 Self::Io(err) => io_hints(err),
351 Self::Json(err) => build_hints(
352 "JSON parsing failed.",
353 vec![
354 "Validate the JSON syntax (no trailing commas).".to_string(),
355 "Check that the file is UTF-8 and not truncated.".to_string(),
356 ],
357 vec![("details", err.to_string())],
358 ),
359 Self::Sqlite(err) => sqlite_hints(err),
360 Self::Aborted => build_hints(
361 "Operation aborted.",
362 Vec::new(),
363 vec![(
364 "details",
365 "Operation cancelled by user or runtime.".to_string(),
366 )],
367 ),
368 Self::Api(message) => build_hints(
369 "API request failed.",
370 vec![
371 "Check your network connection and retry.".to_string(),
372 "Verify your API key and provider selection.".to_string(),
373 ],
374 vec![("details", message.clone())],
375 ),
376 };
377
378 hints.context.push((
379 "error_category".to_string(),
380 self.category_code().to_string(),
381 ));
382
383 if let Some(diagnostic) = self.auth_diagnostic() {
384 hints.context.push((
385 "diagnostic_code".to_string(),
386 diagnostic.code.as_str().to_string(),
387 ));
388 hints.context.push((
389 "diagnostic_remediation".to_string(),
390 diagnostic.remediation.to_string(),
391 ));
392 hints.context.push((
393 "redaction_policy".to_string(),
394 diagnostic.redaction_policy.to_string(),
395 ));
396 }
397
398 hints
399 }
400}
401
402#[derive(Debug, Clone)]
404pub struct ErrorHints {
405 pub summary: String,
407 pub hints: Vec<String>,
409 pub context: Vec<(String, String)>,
411}
412
413fn build_hints(summary: &str, hints: Vec<String>, context: Vec<(&str, String)>) -> ErrorHints {
414 ErrorHints {
415 summary: summary.to_string(),
416 hints,
417 context: context
418 .into_iter()
419 .map(|(label, value)| (label.to_string(), value))
420 .collect(),
421 }
422}
423
424fn contains_any(haystack: &str, needles: &[&str]) -> bool {
425 needles.iter().any(|needle| haystack.contains(needle))
426}
427
428const fn build_auth_diagnostic(code: AuthDiagnosticCode) -> AuthDiagnostic {
429 AuthDiagnostic {
430 code,
431 remediation: code.remediation(),
432 redaction_policy: code.redaction_policy(),
433 }
434}
435
436#[allow(clippy::too_many_lines)]
437fn classify_auth_diagnostic(provider: Option<&str>, message: &str) -> Option<AuthDiagnostic> {
438 let lower = message.to_lowercase();
439 let provider_lower = provider.map(str::to_lowercase);
440 if contains_any(
441 &lower,
442 &[
443 "missing authorization code",
444 "authorization code is missing",
445 ],
446 ) {
447 return Some(build_auth_diagnostic(
448 AuthDiagnosticCode::MissingOAuthAuthorizationCode,
449 ));
450 }
451 if contains_any(&lower, &["token exchange failed", "invalid token response"]) {
452 return Some(build_auth_diagnostic(
453 AuthDiagnosticCode::OAuthTokenExchangeFailed,
454 ));
455 }
456 if contains_any(
457 &lower,
458 &[
459 "token refresh failed",
460 "oauth token refresh failed",
461 "refresh token",
462 ],
463 ) {
464 return Some(build_auth_diagnostic(
465 AuthDiagnosticCode::OAuthTokenRefreshFailed,
466 ));
467 }
468 if contains_any(
469 &lower,
470 &[
471 "missing api key",
472 "api key not configured",
473 "api key is required",
474 "you didn't provide an api key",
475 "no api key provided",
476 "missing bearer",
477 "authorization header missing",
478 ],
479 ) {
480 return Some(build_auth_diagnostic(AuthDiagnosticCode::MissingApiKey));
481 }
482 if contains_any(
483 &lower,
484 &[
485 "insufficient_quota",
486 "quota exceeded",
487 "quota has been exceeded",
488 "billing hard limit",
489 "billing_not_active",
490 "not enough credits",
491 "credit balance is too low",
492 ],
493 ) {
494 return Some(build_auth_diagnostic(AuthDiagnosticCode::QuotaExceeded));
495 }
496 if contains_any(
497 &lower,
498 &[
499 "401",
500 "unauthorized",
501 "403",
502 "forbidden",
503 "invalid api key",
504 "incorrect api key",
505 "malformed api key",
506 "api key is malformed",
507 "revoked",
508 "deactivated",
509 "disabled api key",
510 "expired api key",
511 ],
512 ) {
513 return Some(build_auth_diagnostic(AuthDiagnosticCode::InvalidApiKey));
514 }
515 if contains_any(&lower, &["resource+deployment", "missing deployment"]) {
516 return Some(build_auth_diagnostic(
517 AuthDiagnosticCode::MissingAzureDeployment,
518 ));
519 }
520 if contains_any(&lower, &["missing region", "region is required"]) {
521 return Some(build_auth_diagnostic(AuthDiagnosticCode::MissingRegion));
522 }
523 if contains_any(&lower, &["missing project", "project is required"]) {
524 return Some(build_auth_diagnostic(AuthDiagnosticCode::MissingProject));
525 }
526 if contains_any(&lower, &["missing profile", "profile is required"]) {
527 return Some(build_auth_diagnostic(AuthDiagnosticCode::MissingProfile));
528 }
529 if contains_any(
530 &lower,
531 &[
532 "missing endpoint",
533 "missing base url",
534 "base url is required",
535 ],
536 ) {
537 return Some(build_auth_diagnostic(AuthDiagnosticCode::MissingEndpoint));
538 }
539 if contains_any(
540 &lower,
541 &[
542 "credential chain",
543 "aws_access_key_id",
544 "credential source",
545 "missing credentials",
546 ],
547 ) || provider_lower
548 .as_deref()
549 .is_some_and(|provider_id| provider_id.contains("bedrock") && lower.contains("credential"))
550 {
551 return Some(build_auth_diagnostic(
552 AuthDiagnosticCode::MissingCredentialChain,
553 ));
554 }
555
556 if lower.contains("oauth")
557 || lower.contains("authentication")
558 || lower.contains("credential")
559 || lower.contains("api key")
560 {
561 return Some(build_auth_diagnostic(
562 AuthDiagnosticCode::UnknownAuthFailure,
563 ));
564 }
565
566 None
567}
568
569fn config_hints(message: &str) -> ErrorHints {
570 let lower = message.to_lowercase();
571 if contains_any(&lower, &["json", "parse", "serde"]) {
572 return build_hints(
573 "Configuration file is not valid JSON.",
574 vec![
575 "Fix JSON formatting in the active settings file.".to_string(),
576 "Run `pi config` to see which settings file is in use.".to_string(),
577 ],
578 vec![("details", message.to_string())],
579 );
580 }
581 if contains_any(&lower, &["missing", "not found", "no such file"]) {
582 return build_hints(
583 "Configuration file is missing.",
584 vec![
585 "Create `~/.pi/agent/settings.json` or set `PI_CONFIG_PATH`.".to_string(),
586 "Run `pi config` to confirm the resolved path.".to_string(),
587 ],
588 vec![("details", message.to_string())],
589 );
590 }
591 build_hints(
592 "Configuration error.",
593 vec![
594 "Review your settings file for incorrect values.".to_string(),
595 "Run `pi config` to verify settings precedence.".to_string(),
596 ],
597 vec![("details", message.to_string())],
598 )
599}
600
601fn session_hints(message: &str) -> ErrorHints {
602 let lower = message.to_lowercase();
603 if contains_any(&lower, &["empty session file", "empty session"]) {
604 return build_hints(
605 "Session file is empty or corrupted.",
606 vec![
607 "Start a new session with `pi --no-session`.".to_string(),
608 "Inspect the session file for truncation.".to_string(),
609 ],
610 vec![("details", message.to_string())],
611 );
612 }
613 if contains_any(&lower, &["failed to read", "read dir", "read session"]) {
614 return build_hints(
615 "Failed to read session data.",
616 vec![
617 "Check file permissions for the sessions directory.".to_string(),
618 "Verify `PI_SESSIONS_DIR` if you set it.".to_string(),
619 ],
620 vec![("details", message.to_string())],
621 );
622 }
623 build_hints(
624 "Session error.",
625 vec![
626 "Try `pi --continue` or specify `--session <path>`.".to_string(),
627 "Check session file integrity in the sessions directory.".to_string(),
628 ],
629 vec![("details", message.to_string())],
630 )
631}
632
633#[allow(clippy::too_many_lines)]
634fn provider_hints(provider: &str, message: &str) -> ErrorHints {
635 let lower = message.to_lowercase();
636 let key_hint = provider_key_hint(provider);
637 let context = vec![
638 ("provider", provider.to_string()),
639 ("details", message.to_string()),
640 ];
641
642 if contains_any(
643 &lower,
644 &[
645 "missing api key",
646 "you didn't provide an api key",
647 "no api key provided",
648 "authorization header missing",
649 ],
650 ) {
651 return build_hints(
652 "Provider API key is missing.",
653 vec![
654 key_hint,
655 "Set the API key and retry the request.".to_string(),
656 ],
657 context,
658 );
659 }
660 if contains_any(
661 &lower,
662 &["401", "unauthorized", "invalid api key", "api key"],
663 ) {
664 return build_hints(
665 "Provider authentication failed.",
666 vec![key_hint, "If using OAuth, run `/login` again.".to_string()],
667 context,
668 );
669 }
670 if contains_any(&lower, &["403", "forbidden"]) {
671 return build_hints(
672 "Provider access forbidden.",
673 vec![
674 "Verify the account has access to the requested model.".to_string(),
675 "Check organization/project permissions for the API key.".to_string(),
676 ],
677 context,
678 );
679 }
680 if contains_any(
681 &lower,
682 &[
683 "insufficient_quota",
684 "quota exceeded",
685 "quota has been exceeded",
686 "billing hard limit",
687 "billing_not_active",
688 "not enough credits",
689 "credit balance is too low",
690 ],
691 ) {
692 return build_hints(
693 "Provider quota or billing limit reached.",
694 vec![
695 "Verify billing/credits and organization quota for this API key.".to_string(),
696 key_hint,
697 ],
698 context,
699 );
700 }
701 if contains_any(&lower, &["429", "rate limit", "too many requests"]) {
702 return build_hints(
703 "Provider rate limited the request.",
704 vec![
705 "Wait and retry, or reduce request rate.".to_string(),
706 "Consider smaller max_tokens to lower load.".to_string(),
707 ],
708 context,
709 );
710 }
711 if contains_any(&lower, &["529", "overloaded"]) {
712 return build_hints(
713 "Provider is overloaded.",
714 vec![
715 "Retry after a short delay.".to_string(),
716 "Switch to a different model if available.".to_string(),
717 ],
718 context,
719 );
720 }
721 if contains_any(&lower, &["timeout", "timed out"]) {
722 return build_hints(
723 "Provider request timed out.",
724 vec![
725 "Check network stability and retry.".to_string(),
726 "Lower max_tokens to shorten responses.".to_string(),
727 ],
728 context,
729 );
730 }
731 if contains_any(&lower, &["400", "bad request", "invalid request"]) {
732 return build_hints(
733 "Provider rejected the request.",
734 vec![
735 "Check model name, tools schema, and request size.".to_string(),
736 "Reduce message size or tool payloads.".to_string(),
737 ],
738 context,
739 );
740 }
741 if contains_any(&lower, &["500", "internal server error", "server error"]) {
742 return build_hints(
743 "Provider encountered a server error.",
744 vec![
745 "Retry after a short delay.".to_string(),
746 "If persistent, try a different model/provider.".to_string(),
747 ],
748 context,
749 );
750 }
751 build_hints(
752 "Provider request failed.",
753 vec![
754 key_hint,
755 "Check network connectivity and provider status.".to_string(),
756 ],
757 context,
758 )
759}
760
761fn provider_key_hint(provider: &str) -> String {
762 let canonical = canonical_provider_id(provider).unwrap_or(provider);
763 let env_keys = provider_auth_env_keys(provider);
764 if !env_keys.is_empty() {
765 let key_list = env_keys
766 .iter()
767 .map(|key| format!("`{key}`"))
768 .collect::<Vec<_>>()
769 .join(" or ");
770 if canonical == "anthropic" {
771 return format!("Set {key_list} (or use `/login anthropic`).");
772 }
773 if canonical == "github-copilot" {
774 return format!("Set {key_list} (or use `/login github-copilot`).");
775 }
776 return format!("Set {key_list} for provider `{canonical}`.");
777 }
778
779 format!("Check API key configuration for provider `{provider}`.")
780}
781
782fn auth_hints(message: &str) -> ErrorHints {
783 let lower = message.to_lowercase();
784 if contains_any(
785 &lower,
786 &["missing authorization code", "authorization code"],
787 ) {
788 return build_hints(
789 "OAuth login did not complete.",
790 vec![
791 "Run `/login` again to restart the flow.".to_string(),
792 "Ensure the browser redirect URL was opened.".to_string(),
793 ],
794 vec![("details", message.to_string())],
795 );
796 }
797 if contains_any(&lower, &["token exchange failed", "invalid token response"]) {
798 return build_hints(
799 "OAuth token exchange failed.",
800 vec![
801 "Retry `/login` to refresh credentials.".to_string(),
802 "Check network connectivity during the login flow.".to_string(),
803 ],
804 vec![("details", message.to_string())],
805 );
806 }
807 build_hints(
808 "Authentication error.",
809 vec![
810 "Verify API keys or run `/login`.".to_string(),
811 "Check auth.json permissions in the Pi config directory.".to_string(),
812 ],
813 vec![("details", message.to_string())],
814 )
815}
816
817fn tool_hints(tool: &str, message: &str) -> ErrorHints {
818 let lower = message.to_lowercase();
819 if contains_any(&lower, &["not found", "no such file", "command not found"]) {
820 return build_hints(
821 "Tool executable or target not found.",
822 vec![
823 "Check PATH and tool installation.".to_string(),
824 "Verify the tool input path exists.".to_string(),
825 ],
826 vec![("tool", tool.to_string()), ("details", message.to_string())],
827 );
828 }
829 build_hints(
830 "Tool execution failed.",
831 vec![
832 "Check the tool output for details.".to_string(),
833 "Re-run with simpler inputs to isolate the failure.".to_string(),
834 ],
835 vec![("tool", tool.to_string()), ("details", message.to_string())],
836 )
837}
838
839fn io_hints(err: &std::io::Error) -> ErrorHints {
840 let details = err.to_string();
841 match err.kind() {
842 std::io::ErrorKind::NotFound => build_hints(
843 "Required file or directory not found.",
844 vec![
845 "Verify the path exists and is spelled correctly.".to_string(),
846 "Check `PI_CONFIG_PATH` or `PI_SESSIONS_DIR` overrides.".to_string(),
847 ],
848 vec![
849 ("error_kind", format!("{:?}", err.kind())),
850 ("details", details),
851 ],
852 ),
853 std::io::ErrorKind::PermissionDenied => build_hints(
854 "Permission denied while accessing a file.",
855 vec![
856 "Check file permissions or ownership.".to_string(),
857 "Try a different location with write access.".to_string(),
858 ],
859 vec![
860 ("error_kind", format!("{:?}", err.kind())),
861 ("details", details),
862 ],
863 ),
864 std::io::ErrorKind::TimedOut => build_hints(
865 "I/O operation timed out.",
866 vec![
867 "Check network or filesystem latency.".to_string(),
868 "Retry after confirming connectivity.".to_string(),
869 ],
870 vec![
871 ("error_kind", format!("{:?}", err.kind())),
872 ("details", details),
873 ],
874 ),
875 std::io::ErrorKind::ConnectionRefused => build_hints(
876 "Connection refused.",
877 vec![
878 "Check network connectivity or proxy settings.".to_string(),
879 "Verify the target service is reachable.".to_string(),
880 ],
881 vec![
882 ("error_kind", format!("{:?}", err.kind())),
883 ("details", details),
884 ],
885 ),
886 std::io::ErrorKind::HostUnreachable | std::io::ErrorKind::NetworkUnreachable => {
887 build_hints(
888 "Network/host unreachable while connecting.",
889 vec![
890 "Confirm the endpoint is reachable (e.g. `curl -v <url>`).".to_string(),
891 "Check VPN, proxy, and firewall settings.".to_string(),
892 "If curl works but Pi does not, this is a client connect-path issue \
893 (IPv6/IPv4 reachability or DNS) — please report it."
894 .to_string(),
895 ],
896 vec![
897 ("error_kind", format!("{:?}", err.kind())),
898 ("details", details),
899 ],
900 )
901 }
902 _ => build_hints(
903 "I/O error occurred.",
904 vec![
905 "Check file paths and permissions.".to_string(),
906 "Retry after resolving any transient issues.".to_string(),
907 ],
908 vec![
909 ("error_kind", format!("{:?}", err.kind())),
910 ("details", details),
911 ],
912 ),
913 }
914}
915
916fn sqlite_hints(err: &sqlmodel_core::Error) -> ErrorHints {
917 let details = err.to_string();
918 let lower = details.to_lowercase();
919 if contains_any(&lower, &["database is locked", "busy"]) {
920 return build_hints(
921 "SQLite database is locked.",
922 vec![
923 "Close other Pi instances using the same database.".to_string(),
924 "Retry once the lock clears.".to_string(),
925 ],
926 vec![("details", details)],
927 );
928 }
929 build_hints(
930 "SQLite error.",
931 vec![
932 "Ensure the database path is writable.".to_string(),
933 "Check for schema or migration issues.".to_string(),
934 ],
935 vec![("details", details)],
936 )
937}
938
939impl From<std::io::Error> for Error {
940 fn from(value: std::io::Error) -> Self {
941 Self::Io(Box::new(value))
942 }
943}
944
945impl From<asupersync::sync::LockError> for Error {
946 fn from(value: asupersync::sync::LockError) -> Self {
947 match value {
948 asupersync::sync::LockError::Cancelled => Self::Aborted,
949 asupersync::sync::LockError::Poisoned
950 | asupersync::sync::LockError::PolledAfterCompletion
951 | asupersync::sync::LockError::TimedOut(_) => {
954 Self::session(value.to_string())
955 }
956 }
957 }
958}
959
960impl From<serde_json::Error> for Error {
961 fn from(value: serde_json::Error) -> Self {
962 Self::Json(Box::new(value))
963 }
964}
965
966impl From<sqlmodel_core::Error> for Error {
967 fn from(value: sqlmodel_core::Error) -> Self {
968 Self::Sqlite(Box::new(value))
969 }
970}
971
972const OVERFLOW_PATTERNS: &[&str] = &[
976 "prompt is too long",
977 "input is too long for requested model",
978 "exceeds the context window",
979 "reduce the length of the messages",
982 "exceeds the available context size",
985 "greater than the context length",
986 "context window exceeds limit",
987 "exceeded model token limit",
988 "too many tokens",
990 "token limit exceeded",
991];
992
993static OVERFLOW_RE: OnceLock<regex::RegexSet> = OnceLock::new();
994static RETRYABLE_RE: OnceLock<regex::Regex> = OnceLock::new();
995
996pub fn is_context_overflow(
1003 error_message: &str,
1004 usage_input_tokens: Option<u64>,
1005 context_window: Option<u32>,
1006) -> bool {
1007 if let (Some(input_tokens), Some(window)) = (usage_input_tokens, context_window) {
1009 if input_tokens > u64::from(window) {
1010 return true;
1011 }
1012 }
1013
1014 let lower = error_message.to_lowercase();
1015
1016 if OVERFLOW_PATTERNS
1018 .iter()
1019 .any(|pattern| lower.contains(pattern))
1020 {
1021 return true;
1022 }
1023
1024 let re = OVERFLOW_RE.get_or_init(|| {
1026 regex::RegexSet::new([
1027 r"input token count.*exceeds the maximum",
1028 r"maximum prompt length is \d+",
1029 r"maximum context length is \d+ tokens",
1030 r"exceeds the limit of \d+",
1031 r"context[_ ]length[_ ]exceeded",
1032 r"^4(00|13)\s*(status code)?\s*\(no body\)",
1034 ])
1035 .expect("overflow regex set")
1036 });
1037
1038 re.is_match(&lower)
1039}
1040
1041#[must_use]
1053pub const fn io_kind_is_transient(kind: std::io::ErrorKind) -> bool {
1054 matches!(
1055 kind,
1056 std::io::ErrorKind::ConnectionReset
1057 | std::io::ErrorKind::ConnectionAborted
1058 | std::io::ErrorKind::BrokenPipe
1059 | std::io::ErrorKind::UnexpectedEof
1060 | std::io::ErrorKind::NotConnected
1061 | std::io::ErrorKind::TimedOut
1062 )
1063}
1064
1065fn io_error_chain_is_transient(err: &std::io::Error) -> bool {
1069 let mut current = Some(err);
1070 while let Some(io_err) = current {
1071 if io_kind_is_transient(io_err.kind()) {
1072 return true;
1073 }
1074 current = io_err
1075 .get_ref()
1076 .and_then(|inner| inner.downcast_ref::<std::io::Error>());
1077 }
1078 false
1079}
1080
1081pub fn is_retryable_error(
1094 error_message: &str,
1095 usage_input_tokens: Option<u64>,
1096 context_window: Option<u32>,
1097) -> bool {
1098 if error_message.is_empty() {
1099 return false;
1100 }
1101
1102 if is_context_overflow(error_message, usage_input_tokens, context_window) {
1104 return false;
1105 }
1106
1107 let lower = error_message.to_lowercase();
1108
1109 let re = RETRYABLE_RE.get_or_init(|| {
1110 regex::Regex::new(
1111 r"overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|connection.?refused|connection.?reset|connection.?aborted|connection.?closed|connection.?dropped|other side closed|closed before headers|closed before message|close_notify|broken pipe|unexpected eof|unexpected end of file|transient connection|fetch failed|upstream.?connect|reset before headers|terminated|retry delay",
1112 )
1113 .expect("retryable regex")
1114 });
1115
1116 re.is_match(&lower)
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 use super::*;
1122
1123 fn context_value<'a>(hints: &'a ErrorHints, key: &str) -> Option<&'a str> {
1124 hints
1125 .context
1126 .iter()
1127 .find(|(k, _)| k == key)
1128 .map(|(_, value)| value.as_str())
1129 }
1130
1131 #[test]
1134 fn error_config_constructor() {
1135 let err = Error::config("bad config");
1136 assert!(matches!(err, Error::Config(ref msg) if msg == "bad config"));
1137 }
1138
1139 #[test]
1140 fn error_session_constructor() {
1141 let err = Error::session("session corrupted");
1142 assert!(matches!(err, Error::Session(ref msg) if msg == "session corrupted"));
1143 }
1144
1145 #[test]
1146 fn error_provider_constructor() {
1147 let err = Error::provider("anthropic", "timeout");
1148 assert!(matches!(err, Error::Provider { ref provider, ref message }
1149 if provider == "anthropic" && message == "timeout"));
1150 }
1151
1152 #[test]
1153 fn error_auth_constructor() {
1154 let err = Error::auth("missing key");
1155 assert!(matches!(err, Error::Auth(ref msg) if msg == "missing key"));
1156 }
1157
1158 #[test]
1159 fn error_tool_constructor() {
1160 let err = Error::tool("bash", "exit code 1");
1161 assert!(matches!(err, Error::Tool { ref tool, ref message }
1162 if tool == "bash" && message == "exit code 1"));
1163 }
1164
1165 #[test]
1166 fn error_validation_constructor() {
1167 let err = Error::validation("field required");
1168 assert!(matches!(err, Error::Validation(ref msg) if msg == "field required"));
1169 }
1170
1171 #[test]
1172 fn error_extension_constructor() {
1173 let err = Error::extension("manifest invalid");
1174 assert!(matches!(err, Error::Extension(ref msg) if msg == "manifest invalid"));
1175 }
1176
1177 #[test]
1178 fn error_api_constructor() {
1179 let err = Error::api("404 not found");
1180 assert!(matches!(err, Error::Api(ref msg) if msg == "404 not found"));
1181 }
1182
1183 #[test]
1184 fn error_category_code_is_stable() {
1185 assert_eq!(Error::auth("missing").category_code(), "auth");
1186 assert_eq!(Error::provider("openai", "429").category_code(), "provider");
1187 assert_eq!(Error::tool("bash", "failed").category_code(), "tool");
1188 assert_eq!(Error::Aborted.category_code(), "runtime");
1189 }
1190
1191 #[test]
1192 fn hints_include_error_category_context() {
1193 let hints = Error::tool("bash", "exit code 1").hints();
1194 assert_eq!(context_value(&hints, "error_category"), Some("tool"));
1195 }
1196
1197 #[test]
1200 fn error_config_display() {
1201 let err = Error::config("missing settings.json");
1202 let msg = err.to_string();
1203 assert!(msg.contains("Configuration error"));
1204 assert!(msg.contains("missing settings.json"));
1205 }
1206
1207 #[test]
1208 fn error_session_display() {
1209 let err = Error::session("tree corrupted");
1210 let msg = err.to_string();
1211 assert!(msg.contains("Session error"));
1212 assert!(msg.contains("tree corrupted"));
1213 }
1214
1215 #[test]
1216 fn error_session_not_found_display() {
1217 let err = Error::SessionNotFound {
1218 path: "/home/user/.pi/sessions/abc.jsonl".to_string(),
1219 };
1220 let msg = err.to_string();
1221 assert!(msg.contains("Session not found"));
1222 assert!(msg.contains("/home/user/.pi/sessions/abc.jsonl"));
1223 }
1224
1225 #[test]
1226 fn error_provider_display() {
1227 let err = Error::provider("openai", "429 too many requests");
1228 let msg = err.to_string();
1229 assert!(msg.contains("Provider error"));
1230 assert!(msg.contains("openai"));
1231 assert!(msg.contains("429 too many requests"));
1232 }
1233
1234 #[test]
1235 fn error_auth_display() {
1236 let err = Error::auth("API key expired");
1237 let msg = err.to_string();
1238 assert!(msg.contains("Authentication error"));
1239 assert!(msg.contains("API key expired"));
1240 }
1241
1242 #[test]
1243 fn error_tool_display() {
1244 let err = Error::tool("read", "file not found: /tmp/x.txt");
1245 let msg = err.to_string();
1246 assert!(msg.contains("Tool error"));
1247 assert!(msg.contains("read"));
1248 assert!(msg.contains("file not found: /tmp/x.txt"));
1249 }
1250
1251 #[test]
1252 fn error_validation_display() {
1253 let err = Error::validation("temperature must be 0-2");
1254 let msg = err.to_string();
1255 assert!(msg.contains("Validation error"));
1256 assert!(msg.contains("temperature must be 0-2"));
1257 }
1258
1259 #[test]
1260 fn error_extension_display() {
1261 let err = Error::extension("manifest parse failed");
1262 let msg = err.to_string();
1263 assert!(msg.contains("Extension error"));
1264 assert!(msg.contains("manifest parse failed"));
1265 }
1266
1267 #[test]
1268 fn error_aborted_display() {
1269 let err = Error::Aborted;
1270 let msg = err.to_string();
1271 assert!(msg.contains("Operation aborted"));
1272 }
1273
1274 #[test]
1275 fn error_api_display() {
1276 let err = Error::api("GitHub API error 403");
1277 let msg = err.to_string();
1278 assert!(msg.contains("API error"));
1279 assert!(msg.contains("GitHub API error 403"));
1280 }
1281
1282 #[test]
1283 fn error_io_display() {
1284 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
1285 let err = Error::from(io_err);
1286 let msg = err.to_string();
1287 assert!(msg.contains("IO error"));
1288 }
1289
1290 #[test]
1291 fn error_json_display() {
1292 let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
1293 let err = Error::from(json_err);
1294 let msg = err.to_string();
1295 assert!(msg.contains("JSON error"));
1296 }
1297
1298 #[test]
1301 fn error_from_io_error() {
1302 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
1303 let err: Error = io_err.into();
1304 assert!(matches!(err, Error::Io(_)));
1305 }
1306
1307 #[test]
1308 fn error_from_serde_json_error() {
1309 let json_err = serde_json::from_str::<serde_json::Value>("{invalid").unwrap_err();
1310 let err: Error = json_err.into();
1311 assert!(matches!(err, Error::Json(_)));
1312 }
1313
1314 #[test]
1317 fn hints_config_json_parse_error() {
1318 let err = Error::config("JSON parse error in settings.json");
1319 let h = err.hints();
1320 assert!(h.summary.contains("not valid JSON"));
1321 assert!(h.hints.iter().any(|s| s.contains("JSON formatting")));
1322 }
1323
1324 #[test]
1325 fn hints_config_missing_file() {
1326 let err = Error::config("config file not found: ~/.pi/settings");
1327 let h = err.hints();
1328 assert!(h.summary.contains("missing"));
1329 }
1330
1331 #[test]
1332 fn hints_config_generic() {
1333 let err = Error::config("unknown config issue");
1334 let h = err.hints();
1335 assert!(h.summary.contains("Configuration error"));
1336 }
1337
1338 #[test]
1339 fn hints_session_empty() {
1340 let err = Error::session("empty session file");
1341 let h = err.hints();
1342 assert!(h.summary.contains("empty") || h.summary.contains("corrupted"));
1343 }
1344
1345 #[test]
1346 fn hints_session_read_failure() {
1347 let err = Error::session("failed to read session directory");
1348 let h = err.hints();
1349 assert!(h.summary.contains("Failed to read"));
1350 }
1351
1352 #[test]
1353 fn hints_session_not_found() {
1354 let err = Error::SessionNotFound {
1355 path: "/tmp/session.jsonl".to_string(),
1356 };
1357 let h = err.hints();
1358 assert!(h.summary.contains("not found"));
1359 assert!(
1360 h.context
1361 .iter()
1362 .any(|(k, v)| k == "path" && v.contains("/tmp/session.jsonl"))
1363 );
1364 }
1365
1366 #[test]
1367 fn hints_provider_401() {
1368 let err = Error::provider("anthropic", "HTTP 401 unauthorized");
1369 let h = err.hints();
1370 assert!(h.summary.contains("authentication failed"));
1371 assert!(h.hints.iter().any(|s| s.contains("ANTHROPIC_API_KEY")));
1372 }
1373
1374 #[test]
1375 fn hints_provider_403() {
1376 let err = Error::provider("openai", "403 forbidden");
1377 let h = err.hints();
1378 assert!(h.summary.contains("forbidden"));
1379 }
1380
1381 #[test]
1382 fn hints_provider_429() {
1383 let err = Error::provider("anthropic", "429 rate limit");
1384 let h = err.hints();
1385 assert!(h.summary.contains("rate limited"));
1386 }
1387
1388 #[test]
1389 fn hints_provider_529() {
1390 let err = Error::provider("anthropic", "529 overloaded");
1391 let h = err.hints();
1392 assert!(h.summary.contains("overloaded"));
1393 }
1394
1395 #[test]
1396 fn hints_provider_timeout() {
1397 let err = Error::provider("openai", "request timed out");
1398 let h = err.hints();
1399 assert!(h.summary.contains("timed out"));
1400 }
1401
1402 #[test]
1403 fn hints_provider_400() {
1404 let err = Error::provider("gemini", "400 bad request");
1405 let h = err.hints();
1406 assert!(h.summary.contains("rejected"));
1407 }
1408
1409 #[test]
1410 fn hints_provider_500() {
1411 let err = Error::provider("cohere", "500 internal server error");
1412 let h = err.hints();
1413 assert!(h.summary.contains("server error"));
1414 }
1415
1416 #[test]
1417 fn hints_provider_generic() {
1418 let err = Error::provider("custom", "unknown issue");
1419 let h = err.hints();
1420 assert!(h.summary.contains("failed"));
1421 assert!(h.context.iter().any(|(k, _)| k == "provider"));
1422 }
1423
1424 #[test]
1425 fn hints_provider_key_hint_openai() {
1426 let err = Error::provider("openai", "401 invalid api key");
1427 let h = err.hints();
1428 assert!(h.hints.iter().any(|s| s.contains("OPENAI_API_KEY")));
1429 }
1430
1431 #[test]
1432 fn hints_provider_key_hint_gemini() {
1433 let err = Error::provider("gemini", "401 api key invalid");
1434 let h = err.hints();
1435 assert!(h.hints.iter().any(|s| s.contains("GOOGLE_API_KEY")));
1436 }
1437
1438 #[test]
1439 fn hints_provider_key_hint_openrouter() {
1440 let err = Error::provider("openrouter", "401 unauthorized");
1441 let h = err.hints();
1442 assert!(h.hints.iter().any(|s| s.contains("OPENROUTER_API_KEY")));
1443 }
1444
1445 #[test]
1446 fn hints_provider_key_hint_groq() {
1447 let err = Error::provider("groq", "401 unauthorized");
1448 let h = err.hints();
1449 assert!(h.hints.iter().any(|s| s.contains("GROQ_API_KEY")));
1450 }
1451
1452 #[test]
1453 fn hints_provider_key_hint_alias_dashscope() {
1454 let err = Error::provider("dashscope", "401 invalid api key");
1455 let h = err.hints();
1456 assert!(h.hints.iter().any(|s| s.contains("DASHSCOPE_API_KEY")));
1457 assert!(h.hints.iter().any(|s| s.contains("QWEN_API_KEY")));
1458 }
1459
1460 #[test]
1461 fn hints_provider_key_hint_alias_kimi() {
1462 let err = Error::provider("kimi", "401 invalid api key");
1463 let h = err.hints();
1464 assert!(h.hints.iter().any(|s| s.contains("MOONSHOT_API_KEY")));
1465 assert!(h.hints.iter().any(|s| s.contains("KIMI_API_KEY")));
1466 }
1467
1468 #[test]
1469 fn hints_provider_key_hint_azure() {
1470 let err = Error::provider("azure-openai", "401 unauthorized");
1471 let h = err.hints();
1472 assert!(h.hints.iter().any(|s| s.contains("AZURE_OPENAI_API_KEY")));
1473 }
1474
1475 #[test]
1476 fn hints_provider_key_hint_unknown() {
1477 let err = Error::provider("my-proxy", "401 unauthorized");
1478 let h = err.hints();
1479 assert!(h.hints.iter().any(|s| s.contains("my-proxy")));
1480 }
1481
1482 #[test]
1483 fn hints_auth_authorization_code() {
1484 let err = Error::auth("missing authorization code");
1485 let h = err.hints();
1486 assert!(h.summary.contains("OAuth"));
1487 }
1488
1489 #[test]
1490 fn hints_auth_token_exchange() {
1491 let err = Error::auth("token exchange failed");
1492 let h = err.hints();
1493 assert!(h.summary.contains("token exchange"));
1494 }
1495
1496 #[test]
1497 fn hints_auth_generic() {
1498 let err = Error::auth("unknown auth issue");
1499 let h = err.hints();
1500 assert!(h.summary.contains("Authentication error"));
1501 }
1502
1503 #[test]
1504 fn auth_diagnostic_provider_invalid_key_code_and_context() {
1505 let err = Error::provider("openai", "HTTP 401 unauthorized");
1506 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1507 assert_eq!(diagnostic.code, AuthDiagnosticCode::InvalidApiKey);
1508 assert_eq!(diagnostic.code.as_str(), "auth.invalid_api_key");
1509 assert_eq!(diagnostic.redaction_policy, "redact-secrets");
1510
1511 let hints = err.hints();
1512 assert_eq!(
1513 context_value(&hints, "diagnostic_code"),
1514 Some("auth.invalid_api_key")
1515 );
1516 assert_eq!(
1517 context_value(&hints, "redaction_policy"),
1518 Some("redact-secrets")
1519 );
1520 }
1521
1522 #[test]
1523 fn auth_diagnostic_missing_key_phrase_for_oai_provider() {
1524 let err = Error::provider(
1525 "openrouter",
1526 "You didn't provide an API key in the Authorization header",
1527 );
1528 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1529 assert_eq!(diagnostic.code, AuthDiagnosticCode::MissingApiKey);
1530 assert_eq!(diagnostic.code.as_str(), "auth.missing_api_key");
1531 }
1532
1533 #[test]
1534 fn auth_diagnostic_revoked_key_maps_invalid() {
1535 let err = Error::provider("deepseek", "API key revoked for this project");
1536 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1537 assert_eq!(diagnostic.code, AuthDiagnosticCode::InvalidApiKey);
1538 assert_eq!(diagnostic.code.as_str(), "auth.invalid_api_key");
1539 }
1540
1541 #[test]
1542 fn auth_diagnostic_quota_exceeded_code_and_context() {
1543 let err = Error::provider(
1544 "openai",
1545 "HTTP 429 insufficient_quota: You exceeded your current quota",
1546 );
1547 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1548 assert_eq!(diagnostic.code, AuthDiagnosticCode::QuotaExceeded);
1549 assert_eq!(diagnostic.code.as_str(), "auth.quota_exceeded");
1550 assert_eq!(
1551 diagnostic.remediation,
1552 "Verify billing/quota limits for this API key or organization, then retry."
1553 );
1554
1555 let hints = err.hints();
1556 assert_eq!(
1557 context_value(&hints, "diagnostic_code"),
1558 Some("auth.quota_exceeded")
1559 );
1560 assert!(
1561 hints
1562 .hints
1563 .iter()
1564 .any(|s| s.contains("billing") || s.contains("quota")),
1565 "quota/billing guidance should be present"
1566 );
1567 }
1568
1569 #[test]
1570 fn auth_diagnostic_oauth_exchange_failure_code() {
1571 let err = Error::auth("Token exchange failed: invalid_grant");
1572 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1573 assert_eq!(
1574 diagnostic.code,
1575 AuthDiagnosticCode::OAuthTokenExchangeFailed
1576 );
1577 assert_eq!(
1578 diagnostic.remediation,
1579 "Retry login flow and verify token endpoint/client configuration."
1580 );
1581
1582 let hints = err.hints();
1583 assert_eq!(
1584 context_value(&hints, "diagnostic_code"),
1585 Some("auth.oauth.token_exchange_failed")
1586 );
1587 }
1588
1589 #[test]
1590 fn auth_diagnostic_azure_missing_deployment_code() {
1591 let err = Error::provider(
1592 "azure-openai",
1593 "Azure OpenAI provider requires resource+deployment; configure via models.json",
1594 );
1595 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1596 assert_eq!(diagnostic.code, AuthDiagnosticCode::MissingAzureDeployment);
1597 assert_eq!(diagnostic.code.as_str(), "config.azure.missing_deployment");
1598 }
1599
1600 #[test]
1601 fn auth_diagnostic_bedrock_missing_credential_chain_code() {
1602 let err = Error::provider(
1603 "amazon-bedrock",
1604 "AWS credential chain not configured for provider",
1605 );
1606 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1607 assert_eq!(diagnostic.code, AuthDiagnosticCode::MissingCredentialChain);
1608 assert_eq!(diagnostic.code.as_str(), "auth.credential_chain.missing");
1609 }
1610
1611 #[test]
1612 fn auth_diagnostic_absent_for_non_auth_provider_error() {
1613 let err = Error::provider("anthropic", "429 rate limit");
1614 assert!(err.auth_diagnostic().is_none());
1615
1616 let hints = err.hints();
1617 assert!(context_value(&hints, "diagnostic_code").is_none());
1618 }
1619
1620 #[test]
1626 fn native_provider_missing_key_anthropic() {
1627 let err = Error::provider(
1628 "anthropic",
1629 "Missing API key for Anthropic. Set ANTHROPIC_API_KEY or use `pi auth`.",
1630 );
1631 let d = err.auth_diagnostic().expect("diagnostic");
1632 assert_eq!(d.code, AuthDiagnosticCode::MissingApiKey);
1633 let hints = err.hints();
1634 assert_eq!(context_value(&hints, "provider"), Some("anthropic"));
1635 assert!(
1636 hints.summary.contains("missing"),
1637 "summary: {}",
1638 hints.summary
1639 );
1640 }
1641
1642 #[test]
1643 fn native_provider_missing_key_openai() {
1644 let err = Error::provider(
1645 "openai",
1646 "Missing API key for OpenAI. Set OPENAI_API_KEY or configure in settings.",
1647 );
1648 let d = err.auth_diagnostic().expect("diagnostic");
1649 assert_eq!(d.code, AuthDiagnosticCode::MissingApiKey);
1650 }
1651
1652 #[test]
1653 fn native_provider_missing_key_azure() {
1654 let err = Error::provider(
1655 "azure-openai",
1656 "Missing API key for Azure OpenAI. Set AZURE_OPENAI_API_KEY or configure in settings.",
1657 );
1658 let d = err.auth_diagnostic().expect("diagnostic");
1659 assert_eq!(d.code, AuthDiagnosticCode::MissingApiKey);
1660 }
1661
1662 #[test]
1663 fn native_provider_missing_key_cohere() {
1664 let err = Error::provider(
1665 "cohere",
1666 "Missing API key for Cohere. Set COHERE_API_KEY or configure in settings.",
1667 );
1668 let d = err.auth_diagnostic().expect("diagnostic");
1669 assert_eq!(d.code, AuthDiagnosticCode::MissingApiKey);
1670 }
1671
1672 #[test]
1673 fn native_provider_missing_key_gemini() {
1674 let err = Error::provider(
1675 "google",
1676 "Missing API key for Google/Gemini. Set GOOGLE_API_KEY or GEMINI_API_KEY.",
1677 );
1678 let d = err.auth_diagnostic().expect("diagnostic");
1679 assert_eq!(d.code, AuthDiagnosticCode::MissingApiKey);
1680 }
1681
1682 #[test]
1683 fn native_provider_http_401_anthropic() {
1684 let err = Error::provider(
1685 "anthropic",
1686 "Anthropic API error (HTTP 401): {\"error\":{\"type\":\"authentication_error\"}}",
1687 );
1688 let d = err.auth_diagnostic().expect("diagnostic");
1689 assert_eq!(d.code, AuthDiagnosticCode::InvalidApiKey);
1690 let hints = err.hints();
1691 assert!(hints.summary.contains("authentication failed"));
1692 }
1693
1694 #[test]
1695 fn native_provider_http_401_openai() {
1696 let err = Error::provider(
1697 "openai",
1698 "OpenAI API error (HTTP 401): Incorrect API key provided",
1699 );
1700 let d = err.auth_diagnostic().expect("diagnostic");
1701 assert_eq!(d.code, AuthDiagnosticCode::InvalidApiKey);
1702 }
1703
1704 #[test]
1705 fn native_provider_http_403_azure() {
1706 let err = Error::provider(
1707 "azure-openai",
1708 "Azure OpenAI API error (HTTP 403): Access denied",
1709 );
1710 let d = err.auth_diagnostic().expect("diagnostic");
1711 assert_eq!(d.code, AuthDiagnosticCode::InvalidApiKey);
1712 }
1713
1714 #[test]
1715 fn native_provider_http_429_quota_openai() {
1716 let err = Error::provider("openai", "OpenAI API error (HTTP 429): insufficient_quota");
1717 let d = err.auth_diagnostic().expect("diagnostic");
1718 assert_eq!(d.code, AuthDiagnosticCode::QuotaExceeded);
1719 }
1720
1721 #[test]
1722 fn native_provider_http_500_no_diagnostic() {
1723 let err = Error::provider(
1725 "anthropic",
1726 "Anthropic API error (HTTP 500): Internal server error",
1727 );
1728 assert!(err.auth_diagnostic().is_none());
1729 }
1730
1731 #[test]
1732 fn native_provider_hints_include_provider_context() {
1733 let err = Error::provider("cohere", "Cohere API error (HTTP 401): unauthorized");
1734 let hints = err.hints();
1735 assert_eq!(context_value(&hints, "provider"), Some("cohere"));
1736 assert!(context_value(&hints, "details").is_some());
1737 }
1738
1739 #[test]
1740 fn native_provider_diagnostic_enriches_hints_context() {
1741 let err = Error::provider(
1742 "google",
1743 "Missing API key for Google/Gemini. Set GOOGLE_API_KEY or GEMINI_API_KEY.",
1744 );
1745 let hints = err.hints();
1746 assert_eq!(
1747 context_value(&hints, "diagnostic_code"),
1748 Some("auth.missing_api_key")
1749 );
1750 assert_eq!(
1751 context_value(&hints, "redaction_policy"),
1752 Some("redact-secrets")
1753 );
1754 assert!(context_value(&hints, "diagnostic_remediation").is_some());
1755 }
1756
1757 #[test]
1758 fn hints_tool_not_found() {
1759 let err = Error::tool("bash", "command not found: xyz");
1760 let h = err.hints();
1761 assert!(h.summary.contains("not found"));
1762 }
1763
1764 #[test]
1765 fn hints_tool_generic() {
1766 let err = Error::tool("read", "unexpected error");
1767 let h = err.hints();
1768 assert!(h.summary.contains("execution failed"));
1769 }
1770
1771 #[test]
1772 fn hints_validation() {
1773 let err = Error::validation("invalid input");
1774 let h = err.hints();
1775 assert!(h.summary.contains("Validation"));
1776 }
1777
1778 #[test]
1779 fn hints_extension() {
1780 let err = Error::extension("load error");
1781 let h = err.hints();
1782 assert!(h.summary.contains("Extension"));
1783 }
1784
1785 #[test]
1786 fn hints_io_not_found() {
1787 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
1788 let err = Error::from(io_err);
1789 let h = err.hints();
1790 assert!(h.summary.contains("not found"));
1791 }
1792
1793 #[test]
1794 fn hints_io_permission_denied() {
1795 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1796 let err = Error::from(io_err);
1797 let h = err.hints();
1798 assert!(h.summary.contains("Permission denied"));
1799 }
1800
1801 #[test]
1802 fn hints_io_timed_out() {
1803 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1804 let err = Error::from(io_err);
1805 let h = err.hints();
1806 assert!(h.summary.contains("timed out"));
1807 }
1808
1809 #[test]
1810 fn hints_io_connection_refused() {
1811 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1812 let err = Error::from(io_err);
1813 let h = err.hints();
1814 assert!(h.summary.contains("Connection refused"));
1815 }
1816
1817 #[test]
1818 fn hints_io_generic() {
1819 let io_err = std::io::Error::other("something");
1820 let err = Error::from(io_err);
1821 let h = err.hints();
1822 assert!(h.summary.contains("I/O error"));
1823 }
1824
1825 #[test]
1826 fn hints_json() {
1827 let json_err = serde_json::from_str::<serde_json::Value>("broken").unwrap_err();
1828 let err = Error::from(json_err);
1829 let h = err.hints();
1830 assert!(h.summary.contains("JSON"));
1831 }
1832
1833 #[test]
1834 fn hints_aborted() {
1835 let err = Error::Aborted;
1836 let h = err.hints();
1837 assert!(h.summary.contains("aborted"));
1838 }
1839
1840 #[test]
1841 fn hints_api() {
1842 let err = Error::api("connection reset");
1843 let h = err.hints();
1844 assert!(h.summary.contains("API"));
1845 }
1846
1847 #[test]
1852 fn e2e_all_native_providers_missing_key_diagnostic() {
1853 let cases: &[(&str, &str)] = &[
1854 (
1855 "anthropic",
1856 "Missing API key for Anthropic. Set ANTHROPIC_API_KEY or use `pi auth`.",
1857 ),
1858 (
1859 "openai",
1860 "Missing API key for OpenAI. Set OPENAI_API_KEY or configure in settings.",
1861 ),
1862 (
1863 "azure-openai",
1864 "Missing API key for Azure OpenAI. Set AZURE_OPENAI_API_KEY or configure in settings.",
1865 ),
1866 (
1867 "cohere",
1868 "Missing API key for Cohere. Set COHERE_API_KEY or configure in settings.",
1869 ),
1870 (
1871 "google",
1872 "Missing API key for Google/Gemini. Set GOOGLE_API_KEY or GEMINI_API_KEY.",
1873 ),
1874 ];
1875 for (provider, message) in cases {
1876 let err = Error::provider(*provider, *message);
1877 let d = err.auth_diagnostic().expect("expected auth diagnostic");
1878 assert_eq!(
1879 d.code,
1880 AuthDiagnosticCode::MissingApiKey,
1881 "wrong code for {provider}: {:?}",
1882 d.code
1883 );
1884 }
1885 }
1886
1887 #[test]
1888 fn e2e_all_native_providers_401_diagnostic() {
1889 let cases: &[(&str, &str)] = &[
1890 (
1891 "anthropic",
1892 "Anthropic API error (HTTP 401): invalid x-api-key",
1893 ),
1894 (
1895 "openai",
1896 "OpenAI API error (HTTP 401): Incorrect API key provided",
1897 ),
1898 (
1899 "azure-openai",
1900 "Azure OpenAI API error (HTTP 401): unauthorized",
1901 ),
1902 ("cohere", "Cohere API error (HTTP 401): unauthorized"),
1903 ("google", "Gemini API error (HTTP 401): API key not valid"),
1904 ];
1905 for (provider, message) in cases {
1906 let err = Error::provider(*provider, *message);
1907 let d = err.auth_diagnostic().expect("expected auth diagnostic");
1908 assert_eq!(
1909 d.code,
1910 AuthDiagnosticCode::InvalidApiKey,
1911 "wrong code for {provider}: {:?}",
1912 d.code
1913 );
1914 }
1915 }
1916
1917 #[test]
1919 fn e2e_non_auth_errors_no_diagnostic() {
1920 let cases: &[(&str, &str)] = &[
1921 (
1922 "anthropic",
1923 "Anthropic API error (HTTP 500): Internal server error",
1924 ),
1925 ("openai", "OpenAI API error (HTTP 503): Service unavailable"),
1926 ("google", "Gemini API error (HTTP 502): Bad gateway"),
1927 ("cohere", "Cohere API error (HTTP 504): Gateway timeout"),
1928 ];
1929 for (provider, message) in cases {
1930 let err = Error::provider(*provider, *message);
1931 assert!(
1932 err.auth_diagnostic().is_none(),
1933 "unexpected diagnostic for {provider} with message: {message}"
1934 );
1935 }
1936 }
1937
1938 #[test]
1940 fn e2e_all_diagnostic_codes_have_redact_secrets_policy() {
1941 let codes = [
1942 AuthDiagnosticCode::MissingApiKey,
1943 AuthDiagnosticCode::InvalidApiKey,
1944 AuthDiagnosticCode::QuotaExceeded,
1945 AuthDiagnosticCode::MissingOAuthAuthorizationCode,
1946 AuthDiagnosticCode::OAuthTokenExchangeFailed,
1947 AuthDiagnosticCode::OAuthTokenRefreshFailed,
1948 AuthDiagnosticCode::MissingAzureDeployment,
1949 AuthDiagnosticCode::MissingRegion,
1950 AuthDiagnosticCode::MissingProject,
1951 AuthDiagnosticCode::MissingProfile,
1952 AuthDiagnosticCode::MissingEndpoint,
1953 AuthDiagnosticCode::MissingCredentialChain,
1954 AuthDiagnosticCode::UnknownAuthFailure,
1955 ];
1956 for code in &codes {
1957 assert_eq!(
1958 code.redaction_policy(),
1959 "redact-secrets",
1960 "code {code:?} missing redact-secrets policy",
1961 );
1962 }
1963 }
1964
1965 #[test]
1968 fn e2e_hints_enrichment_completeness() {
1969 let providers: &[(&str, &str)] = &[
1970 ("anthropic", "Missing API key for Anthropic"),
1971 ("openai", "OpenAI API error (HTTP 401): invalid key"),
1972 ("cohere", "insufficient_quota"),
1973 ("google", "Missing API key for Google"),
1974 ];
1975 for (provider, message) in providers {
1976 let err = Error::provider(*provider, *message);
1977 let hints = err.hints();
1978 assert!(
1979 context_value(&hints, "diagnostic_code").is_some(),
1980 "missing diagnostic_code for {provider}"
1981 );
1982 assert!(
1983 context_value(&hints, "diagnostic_remediation").is_some(),
1984 "missing diagnostic_remediation for {provider}"
1985 );
1986 assert_eq!(
1987 context_value(&hints, "redaction_policy"),
1988 Some("redact-secrets"),
1989 "wrong redaction_policy for {provider}"
1990 );
1991 }
1992 }
1993
1994 #[test]
1996 fn e2e_hints_always_include_provider_context() {
1997 let providers = [
1998 "anthropic",
1999 "openai",
2000 "azure-openai",
2001 "cohere",
2002 "google",
2003 "groq",
2004 "deepseek",
2005 ];
2006 for provider in &providers {
2007 let err = Error::provider(*provider, "some error");
2008 let hints = err.hints();
2009 assert_eq!(
2010 context_value(&hints, "provider"),
2011 Some(*provider),
2012 "missing provider context for {provider}"
2013 );
2014 }
2015 }
2016
2017 #[test]
2019 fn e2e_alias_env_key_consistency() {
2020 let alias_to_canonical: &[(&str, &str)] = &[
2021 ("gemini", "google"),
2022 ("azure", "azure-openai"),
2023 ("copilot", "github-copilot"),
2024 ("dashscope", "alibaba"),
2025 ("qwen", "alibaba"),
2026 ("kimi", "moonshotai"),
2027 ("moonshot", "moonshotai"),
2028 ("bedrock", "amazon-bedrock"),
2029 ("sap", "sap-ai-core"),
2030 ];
2031 for (alias, canonical) in alias_to_canonical {
2032 let alias_keys = crate::provider_metadata::provider_auth_env_keys(alias);
2033 let canonical_keys = crate::provider_metadata::provider_auth_env_keys(canonical);
2034 assert_eq!(
2035 alias_keys, canonical_keys,
2036 "alias {alias} env keys differ from canonical {canonical}"
2037 );
2038 }
2039 }
2040
2041 #[test]
2043 fn e2e_all_native_providers_have_env_keys() {
2044 let native_providers = [
2045 "anthropic",
2046 "openai",
2047 "google",
2048 "cohere",
2049 "azure-openai",
2050 "amazon-bedrock",
2051 "github-copilot",
2052 "sap-ai-core",
2053 ];
2054 for provider in &native_providers {
2055 let keys = crate::provider_metadata::provider_auth_env_keys(provider);
2056 assert!(!keys.is_empty(), "provider {provider} has no auth env keys");
2057 }
2058 }
2059
2060 #[test]
2063 fn e2e_error_messages_never_contain_secrets() {
2064 let fake_key = "sk-proj-FAKE123456789abcdef";
2065 let err1 = Error::provider("openai", "OpenAI API error (HTTP 401): Invalid API key");
2067 let err2 = Error::provider("anthropic", "Missing API key for Anthropic");
2068 let err3 = Error::auth("OAuth token exchange failed");
2069
2070 for err in [&err1, &err2, &err3] {
2071 let display = err.to_string();
2072 assert!(
2073 !display.contains(fake_key),
2074 "error message contains secret: {display}"
2075 );
2076 let hints = err.hints();
2077 for hint in &hints.hints {
2078 assert!(!hint.contains(fake_key), "hint contains secret: {hint}");
2079 }
2080 for (key, value) in &hints.context {
2081 assert!(
2082 !value.contains(fake_key),
2083 "context {key} contains secret: {value}"
2084 );
2085 }
2086 }
2087 }
2088
2089 #[test]
2092 fn e2e_bedrock_credential_chain_diagnostic() {
2093 let err = Error::provider("amazon-bedrock", "No credential source configured");
2094 let d = err
2095 .auth_diagnostic()
2096 .expect("expected credential chain diagnostic");
2097 assert_eq!(d.code, AuthDiagnosticCode::MissingCredentialChain);
2098 }
2099
2100 #[test]
2102 fn e2e_auth_variant_diagnostics() {
2103 let cases: &[(&str, AuthDiagnosticCode)] = &[
2104 ("Missing API key", AuthDiagnosticCode::MissingApiKey),
2105 ("401 unauthorized", AuthDiagnosticCode::InvalidApiKey),
2106 ("insufficient_quota", AuthDiagnosticCode::QuotaExceeded),
2107 (
2108 "Missing authorization code",
2109 AuthDiagnosticCode::MissingOAuthAuthorizationCode,
2110 ),
2111 (
2112 "Token exchange failed",
2113 AuthDiagnosticCode::OAuthTokenExchangeFailed,
2114 ),
2115 (
2116 "OAuth token refresh failed",
2117 AuthDiagnosticCode::OAuthTokenRefreshFailed,
2118 ),
2119 (
2120 "Missing deployment",
2121 AuthDiagnosticCode::MissingAzureDeployment,
2122 ),
2123 ("Missing region", AuthDiagnosticCode::MissingRegion),
2124 ("Missing project", AuthDiagnosticCode::MissingProject),
2125 ("Missing profile", AuthDiagnosticCode::MissingProfile),
2126 ("Missing endpoint", AuthDiagnosticCode::MissingEndpoint),
2127 (
2128 "credential chain not configured",
2129 AuthDiagnosticCode::MissingCredentialChain,
2130 ),
2131 ];
2132 for (message, expected_code) in cases {
2133 let err = Error::auth(*message);
2134 let d = err.auth_diagnostic().expect("expected auth diagnostic");
2135 assert_eq!(
2136 d.code, *expected_code,
2137 "wrong code for Auth({message}): {:?}",
2138 d.code
2139 );
2140 }
2141 }
2142
2143 #[test]
2145 fn e2e_classifier_case_insensitive() {
2146 let variants = ["MISSING API KEY", "Missing Api Key", "missing api key"];
2147 for msg in &variants {
2148 let err = Error::provider("openai", *msg);
2149 let d = err.auth_diagnostic().expect("expected auth diagnostic");
2150 assert_eq!(
2151 d.code,
2152 AuthDiagnosticCode::MissingApiKey,
2153 "failed for: {msg}"
2154 );
2155 }
2156 }
2157
2158 #[test]
2160 fn e2e_non_auth_variants_no_diagnostic() {
2161 let errors: Vec<Error> = vec![
2162 Error::config("bad json"),
2163 Error::session("timeout"),
2164 Error::tool("bash", "not found"),
2165 Error::validation("missing field"),
2166 Error::extension("crash"),
2167 Error::api("network error"),
2168 Error::Aborted,
2169 ];
2170 for err in &errors {
2171 assert!(
2172 err.auth_diagnostic().is_none(),
2173 "unexpected diagnostic for: {err}"
2174 );
2175 }
2176 }
2177
2178 #[test]
2180 fn e2e_quota_messages_cross_provider() {
2181 let messages = [
2182 "insufficient_quota",
2183 "quota exceeded",
2184 "billing hard limit reached",
2185 "billing_not_active",
2186 "not enough credits",
2187 "credit balance is too low",
2188 ];
2189 for msg in &messages {
2190 let err = Error::provider("openai", *msg);
2191 let d = err.auth_diagnostic().expect("expected auth diagnostic");
2192 assert_eq!(
2193 d.code,
2194 AuthDiagnosticCode::QuotaExceeded,
2195 "wrong code for: {msg}"
2196 );
2197 }
2198 }
2199
2200 #[test]
2202 fn e2e_openai_compatible_providers_env_keys() {
2203 let providers_and_keys: &[(&str, &str)] = &[
2204 ("groq", "GROQ_API_KEY"),
2205 ("deepinfra", "DEEPINFRA_API_KEY"),
2206 ("cerebras", "CEREBRAS_API_KEY"),
2207 ("openrouter", "OPENROUTER_API_KEY"),
2208 ("mistral", "MISTRAL_API_KEY"),
2209 ("moonshotai", "MOONSHOT_API_KEY"),
2210 ("moonshotai", "KIMI_API_KEY"),
2211 ("alibaba", "DASHSCOPE_API_KEY"),
2212 ("alibaba", "QWEN_API_KEY"),
2213 ("alibaba-us", "DASHSCOPE_API_KEY"),
2214 ("alibaba-us", "QWEN_API_KEY"),
2215 ("deepseek", "DEEPSEEK_API_KEY"),
2216 ("perplexity", "PERPLEXITY_API_KEY"),
2217 ("xai", "XAI_API_KEY"),
2218 ];
2219 for (provider, expected_key) in providers_and_keys {
2220 let keys = crate::provider_metadata::provider_auth_env_keys(provider);
2221 assert!(
2222 keys.contains(expected_key),
2223 "provider {provider} missing env key {expected_key}, got: {keys:?}"
2224 );
2225 }
2226 }
2227
2228 #[test]
2230 fn e2e_key_hint_format_consistency() {
2231 let hint = provider_key_hint("anthropic");
2233 assert!(hint.contains("ANTHROPIC_API_KEY"), "hint: {hint}");
2234 assert!(hint.contains("/login"), "hint: {hint}");
2235
2236 let hint = provider_key_hint("github-copilot");
2238 assert!(hint.contains("/login"), "hint: {hint}");
2239
2240 let hint = provider_key_hint("openai");
2242 assert!(hint.contains("OPENAI_API_KEY"), "hint: {hint}");
2243 assert!(!hint.contains("/login"), "hint: {hint}");
2244
2245 let hint = provider_key_hint("my-custom-proxy");
2247 assert!(hint.contains("my-custom-proxy"), "hint: {hint}");
2248 }
2249
2250 #[test]
2252 fn e2e_empty_message_no_diagnostic() {
2253 let err = Error::provider("openai", "");
2254 assert!(err.auth_diagnostic().is_none());
2255 }
2256
2257 #[test]
2260 fn overflow_prompt_is_too_long() {
2261 assert!(is_context_overflow(
2262 "prompt is too long: 150000 tokens",
2263 None,
2264 None
2265 ));
2266 }
2267
2268 #[test]
2269 fn overflow_input_too_long_for_model() {
2270 assert!(is_context_overflow(
2271 "input is too long for requested model",
2272 None,
2273 None,
2274 ));
2275 }
2276
2277 #[test]
2278 fn overflow_exceeds_context_window() {
2279 assert!(is_context_overflow(
2280 "exceeds the context window",
2281 None,
2282 None
2283 ));
2284 }
2285
2286 #[test]
2287 fn overflow_input_token_count_exceeds_maximum() {
2288 assert!(is_context_overflow(
2289 "input token count of 50000 exceeds the maximum of 32000",
2290 None,
2291 None,
2292 ));
2293 }
2294
2295 #[test]
2296 fn overflow_maximum_prompt_length() {
2297 assert!(is_context_overflow(
2298 "maximum prompt length is 32000",
2299 None,
2300 None,
2301 ));
2302 }
2303
2304 #[test]
2305 fn overflow_reduce_length_of_messages() {
2306 assert!(is_context_overflow(
2307 "reduce the length of the messages",
2308 None,
2309 None,
2310 ));
2311 }
2312
2313 #[test]
2314 fn overflow_maximum_context_length() {
2315 assert!(is_context_overflow(
2316 "maximum context length is 128000 tokens",
2317 None,
2318 None,
2319 ));
2320 }
2321
2322 #[test]
2323 fn overflow_exceeds_limit_of() {
2324 assert!(is_context_overflow(
2325 "exceeds the limit of 200000",
2326 None,
2327 None,
2328 ));
2329 }
2330
2331 #[test]
2332 fn overflow_exceeds_available_context_size() {
2333 assert!(is_context_overflow(
2334 "exceeds the available context size",
2335 None,
2336 None,
2337 ));
2338 }
2339
2340 #[test]
2341 fn overflow_greater_than_context_length() {
2342 assert!(is_context_overflow(
2343 "greater than the context length",
2344 None,
2345 None,
2346 ));
2347 }
2348
2349 #[test]
2350 fn overflow_context_window_exceeds_limit() {
2351 assert!(is_context_overflow(
2352 "context window exceeds limit",
2353 None,
2354 None,
2355 ));
2356 }
2357
2358 #[test]
2359 fn overflow_exceeded_model_token_limit() {
2360 assert!(is_context_overflow(
2361 "exceeded model token limit",
2362 None,
2363 None,
2364 ));
2365 }
2366
2367 #[test]
2368 fn overflow_context_length_exceeded_underscore() {
2369 assert!(is_context_overflow("context_length_exceeded", None, None));
2370 }
2371
2372 #[test]
2373 fn overflow_context_length_exceeded_space() {
2374 assert!(is_context_overflow("context length exceeded", None, None));
2375 }
2376
2377 #[test]
2378 fn overflow_too_many_tokens() {
2379 assert!(is_context_overflow("too many tokens", None, None));
2380 }
2381
2382 #[test]
2383 fn overflow_token_limit_exceeded() {
2384 assert!(is_context_overflow("token limit exceeded", None, None));
2385 }
2386
2387 #[test]
2388 fn overflow_cerebras_400_no_body() {
2389 assert!(is_context_overflow("400 (no body)", None, None));
2390 }
2391
2392 #[test]
2393 fn overflow_cerebras_413_no_body() {
2394 assert!(is_context_overflow("413 (no body)", None, None));
2395 }
2396
2397 #[test]
2398 fn overflow_mistral_status_code_pattern() {
2399 assert!(is_context_overflow("413 status code (no body)", None, None,));
2400 }
2401
2402 #[test]
2403 fn overflow_case_insensitive() {
2404 assert!(is_context_overflow("PROMPT IS TOO LONG", None, None));
2405 assert!(is_context_overflow("Token Limit Exceeded", None, None));
2406 }
2407
2408 #[test]
2409 fn overflow_silent_usage_exceeds_window() {
2410 assert!(is_context_overflow(
2411 "some error",
2412 Some(250_000),
2413 Some(200_000),
2414 ));
2415 }
2416
2417 #[test]
2418 fn overflow_usage_within_window() {
2419 assert!(!is_context_overflow(
2420 "some error",
2421 Some(100_000),
2422 Some(200_000),
2423 ));
2424 }
2425
2426 #[test]
2427 fn overflow_no_usage_info() {
2428 assert!(!is_context_overflow("some error", None, None));
2429 }
2430
2431 #[test]
2432 fn overflow_negative_not_matched() {
2433 assert!(!is_context_overflow("rate limit exceeded", None, None));
2434 assert!(!is_context_overflow("server error 500", None, None));
2435 assert!(!is_context_overflow("authentication error", None, None));
2436 assert!(!is_context_overflow("", None, None));
2437 }
2438
2439 #[test]
2442 fn retryable_rate_limit() {
2443 assert!(is_retryable_error("429 rate limit exceeded", None, None));
2444 }
2445
2446 #[test]
2447 fn retryable_too_many_requests() {
2448 assert!(is_retryable_error("too many requests", None, None));
2449 }
2450
2451 #[test]
2452 fn retryable_overloaded() {
2453 assert!(is_retryable_error("API overloaded", None, None));
2454 }
2455
2456 #[test]
2457 fn retryable_server_500() {
2458 assert!(is_retryable_error(
2459 "HTTP 500 internal server error",
2460 None,
2461 None
2462 ));
2463 }
2464
2465 #[test]
2466 fn retryable_server_502() {
2467 assert!(is_retryable_error("502 bad gateway", None, None));
2468 }
2469
2470 #[test]
2471 fn retryable_server_503() {
2472 assert!(is_retryable_error("503 service unavailable", None, None));
2473 }
2474
2475 #[test]
2476 fn retryable_server_504() {
2477 assert!(is_retryable_error("504 gateway timeout", None, None));
2478 }
2479
2480 #[test]
2481 fn retryable_service_unavailable() {
2482 assert!(is_retryable_error("service unavailable", None, None));
2483 }
2484
2485 #[test]
2486 fn retryable_server_error() {
2487 assert!(is_retryable_error("server error", None, None));
2488 }
2489
2490 #[test]
2491 fn retryable_internal_error() {
2492 assert!(is_retryable_error("internal error occurred", None, None));
2493 }
2494
2495 #[test]
2496 fn retryable_connection_error() {
2497 assert!(is_retryable_error("connection error", None, None));
2498 }
2499
2500 #[test]
2501 fn retryable_connection_refused() {
2502 assert!(is_retryable_error("connection refused", None, None));
2503 }
2504
2505 #[test]
2506 fn retryable_other_side_closed() {
2507 assert!(is_retryable_error("other side closed", None, None));
2508 }
2509
2510 #[test]
2511 fn retryable_fetch_failed() {
2512 assert!(is_retryable_error("fetch failed", None, None));
2513 }
2514
2515 #[test]
2516 fn retryable_upstream_connect() {
2517 assert!(is_retryable_error("upstream connect error", None, None));
2518 }
2519
2520 #[test]
2521 fn retryable_reset_before_headers() {
2522 assert!(is_retryable_error("reset before headers", None, None));
2523 }
2524
2525 #[test]
2526 fn retryable_terminated() {
2527 assert!(is_retryable_error("request terminated", None, None));
2528 }
2529
2530 #[test]
2531 fn retryable_retry_delay() {
2532 assert!(is_retryable_error("retry delay 30s", None, None));
2533 }
2534
2535 #[test]
2536 fn not_retryable_context_overflow() {
2537 assert!(!is_retryable_error("prompt is too long", None, None));
2539 assert!(!is_retryable_error(
2540 "exceeds the context window",
2541 None,
2542 None,
2543 ));
2544 assert!(!is_retryable_error("too many tokens", None, None));
2545 }
2546
2547 #[test]
2548 fn not_retryable_auth_errors() {
2549 assert!(!is_retryable_error("invalid api key", None, None));
2550 assert!(!is_retryable_error("unauthorized access", None, None));
2551 assert!(!is_retryable_error("permission denied", None, None));
2552 }
2553
2554 #[test]
2555 fn not_retryable_empty_message() {
2556 assert!(!is_retryable_error("", None, None));
2557 }
2558
2559 #[test]
2560 fn not_retryable_generic_error() {
2561 assert!(!is_retryable_error("something went wrong", None, None));
2562 }
2563
2564 #[test]
2565 fn not_retryable_silent_overflow() {
2566 assert!(!is_retryable_error(
2569 "500 server error",
2570 Some(250_000),
2571 Some(200_000),
2572 ));
2573 }
2574
2575 #[test]
2576 fn retryable_case_insensitive() {
2577 assert!(is_retryable_error("RATE LIMIT", None, None));
2578 assert!(is_retryable_error("Service Unavailable", None, None));
2579 }
2580
2581 #[test]
2584 fn transient_io_kinds_classified_from_type() {
2585 for kind in [
2586 std::io::ErrorKind::ConnectionReset,
2587 std::io::ErrorKind::ConnectionAborted,
2588 std::io::ErrorKind::BrokenPipe,
2589 std::io::ErrorKind::UnexpectedEof,
2590 std::io::ErrorKind::NotConnected,
2591 std::io::ErrorKind::TimedOut,
2592 ] {
2593 assert!(io_kind_is_transient(kind), "{kind:?} should be transient");
2594 }
2595 for kind in [
2596 std::io::ErrorKind::PermissionDenied,
2597 std::io::ErrorKind::NotFound,
2598 std::io::ErrorKind::InvalidData,
2599 std::io::ErrorKind::AlreadyExists,
2600 ] {
2601 assert!(
2602 !io_kind_is_transient(kind),
2603 "{kind:?} should NOT be transient"
2604 );
2605 }
2606 }
2607
2608 #[test]
2609 fn typed_io_error_is_transient_without_string_match() {
2610 let reset = Error::Io(Box::new(std::io::Error::new(
2613 std::io::ErrorKind::ConnectionReset,
2614 "opaque cause with no keywords",
2615 )));
2616 assert!(reset.is_transient());
2617 assert!(!is_retryable_error(&reset.to_string(), None, None));
2618
2619 let denied = Error::Io(Box::new(std::io::Error::new(
2621 std::io::ErrorKind::PermissionDenied,
2622 "denied",
2623 )));
2624 assert!(!denied.is_transient());
2625
2626 assert!(!Error::auth("invalid key").is_transient());
2628 assert!(!Error::api("400 bad request").is_transient());
2629 }
2630
2631 #[test]
2632 fn is_transient_walks_wrapped_io_error() {
2633 let inner = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "broken");
2636 let outer = Error::Io(Box::new(std::io::Error::other(inner)));
2637 assert!(outer.is_transient());
2638 }
2639
2640 #[test]
2647 fn sse_transient_drop_retryable_end_to_end() {
2648 let io_err = std::io::Error::new(
2650 std::io::ErrorKind::UnexpectedEof,
2651 "tls connection closed without close_notify",
2652 );
2653 let flattened = Error::sse(&io_err).to_string();
2654 assert!(
2655 is_retryable_error(&flattened, None, None),
2656 "flattened transient SSE error should be retryable: {flattened}"
2657 );
2658
2659 let fatal = Error::sse(&std::io::Error::new(
2661 std::io::ErrorKind::InvalidData,
2662 "invalid utf-8 in event",
2663 ));
2664 assert!(!is_retryable_error(&fatal.to_string(), None, None));
2665 }
2666
2667 #[test]
2671 fn issue_118_reported_strings_retryable() {
2672 assert!(is_retryable_error(
2673 "API error: HTTP connection closed before headers (transient connection drop)",
2674 None,
2675 None,
2676 ));
2677 assert!(is_retryable_error(
2678 "API error: SSE error: tls connection closed without close_notify \
2679 (transient connection drop)",
2680 None,
2681 None,
2682 ));
2683 assert!(is_retryable_error(
2685 "HTTP connection closed before headers",
2686 None,
2687 None,
2688 ));
2689 assert!(is_retryable_error(
2690 "tls connection closed without close_notify",
2691 None,
2692 None,
2693 ));
2694 }
2695
2696 mod proptest_error {
2697 use super::*;
2698 use proptest::prelude::*;
2699
2700 const ALL_DIAGNOSTIC_CODES: &[AuthDiagnosticCode] = &[
2701 AuthDiagnosticCode::MissingApiKey,
2702 AuthDiagnosticCode::InvalidApiKey,
2703 AuthDiagnosticCode::QuotaExceeded,
2704 AuthDiagnosticCode::MissingOAuthAuthorizationCode,
2705 AuthDiagnosticCode::OAuthTokenExchangeFailed,
2706 AuthDiagnosticCode::OAuthTokenRefreshFailed,
2707 AuthDiagnosticCode::MissingAzureDeployment,
2708 AuthDiagnosticCode::MissingRegion,
2709 AuthDiagnosticCode::MissingProject,
2710 AuthDiagnosticCode::MissingProfile,
2711 AuthDiagnosticCode::MissingEndpoint,
2712 AuthDiagnosticCode::MissingCredentialChain,
2713 AuthDiagnosticCode::UnknownAuthFailure,
2714 ];
2715
2716 proptest! {
2717 #[test]
2719 fn as_str_non_empty_dotted(idx in 0..13usize) {
2720 let code = ALL_DIAGNOSTIC_CODES[idx];
2721 let s = code.as_str();
2722 assert!(!s.is_empty());
2723 assert!(s.contains('.'), "diagnostic code should be dotted: {s}");
2724 }
2725
2726 #[test]
2728 fn as_str_unique(a in 0..13usize, b in 0..13usize) {
2729 if a != b {
2730 assert_ne!(
2731 ALL_DIAGNOSTIC_CODES[a].as_str(),
2732 ALL_DIAGNOSTIC_CODES[b].as_str()
2733 );
2734 }
2735 }
2736
2737 #[test]
2739 fn remediation_non_empty(idx in 0..13usize) {
2740 let code = ALL_DIAGNOSTIC_CODES[idx];
2741 assert!(!code.remediation().is_empty());
2742 }
2743
2744 #[test]
2746 fn redaction_policy_constant(idx in 0..13usize) {
2747 let code = ALL_DIAGNOSTIC_CODES[idx];
2748 assert_eq!(code.redaction_policy(), "redact-secrets");
2749 }
2750
2751 #[test]
2753 fn hostcall_code_known(msg in "[a-z ]{1,20}") {
2754 let known = ["invalid_request", "io", "denied", "timeout", "internal"];
2755 let errors = [
2756 Error::config(msg.clone()),
2757 Error::session(msg.clone()),
2758 Error::auth(msg.clone()),
2759 Error::validation(msg.clone()),
2760 Error::api(msg),
2761 ];
2762 for e in &errors {
2763 assert!(known.contains(&e.hostcall_error_code()));
2764 }
2765 }
2766
2767 #[test]
2769 fn category_code_format(msg in "[a-z ]{1,20}") {
2770 let errors = [
2771 Error::config(msg.clone()),
2772 Error::session(msg.clone()),
2773 Error::auth(msg.clone()),
2774 Error::validation(msg.clone()),
2775 Error::extension(msg.clone()),
2776 Error::api(msg),
2777 ];
2778 for e in &errors {
2779 let code = e.category_code();
2780 assert!(!code.is_empty());
2781 assert!(code.chars().all(|c| c.is_ascii_lowercase()));
2782 }
2783 }
2784
2785 #[test]
2787 fn context_overflow_token_based(
2788 input_tokens in 100_001..500_000u64,
2789 window in 1..100_000u32
2790 ) {
2791 assert!(is_context_overflow(
2792 "",
2793 Some(input_tokens),
2794 Some(window)
2795 ));
2796 }
2797
2798 #[test]
2800 fn context_overflow_within_window(
2801 window in 100..200_000u32,
2802 offset in 0..100u64
2803 ) {
2804 let input = u64::from(window).saturating_sub(offset);
2805 assert!(!is_context_overflow(
2806 "some normal error",
2807 Some(input),
2808 Some(window)
2809 ));
2810 }
2811
2812 #[test]
2814 fn context_overflow_pattern_detection(idx in 0..OVERFLOW_PATTERNS.len()) {
2815 let pattern = OVERFLOW_PATTERNS[idx];
2816 assert!(is_context_overflow(pattern, None, None));
2817 }
2818
2819 #[test]
2821 fn context_overflow_case_insensitive(idx in 0..OVERFLOW_PATTERNS.len()) {
2822 let pattern = OVERFLOW_PATTERNS[idx];
2823 assert!(is_context_overflow(&pattern.to_uppercase(), None, None));
2824 }
2825
2826 #[test]
2828 fn retryable_empty_is_false(_dummy in 0..1u8) {
2829 assert!(!is_retryable_error("", None, None));
2830 }
2831
2832 #[test]
2834 fn overflow_not_retryable(idx in 0..OVERFLOW_PATTERNS.len()) {
2835 let pattern = OVERFLOW_PATTERNS[idx];
2836 assert!(!is_retryable_error(pattern, None, None));
2837 }
2838
2839 #[test]
2841 fn retryable_known_patterns(idx in 0..8usize) {
2842 let patterns = [
2843 "overloaded",
2844 "rate limit exceeded",
2845 "too many requests",
2846 "429 status code",
2847 "502 bad gateway",
2848 "503 service unavailable",
2849 "connection error",
2850 "fetch failed",
2851 ];
2852 assert!(is_retryable_error(patterns[idx], None, None));
2853 }
2854
2855 #[test]
2857 fn random_not_retryable(s in "[a-z]{20,40}") {
2858 assert!(!is_retryable_error(&s, None, None));
2859 }
2860
2861 #[test]
2863 fn constructor_category_consistency(msg in "[a-z]{1,10}") {
2864 assert_eq!(Error::config(&msg).category_code(), "config");
2865 assert_eq!(Error::session(&msg).category_code(), "session");
2866 assert_eq!(Error::auth(&msg).category_code(), "auth");
2867 assert_eq!(Error::validation(&msg).category_code(), "validation");
2868 assert_eq!(Error::extension(&msg).category_code(), "extension");
2869 assert_eq!(Error::api(&msg).category_code(), "api");
2870 }
2871 }
2872 }
2873}