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