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 => Self::session(value.to_string()),
880 }
881 }
882}
883
884impl From<serde_json::Error> for Error {
885 fn from(value: serde_json::Error) -> Self {
886 Self::Json(Box::new(value))
887 }
888}
889
890impl From<sqlmodel_core::Error> for Error {
891 fn from(value: sqlmodel_core::Error) -> Self {
892 Self::Sqlite(Box::new(value))
893 }
894}
895
896const OVERFLOW_PATTERNS: &[&str] = &[
900 "prompt is too long",
901 "input is too long for requested model",
902 "exceeds the context window",
903 "reduce the length of the messages",
906 "exceeds the available context size",
909 "greater than the context length",
910 "context window exceeds limit",
911 "exceeded model token limit",
912 "too many tokens",
914 "token limit exceeded",
915];
916
917static OVERFLOW_RE: OnceLock<regex::RegexSet> = OnceLock::new();
918static RETRYABLE_RE: OnceLock<regex::Regex> = OnceLock::new();
919
920pub fn is_context_overflow(
927 error_message: &str,
928 usage_input_tokens: Option<u64>,
929 context_window: Option<u32>,
930) -> bool {
931 if let (Some(input_tokens), Some(window)) = (usage_input_tokens, context_window) {
933 if input_tokens > u64::from(window) {
934 return true;
935 }
936 }
937
938 let lower = error_message.to_lowercase();
939
940 if OVERFLOW_PATTERNS
942 .iter()
943 .any(|pattern| lower.contains(pattern))
944 {
945 return true;
946 }
947
948 let re = OVERFLOW_RE.get_or_init(|| {
950 regex::RegexSet::new([
951 r"input token count.*exceeds the maximum",
952 r"maximum prompt length is \d+",
953 r"maximum context length is \d+ tokens",
954 r"exceeds the limit of \d+",
955 r"context[_ ]length[_ ]exceeded",
956 r"^4(00|13)\s*(status code)?\s*\(no body\)",
958 ])
959 .expect("overflow regex set")
960 });
961
962 re.is_match(&lower)
963}
964
965pub fn is_retryable_error(
974 error_message: &str,
975 usage_input_tokens: Option<u64>,
976 context_window: Option<u32>,
977) -> bool {
978 if error_message.is_empty() {
979 return false;
980 }
981
982 if is_context_overflow(error_message, usage_input_tokens, context_window) {
984 return false;
985 }
986
987 let lower = error_message.to_lowercase();
988
989 let re = RETRYABLE_RE.get_or_init(|| {
990 regex::Regex::new(
991 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",
992 )
993 .expect("retryable regex")
994 });
995
996 re.is_match(&lower)
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002
1003 fn context_value<'a>(hints: &'a ErrorHints, key: &str) -> Option<&'a str> {
1004 hints
1005 .context
1006 .iter()
1007 .find(|(k, _)| k == key)
1008 .map(|(_, value)| value.as_str())
1009 }
1010
1011 #[test]
1014 fn error_config_constructor() {
1015 let err = Error::config("bad config");
1016 assert!(matches!(err, Error::Config(ref msg) if msg == "bad config"));
1017 }
1018
1019 #[test]
1020 fn error_session_constructor() {
1021 let err = Error::session("session corrupted");
1022 assert!(matches!(err, Error::Session(ref msg) if msg == "session corrupted"));
1023 }
1024
1025 #[test]
1026 fn error_provider_constructor() {
1027 let err = Error::provider("anthropic", "timeout");
1028 assert!(matches!(err, Error::Provider { ref provider, ref message }
1029 if provider == "anthropic" && message == "timeout"));
1030 }
1031
1032 #[test]
1033 fn error_auth_constructor() {
1034 let err = Error::auth("missing key");
1035 assert!(matches!(err, Error::Auth(ref msg) if msg == "missing key"));
1036 }
1037
1038 #[test]
1039 fn error_tool_constructor() {
1040 let err = Error::tool("bash", "exit code 1");
1041 assert!(matches!(err, Error::Tool { ref tool, ref message }
1042 if tool == "bash" && message == "exit code 1"));
1043 }
1044
1045 #[test]
1046 fn error_validation_constructor() {
1047 let err = Error::validation("field required");
1048 assert!(matches!(err, Error::Validation(ref msg) if msg == "field required"));
1049 }
1050
1051 #[test]
1052 fn error_extension_constructor() {
1053 let err = Error::extension("manifest invalid");
1054 assert!(matches!(err, Error::Extension(ref msg) if msg == "manifest invalid"));
1055 }
1056
1057 #[test]
1058 fn error_api_constructor() {
1059 let err = Error::api("404 not found");
1060 assert!(matches!(err, Error::Api(ref msg) if msg == "404 not found"));
1061 }
1062
1063 #[test]
1064 fn error_category_code_is_stable() {
1065 assert_eq!(Error::auth("missing").category_code(), "auth");
1066 assert_eq!(Error::provider("openai", "429").category_code(), "provider");
1067 assert_eq!(Error::tool("bash", "failed").category_code(), "tool");
1068 assert_eq!(Error::Aborted.category_code(), "runtime");
1069 }
1070
1071 #[test]
1072 fn hints_include_error_category_context() {
1073 let hints = Error::tool("bash", "exit code 1").hints();
1074 assert_eq!(context_value(&hints, "error_category"), Some("tool"));
1075 }
1076
1077 #[test]
1080 fn error_config_display() {
1081 let err = Error::config("missing settings.json");
1082 let msg = err.to_string();
1083 assert!(msg.contains("Configuration error"));
1084 assert!(msg.contains("missing settings.json"));
1085 }
1086
1087 #[test]
1088 fn error_session_display() {
1089 let err = Error::session("tree corrupted");
1090 let msg = err.to_string();
1091 assert!(msg.contains("Session error"));
1092 assert!(msg.contains("tree corrupted"));
1093 }
1094
1095 #[test]
1096 fn error_session_not_found_display() {
1097 let err = Error::SessionNotFound {
1098 path: "/home/user/.pi/sessions/abc.jsonl".to_string(),
1099 };
1100 let msg = err.to_string();
1101 assert!(msg.contains("Session not found"));
1102 assert!(msg.contains("/home/user/.pi/sessions/abc.jsonl"));
1103 }
1104
1105 #[test]
1106 fn error_provider_display() {
1107 let err = Error::provider("openai", "429 too many requests");
1108 let msg = err.to_string();
1109 assert!(msg.contains("Provider error"));
1110 assert!(msg.contains("openai"));
1111 assert!(msg.contains("429 too many requests"));
1112 }
1113
1114 #[test]
1115 fn error_auth_display() {
1116 let err = Error::auth("API key expired");
1117 let msg = err.to_string();
1118 assert!(msg.contains("Authentication error"));
1119 assert!(msg.contains("API key expired"));
1120 }
1121
1122 #[test]
1123 fn error_tool_display() {
1124 let err = Error::tool("read", "file not found: /tmp/x.txt");
1125 let msg = err.to_string();
1126 assert!(msg.contains("Tool error"));
1127 assert!(msg.contains("read"));
1128 assert!(msg.contains("file not found: /tmp/x.txt"));
1129 }
1130
1131 #[test]
1132 fn error_validation_display() {
1133 let err = Error::validation("temperature must be 0-2");
1134 let msg = err.to_string();
1135 assert!(msg.contains("Validation error"));
1136 assert!(msg.contains("temperature must be 0-2"));
1137 }
1138
1139 #[test]
1140 fn error_extension_display() {
1141 let err = Error::extension("manifest parse failed");
1142 let msg = err.to_string();
1143 assert!(msg.contains("Extension error"));
1144 assert!(msg.contains("manifest parse failed"));
1145 }
1146
1147 #[test]
1148 fn error_aborted_display() {
1149 let err = Error::Aborted;
1150 let msg = err.to_string();
1151 assert!(msg.contains("Operation aborted"));
1152 }
1153
1154 #[test]
1155 fn error_api_display() {
1156 let err = Error::api("GitHub API error 403");
1157 let msg = err.to_string();
1158 assert!(msg.contains("API error"));
1159 assert!(msg.contains("GitHub API error 403"));
1160 }
1161
1162 #[test]
1163 fn error_io_display() {
1164 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
1165 let err = Error::from(io_err);
1166 let msg = err.to_string();
1167 assert!(msg.contains("IO error"));
1168 }
1169
1170 #[test]
1171 fn error_json_display() {
1172 let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
1173 let err = Error::from(json_err);
1174 let msg = err.to_string();
1175 assert!(msg.contains("JSON error"));
1176 }
1177
1178 #[test]
1181 fn error_from_io_error() {
1182 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
1183 let err: Error = io_err.into();
1184 assert!(matches!(err, Error::Io(_)));
1185 }
1186
1187 #[test]
1188 fn error_from_serde_json_error() {
1189 let json_err = serde_json::from_str::<serde_json::Value>("{invalid").unwrap_err();
1190 let err: Error = json_err.into();
1191 assert!(matches!(err, Error::Json(_)));
1192 }
1193
1194 #[test]
1197 fn hints_config_json_parse_error() {
1198 let err = Error::config("JSON parse error in settings.json");
1199 let h = err.hints();
1200 assert!(h.summary.contains("not valid JSON"));
1201 assert!(h.hints.iter().any(|s| s.contains("JSON formatting")));
1202 }
1203
1204 #[test]
1205 fn hints_config_missing_file() {
1206 let err = Error::config("config file not found: ~/.pi/settings");
1207 let h = err.hints();
1208 assert!(h.summary.contains("missing"));
1209 }
1210
1211 #[test]
1212 fn hints_config_generic() {
1213 let err = Error::config("unknown config issue");
1214 let h = err.hints();
1215 assert!(h.summary.contains("Configuration error"));
1216 }
1217
1218 #[test]
1219 fn hints_session_empty() {
1220 let err = Error::session("empty session file");
1221 let h = err.hints();
1222 assert!(h.summary.contains("empty") || h.summary.contains("corrupted"));
1223 }
1224
1225 #[test]
1226 fn hints_session_read_failure() {
1227 let err = Error::session("failed to read session directory");
1228 let h = err.hints();
1229 assert!(h.summary.contains("Failed to read"));
1230 }
1231
1232 #[test]
1233 fn hints_session_not_found() {
1234 let err = Error::SessionNotFound {
1235 path: "/tmp/session.jsonl".to_string(),
1236 };
1237 let h = err.hints();
1238 assert!(h.summary.contains("not found"));
1239 assert!(
1240 h.context
1241 .iter()
1242 .any(|(k, v)| k == "path" && v.contains("/tmp/session.jsonl"))
1243 );
1244 }
1245
1246 #[test]
1247 fn hints_provider_401() {
1248 let err = Error::provider("anthropic", "HTTP 401 unauthorized");
1249 let h = err.hints();
1250 assert!(h.summary.contains("authentication failed"));
1251 assert!(h.hints.iter().any(|s| s.contains("ANTHROPIC_API_KEY")));
1252 }
1253
1254 #[test]
1255 fn hints_provider_403() {
1256 let err = Error::provider("openai", "403 forbidden");
1257 let h = err.hints();
1258 assert!(h.summary.contains("forbidden"));
1259 }
1260
1261 #[test]
1262 fn hints_provider_429() {
1263 let err = Error::provider("anthropic", "429 rate limit");
1264 let h = err.hints();
1265 assert!(h.summary.contains("rate limited"));
1266 }
1267
1268 #[test]
1269 fn hints_provider_529() {
1270 let err = Error::provider("anthropic", "529 overloaded");
1271 let h = err.hints();
1272 assert!(h.summary.contains("overloaded"));
1273 }
1274
1275 #[test]
1276 fn hints_provider_timeout() {
1277 let err = Error::provider("openai", "request timed out");
1278 let h = err.hints();
1279 assert!(h.summary.contains("timed out"));
1280 }
1281
1282 #[test]
1283 fn hints_provider_400() {
1284 let err = Error::provider("gemini", "400 bad request");
1285 let h = err.hints();
1286 assert!(h.summary.contains("rejected"));
1287 }
1288
1289 #[test]
1290 fn hints_provider_500() {
1291 let err = Error::provider("cohere", "500 internal server error");
1292 let h = err.hints();
1293 assert!(h.summary.contains("server error"));
1294 }
1295
1296 #[test]
1297 fn hints_provider_generic() {
1298 let err = Error::provider("custom", "unknown issue");
1299 let h = err.hints();
1300 assert!(h.summary.contains("failed"));
1301 assert!(h.context.iter().any(|(k, _)| k == "provider"));
1302 }
1303
1304 #[test]
1305 fn hints_provider_key_hint_openai() {
1306 let err = Error::provider("openai", "401 invalid api key");
1307 let h = err.hints();
1308 assert!(h.hints.iter().any(|s| s.contains("OPENAI_API_KEY")));
1309 }
1310
1311 #[test]
1312 fn hints_provider_key_hint_gemini() {
1313 let err = Error::provider("gemini", "401 api key invalid");
1314 let h = err.hints();
1315 assert!(h.hints.iter().any(|s| s.contains("GOOGLE_API_KEY")));
1316 }
1317
1318 #[test]
1319 fn hints_provider_key_hint_openrouter() {
1320 let err = Error::provider("openrouter", "401 unauthorized");
1321 let h = err.hints();
1322 assert!(h.hints.iter().any(|s| s.contains("OPENROUTER_API_KEY")));
1323 }
1324
1325 #[test]
1326 fn hints_provider_key_hint_groq() {
1327 let err = Error::provider("groq", "401 unauthorized");
1328 let h = err.hints();
1329 assert!(h.hints.iter().any(|s| s.contains("GROQ_API_KEY")));
1330 }
1331
1332 #[test]
1333 fn hints_provider_key_hint_alias_dashscope() {
1334 let err = Error::provider("dashscope", "401 invalid api key");
1335 let h = err.hints();
1336 assert!(h.hints.iter().any(|s| s.contains("DASHSCOPE_API_KEY")));
1337 assert!(h.hints.iter().any(|s| s.contains("QWEN_API_KEY")));
1338 }
1339
1340 #[test]
1341 fn hints_provider_key_hint_alias_kimi() {
1342 let err = Error::provider("kimi", "401 invalid api key");
1343 let h = err.hints();
1344 assert!(h.hints.iter().any(|s| s.contains("MOONSHOT_API_KEY")));
1345 assert!(h.hints.iter().any(|s| s.contains("KIMI_API_KEY")));
1346 }
1347
1348 #[test]
1349 fn hints_provider_key_hint_azure() {
1350 let err = Error::provider("azure-openai", "401 unauthorized");
1351 let h = err.hints();
1352 assert!(h.hints.iter().any(|s| s.contains("AZURE_OPENAI_API_KEY")));
1353 }
1354
1355 #[test]
1356 fn hints_provider_key_hint_unknown() {
1357 let err = Error::provider("my-proxy", "401 unauthorized");
1358 let h = err.hints();
1359 assert!(h.hints.iter().any(|s| s.contains("my-proxy")));
1360 }
1361
1362 #[test]
1363 fn hints_auth_authorization_code() {
1364 let err = Error::auth("missing authorization code");
1365 let h = err.hints();
1366 assert!(h.summary.contains("OAuth"));
1367 }
1368
1369 #[test]
1370 fn hints_auth_token_exchange() {
1371 let err = Error::auth("token exchange failed");
1372 let h = err.hints();
1373 assert!(h.summary.contains("token exchange"));
1374 }
1375
1376 #[test]
1377 fn hints_auth_generic() {
1378 let err = Error::auth("unknown auth issue");
1379 let h = err.hints();
1380 assert!(h.summary.contains("Authentication error"));
1381 }
1382
1383 #[test]
1384 fn auth_diagnostic_provider_invalid_key_code_and_context() {
1385 let err = Error::provider("openai", "HTTP 401 unauthorized");
1386 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1387 assert_eq!(diagnostic.code, AuthDiagnosticCode::InvalidApiKey);
1388 assert_eq!(diagnostic.code.as_str(), "auth.invalid_api_key");
1389 assert_eq!(diagnostic.redaction_policy, "redact-secrets");
1390
1391 let hints = err.hints();
1392 assert_eq!(
1393 context_value(&hints, "diagnostic_code"),
1394 Some("auth.invalid_api_key")
1395 );
1396 assert_eq!(
1397 context_value(&hints, "redaction_policy"),
1398 Some("redact-secrets")
1399 );
1400 }
1401
1402 #[test]
1403 fn auth_diagnostic_missing_key_phrase_for_oai_provider() {
1404 let err = Error::provider(
1405 "openrouter",
1406 "You didn't provide an API key in the Authorization header",
1407 );
1408 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1409 assert_eq!(diagnostic.code, AuthDiagnosticCode::MissingApiKey);
1410 assert_eq!(diagnostic.code.as_str(), "auth.missing_api_key");
1411 }
1412
1413 #[test]
1414 fn auth_diagnostic_revoked_key_maps_invalid() {
1415 let err = Error::provider("deepseek", "API key revoked for this project");
1416 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1417 assert_eq!(diagnostic.code, AuthDiagnosticCode::InvalidApiKey);
1418 assert_eq!(diagnostic.code.as_str(), "auth.invalid_api_key");
1419 }
1420
1421 #[test]
1422 fn auth_diagnostic_quota_exceeded_code_and_context() {
1423 let err = Error::provider(
1424 "openai",
1425 "HTTP 429 insufficient_quota: You exceeded your current quota",
1426 );
1427 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1428 assert_eq!(diagnostic.code, AuthDiagnosticCode::QuotaExceeded);
1429 assert_eq!(diagnostic.code.as_str(), "auth.quota_exceeded");
1430 assert_eq!(
1431 diagnostic.remediation,
1432 "Verify billing/quota limits for this API key or organization, then retry."
1433 );
1434
1435 let hints = err.hints();
1436 assert_eq!(
1437 context_value(&hints, "diagnostic_code"),
1438 Some("auth.quota_exceeded")
1439 );
1440 assert!(
1441 hints
1442 .hints
1443 .iter()
1444 .any(|s| s.contains("billing") || s.contains("quota")),
1445 "quota/billing guidance should be present"
1446 );
1447 }
1448
1449 #[test]
1450 fn auth_diagnostic_oauth_exchange_failure_code() {
1451 let err = Error::auth("Token exchange failed: invalid_grant");
1452 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1453 assert_eq!(
1454 diagnostic.code,
1455 AuthDiagnosticCode::OAuthTokenExchangeFailed
1456 );
1457 assert_eq!(
1458 diagnostic.remediation,
1459 "Retry login flow and verify token endpoint/client configuration."
1460 );
1461
1462 let hints = err.hints();
1463 assert_eq!(
1464 context_value(&hints, "diagnostic_code"),
1465 Some("auth.oauth.token_exchange_failed")
1466 );
1467 }
1468
1469 #[test]
1470 fn auth_diagnostic_azure_missing_deployment_code() {
1471 let err = Error::provider(
1472 "azure-openai",
1473 "Azure OpenAI provider requires resource+deployment; configure via models.json",
1474 );
1475 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1476 assert_eq!(diagnostic.code, AuthDiagnosticCode::MissingAzureDeployment);
1477 assert_eq!(diagnostic.code.as_str(), "config.azure.missing_deployment");
1478 }
1479
1480 #[test]
1481 fn auth_diagnostic_bedrock_missing_credential_chain_code() {
1482 let err = Error::provider(
1483 "amazon-bedrock",
1484 "AWS credential chain not configured for provider",
1485 );
1486 let diagnostic = err.auth_diagnostic().expect("diagnostic should be present");
1487 assert_eq!(diagnostic.code, AuthDiagnosticCode::MissingCredentialChain);
1488 assert_eq!(diagnostic.code.as_str(), "auth.credential_chain.missing");
1489 }
1490
1491 #[test]
1492 fn auth_diagnostic_absent_for_non_auth_provider_error() {
1493 let err = Error::provider("anthropic", "429 rate limit");
1494 assert!(err.auth_diagnostic().is_none());
1495
1496 let hints = err.hints();
1497 assert!(context_value(&hints, "diagnostic_code").is_none());
1498 }
1499
1500 #[test]
1506 fn native_provider_missing_key_anthropic() {
1507 let err = Error::provider(
1508 "anthropic",
1509 "Missing API key for Anthropic. Set ANTHROPIC_API_KEY or use `pi auth`.",
1510 );
1511 let d = err.auth_diagnostic().expect("diagnostic");
1512 assert_eq!(d.code, AuthDiagnosticCode::MissingApiKey);
1513 let hints = err.hints();
1514 assert_eq!(context_value(&hints, "provider"), Some("anthropic"));
1515 assert!(
1516 hints.summary.contains("missing"),
1517 "summary: {}",
1518 hints.summary
1519 );
1520 }
1521
1522 #[test]
1523 fn native_provider_missing_key_openai() {
1524 let err = Error::provider(
1525 "openai",
1526 "Missing API key for OpenAI. Set OPENAI_API_KEY or configure in settings.",
1527 );
1528 let d = err.auth_diagnostic().expect("diagnostic");
1529 assert_eq!(d.code, AuthDiagnosticCode::MissingApiKey);
1530 }
1531
1532 #[test]
1533 fn native_provider_missing_key_azure() {
1534 let err = Error::provider(
1535 "azure-openai",
1536 "Missing API key for Azure OpenAI. Set AZURE_OPENAI_API_KEY or configure in settings.",
1537 );
1538 let d = err.auth_diagnostic().expect("diagnostic");
1539 assert_eq!(d.code, AuthDiagnosticCode::MissingApiKey);
1540 }
1541
1542 #[test]
1543 fn native_provider_missing_key_cohere() {
1544 let err = Error::provider(
1545 "cohere",
1546 "Missing API key for Cohere. Set COHERE_API_KEY or configure in settings.",
1547 );
1548 let d = err.auth_diagnostic().expect("diagnostic");
1549 assert_eq!(d.code, AuthDiagnosticCode::MissingApiKey);
1550 }
1551
1552 #[test]
1553 fn native_provider_missing_key_gemini() {
1554 let err = Error::provider(
1555 "google",
1556 "Missing API key for Google/Gemini. Set GOOGLE_API_KEY or GEMINI_API_KEY.",
1557 );
1558 let d = err.auth_diagnostic().expect("diagnostic");
1559 assert_eq!(d.code, AuthDiagnosticCode::MissingApiKey);
1560 }
1561
1562 #[test]
1563 fn native_provider_http_401_anthropic() {
1564 let err = Error::provider(
1565 "anthropic",
1566 "Anthropic API error (HTTP 401): {\"error\":{\"type\":\"authentication_error\"}}",
1567 );
1568 let d = err.auth_diagnostic().expect("diagnostic");
1569 assert_eq!(d.code, AuthDiagnosticCode::InvalidApiKey);
1570 let hints = err.hints();
1571 assert!(hints.summary.contains("authentication failed"));
1572 }
1573
1574 #[test]
1575 fn native_provider_http_401_openai() {
1576 let err = Error::provider(
1577 "openai",
1578 "OpenAI API error (HTTP 401): Incorrect API key provided",
1579 );
1580 let d = err.auth_diagnostic().expect("diagnostic");
1581 assert_eq!(d.code, AuthDiagnosticCode::InvalidApiKey);
1582 }
1583
1584 #[test]
1585 fn native_provider_http_403_azure() {
1586 let err = Error::provider(
1587 "azure-openai",
1588 "Azure OpenAI API error (HTTP 403): Access denied",
1589 );
1590 let d = err.auth_diagnostic().expect("diagnostic");
1591 assert_eq!(d.code, AuthDiagnosticCode::InvalidApiKey);
1592 }
1593
1594 #[test]
1595 fn native_provider_http_429_quota_openai() {
1596 let err = Error::provider("openai", "OpenAI API error (HTTP 429): insufficient_quota");
1597 let d = err.auth_diagnostic().expect("diagnostic");
1598 assert_eq!(d.code, AuthDiagnosticCode::QuotaExceeded);
1599 }
1600
1601 #[test]
1602 fn native_provider_http_500_no_diagnostic() {
1603 let err = Error::provider(
1605 "anthropic",
1606 "Anthropic API error (HTTP 500): Internal server error",
1607 );
1608 assert!(err.auth_diagnostic().is_none());
1609 }
1610
1611 #[test]
1612 fn native_provider_hints_include_provider_context() {
1613 let err = Error::provider("cohere", "Cohere API error (HTTP 401): unauthorized");
1614 let hints = err.hints();
1615 assert_eq!(context_value(&hints, "provider"), Some("cohere"));
1616 assert!(context_value(&hints, "details").is_some());
1617 }
1618
1619 #[test]
1620 fn native_provider_diagnostic_enriches_hints_context() {
1621 let err = Error::provider(
1622 "google",
1623 "Missing API key for Google/Gemini. Set GOOGLE_API_KEY or GEMINI_API_KEY.",
1624 );
1625 let hints = err.hints();
1626 assert_eq!(
1627 context_value(&hints, "diagnostic_code"),
1628 Some("auth.missing_api_key")
1629 );
1630 assert_eq!(
1631 context_value(&hints, "redaction_policy"),
1632 Some("redact-secrets")
1633 );
1634 assert!(context_value(&hints, "diagnostic_remediation").is_some());
1635 }
1636
1637 #[test]
1638 fn hints_tool_not_found() {
1639 let err = Error::tool("bash", "command not found: xyz");
1640 let h = err.hints();
1641 assert!(h.summary.contains("not found"));
1642 }
1643
1644 #[test]
1645 fn hints_tool_generic() {
1646 let err = Error::tool("read", "unexpected error");
1647 let h = err.hints();
1648 assert!(h.summary.contains("execution failed"));
1649 }
1650
1651 #[test]
1652 fn hints_validation() {
1653 let err = Error::validation("invalid input");
1654 let h = err.hints();
1655 assert!(h.summary.contains("Validation"));
1656 }
1657
1658 #[test]
1659 fn hints_extension() {
1660 let err = Error::extension("load error");
1661 let h = err.hints();
1662 assert!(h.summary.contains("Extension"));
1663 }
1664
1665 #[test]
1666 fn hints_io_not_found() {
1667 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
1668 let err = Error::from(io_err);
1669 let h = err.hints();
1670 assert!(h.summary.contains("not found"));
1671 }
1672
1673 #[test]
1674 fn hints_io_permission_denied() {
1675 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1676 let err = Error::from(io_err);
1677 let h = err.hints();
1678 assert!(h.summary.contains("Permission denied"));
1679 }
1680
1681 #[test]
1682 fn hints_io_timed_out() {
1683 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1684 let err = Error::from(io_err);
1685 let h = err.hints();
1686 assert!(h.summary.contains("timed out"));
1687 }
1688
1689 #[test]
1690 fn hints_io_connection_refused() {
1691 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1692 let err = Error::from(io_err);
1693 let h = err.hints();
1694 assert!(h.summary.contains("Connection refused"));
1695 }
1696
1697 #[test]
1698 fn hints_io_generic() {
1699 let io_err = std::io::Error::other("something");
1700 let err = Error::from(io_err);
1701 let h = err.hints();
1702 assert!(h.summary.contains("I/O error"));
1703 }
1704
1705 #[test]
1706 fn hints_json() {
1707 let json_err = serde_json::from_str::<serde_json::Value>("broken").unwrap_err();
1708 let err = Error::from(json_err);
1709 let h = err.hints();
1710 assert!(h.summary.contains("JSON"));
1711 }
1712
1713 #[test]
1714 fn hints_aborted() {
1715 let err = Error::Aborted;
1716 let h = err.hints();
1717 assert!(h.summary.contains("aborted"));
1718 }
1719
1720 #[test]
1721 fn hints_api() {
1722 let err = Error::api("connection reset");
1723 let h = err.hints();
1724 assert!(h.summary.contains("API"));
1725 }
1726
1727 #[test]
1732 fn e2e_all_native_providers_missing_key_diagnostic() {
1733 let cases: &[(&str, &str)] = &[
1734 (
1735 "anthropic",
1736 "Missing API key for Anthropic. Set ANTHROPIC_API_KEY or use `pi auth`.",
1737 ),
1738 (
1739 "openai",
1740 "Missing API key for OpenAI. Set OPENAI_API_KEY or configure in settings.",
1741 ),
1742 (
1743 "azure-openai",
1744 "Missing API key for Azure OpenAI. Set AZURE_OPENAI_API_KEY or configure in settings.",
1745 ),
1746 (
1747 "cohere",
1748 "Missing API key for Cohere. Set COHERE_API_KEY or configure in settings.",
1749 ),
1750 (
1751 "google",
1752 "Missing API key for Google/Gemini. Set GOOGLE_API_KEY or GEMINI_API_KEY.",
1753 ),
1754 ];
1755 for (provider, message) in cases {
1756 let err = Error::provider(*provider, *message);
1757 let d = err
1758 .auth_diagnostic()
1759 .unwrap_or_else(|| panic!("expected MissingApiKey diagnostic for {provider}"));
1760 assert_eq!(
1761 d.code,
1762 AuthDiagnosticCode::MissingApiKey,
1763 "wrong code for {provider}: {:?}",
1764 d.code
1765 );
1766 }
1767 }
1768
1769 #[test]
1770 fn e2e_all_native_providers_401_diagnostic() {
1771 let cases: &[(&str, &str)] = &[
1772 (
1773 "anthropic",
1774 "Anthropic API error (HTTP 401): invalid x-api-key",
1775 ),
1776 (
1777 "openai",
1778 "OpenAI API error (HTTP 401): Incorrect API key provided",
1779 ),
1780 (
1781 "azure-openai",
1782 "Azure OpenAI API error (HTTP 401): unauthorized",
1783 ),
1784 ("cohere", "Cohere API error (HTTP 401): unauthorized"),
1785 ("google", "Gemini API error (HTTP 401): API key not valid"),
1786 ];
1787 for (provider, message) in cases {
1788 let err = Error::provider(*provider, *message);
1789 let d = err
1790 .auth_diagnostic()
1791 .unwrap_or_else(|| panic!("expected InvalidApiKey diagnostic for {provider}"));
1792 assert_eq!(
1793 d.code,
1794 AuthDiagnosticCode::InvalidApiKey,
1795 "wrong code for {provider}: {:?}",
1796 d.code
1797 );
1798 }
1799 }
1800
1801 #[test]
1803 fn e2e_non_auth_errors_no_diagnostic() {
1804 let cases: &[(&str, &str)] = &[
1805 (
1806 "anthropic",
1807 "Anthropic API error (HTTP 500): Internal server error",
1808 ),
1809 ("openai", "OpenAI API error (HTTP 503): Service unavailable"),
1810 ("google", "Gemini API error (HTTP 502): Bad gateway"),
1811 ("cohere", "Cohere API error (HTTP 504): Gateway timeout"),
1812 ];
1813 for (provider, message) in cases {
1814 let err = Error::provider(*provider, *message);
1815 assert!(
1816 err.auth_diagnostic().is_none(),
1817 "unexpected diagnostic for {provider} with message: {message}"
1818 );
1819 }
1820 }
1821
1822 #[test]
1824 fn e2e_all_diagnostic_codes_have_redact_secrets_policy() {
1825 let codes = [
1826 AuthDiagnosticCode::MissingApiKey,
1827 AuthDiagnosticCode::InvalidApiKey,
1828 AuthDiagnosticCode::QuotaExceeded,
1829 AuthDiagnosticCode::MissingOAuthAuthorizationCode,
1830 AuthDiagnosticCode::OAuthTokenExchangeFailed,
1831 AuthDiagnosticCode::OAuthTokenRefreshFailed,
1832 AuthDiagnosticCode::MissingAzureDeployment,
1833 AuthDiagnosticCode::MissingRegion,
1834 AuthDiagnosticCode::MissingProject,
1835 AuthDiagnosticCode::MissingProfile,
1836 AuthDiagnosticCode::MissingEndpoint,
1837 AuthDiagnosticCode::MissingCredentialChain,
1838 AuthDiagnosticCode::UnknownAuthFailure,
1839 ];
1840 for code in &codes {
1841 assert_eq!(
1842 code.redaction_policy(),
1843 "redact-secrets",
1844 "code {code:?} missing redact-secrets policy",
1845 );
1846 }
1847 }
1848
1849 #[test]
1852 fn e2e_hints_enrichment_completeness() {
1853 let providers: &[(&str, &str)] = &[
1854 ("anthropic", "Missing API key for Anthropic"),
1855 ("openai", "OpenAI API error (HTTP 401): invalid key"),
1856 ("cohere", "insufficient_quota"),
1857 ("google", "Missing API key for Google"),
1858 ];
1859 for (provider, message) in providers {
1860 let err = Error::provider(*provider, *message);
1861 let hints = err.hints();
1862 assert!(
1863 context_value(&hints, "diagnostic_code").is_some(),
1864 "missing diagnostic_code for {provider}"
1865 );
1866 assert!(
1867 context_value(&hints, "diagnostic_remediation").is_some(),
1868 "missing diagnostic_remediation for {provider}"
1869 );
1870 assert_eq!(
1871 context_value(&hints, "redaction_policy"),
1872 Some("redact-secrets"),
1873 "wrong redaction_policy for {provider}"
1874 );
1875 }
1876 }
1877
1878 #[test]
1880 fn e2e_hints_always_include_provider_context() {
1881 let providers = [
1882 "anthropic",
1883 "openai",
1884 "azure-openai",
1885 "cohere",
1886 "google",
1887 "groq",
1888 "deepseek",
1889 ];
1890 for provider in &providers {
1891 let err = Error::provider(*provider, "some error");
1892 let hints = err.hints();
1893 assert_eq!(
1894 context_value(&hints, "provider"),
1895 Some(*provider),
1896 "missing provider context for {provider}"
1897 );
1898 }
1899 }
1900
1901 #[test]
1903 fn e2e_alias_env_key_consistency() {
1904 let alias_to_canonical: &[(&str, &str)] = &[
1905 ("gemini", "google"),
1906 ("azure", "azure-openai"),
1907 ("copilot", "github-copilot"),
1908 ("dashscope", "alibaba"),
1909 ("qwen", "alibaba"),
1910 ("kimi", "moonshotai"),
1911 ("moonshot", "moonshotai"),
1912 ("bedrock", "amazon-bedrock"),
1913 ("sap", "sap-ai-core"),
1914 ];
1915 for (alias, canonical) in alias_to_canonical {
1916 let alias_keys = crate::provider_metadata::provider_auth_env_keys(alias);
1917 let canonical_keys = crate::provider_metadata::provider_auth_env_keys(canonical);
1918 assert_eq!(
1919 alias_keys, canonical_keys,
1920 "alias {alias} env keys differ from canonical {canonical}"
1921 );
1922 }
1923 }
1924
1925 #[test]
1927 fn e2e_all_native_providers_have_env_keys() {
1928 let native_providers = [
1929 "anthropic",
1930 "openai",
1931 "google",
1932 "cohere",
1933 "azure-openai",
1934 "amazon-bedrock",
1935 "github-copilot",
1936 "sap-ai-core",
1937 ];
1938 for provider in &native_providers {
1939 let keys = crate::provider_metadata::provider_auth_env_keys(provider);
1940 assert!(!keys.is_empty(), "provider {provider} has no auth env keys");
1941 }
1942 }
1943
1944 #[test]
1947 fn e2e_error_messages_never_contain_secrets() {
1948 let fake_key = "sk-proj-FAKE123456789abcdef";
1949 let err1 = Error::provider("openai", "OpenAI API error (HTTP 401): Invalid API key");
1951 let err2 = Error::provider("anthropic", "Missing API key for Anthropic");
1952 let err3 = Error::auth("OAuth token exchange failed");
1953
1954 for err in [&err1, &err2, &err3] {
1955 let display = err.to_string();
1956 assert!(
1957 !display.contains(fake_key),
1958 "error message contains secret: {display}"
1959 );
1960 let hints = err.hints();
1961 for hint in &hints.hints {
1962 assert!(!hint.contains(fake_key), "hint contains secret: {hint}");
1963 }
1964 for (key, value) in &hints.context {
1965 assert!(
1966 !value.contains(fake_key),
1967 "context {key} contains secret: {value}"
1968 );
1969 }
1970 }
1971 }
1972
1973 #[test]
1976 fn e2e_bedrock_credential_chain_diagnostic() {
1977 let err = Error::provider("amazon-bedrock", "No credential source configured");
1978 let d = err
1979 .auth_diagnostic()
1980 .expect("expected credential chain diagnostic");
1981 assert_eq!(d.code, AuthDiagnosticCode::MissingCredentialChain);
1982 }
1983
1984 #[test]
1986 fn e2e_auth_variant_diagnostics() {
1987 let cases: &[(&str, AuthDiagnosticCode)] = &[
1988 ("Missing API key", AuthDiagnosticCode::MissingApiKey),
1989 ("401 unauthorized", AuthDiagnosticCode::InvalidApiKey),
1990 ("insufficient_quota", AuthDiagnosticCode::QuotaExceeded),
1991 (
1992 "Missing authorization code",
1993 AuthDiagnosticCode::MissingOAuthAuthorizationCode,
1994 ),
1995 (
1996 "Token exchange failed",
1997 AuthDiagnosticCode::OAuthTokenExchangeFailed,
1998 ),
1999 (
2000 "OAuth token refresh failed",
2001 AuthDiagnosticCode::OAuthTokenRefreshFailed,
2002 ),
2003 (
2004 "Missing deployment",
2005 AuthDiagnosticCode::MissingAzureDeployment,
2006 ),
2007 ("Missing region", AuthDiagnosticCode::MissingRegion),
2008 ("Missing project", AuthDiagnosticCode::MissingProject),
2009 ("Missing profile", AuthDiagnosticCode::MissingProfile),
2010 ("Missing endpoint", AuthDiagnosticCode::MissingEndpoint),
2011 (
2012 "credential chain not configured",
2013 AuthDiagnosticCode::MissingCredentialChain,
2014 ),
2015 ];
2016 for (message, expected_code) in cases {
2017 let err = Error::auth(*message);
2018 let d = err
2019 .auth_diagnostic()
2020 .unwrap_or_else(|| panic!("expected diagnostic for Auth({message})"));
2021 assert_eq!(
2022 d.code, *expected_code,
2023 "wrong code for Auth({message}): {:?}",
2024 d.code
2025 );
2026 }
2027 }
2028
2029 #[test]
2031 fn e2e_classifier_case_insensitive() {
2032 let variants = ["MISSING API KEY", "Missing Api Key", "missing api key"];
2033 for msg in &variants {
2034 let err = Error::provider("openai", *msg);
2035 let d = err
2036 .auth_diagnostic()
2037 .unwrap_or_else(|| panic!("no diagnostic for: {msg}"));
2038 assert_eq!(
2039 d.code,
2040 AuthDiagnosticCode::MissingApiKey,
2041 "failed for: {msg}"
2042 );
2043 }
2044 }
2045
2046 #[test]
2048 fn e2e_non_auth_variants_no_diagnostic() {
2049 let errors: Vec<Error> = vec![
2050 Error::config("bad json"),
2051 Error::session("timeout"),
2052 Error::tool("bash", "not found"),
2053 Error::validation("missing field"),
2054 Error::extension("crash"),
2055 Error::api("network error"),
2056 Error::Aborted,
2057 ];
2058 for err in &errors {
2059 assert!(
2060 err.auth_diagnostic().is_none(),
2061 "unexpected diagnostic for: {err}"
2062 );
2063 }
2064 }
2065
2066 #[test]
2068 fn e2e_quota_messages_cross_provider() {
2069 let messages = [
2070 "insufficient_quota",
2071 "quota exceeded",
2072 "billing hard limit reached",
2073 "billing_not_active",
2074 "not enough credits",
2075 "credit balance is too low",
2076 ];
2077 for msg in &messages {
2078 let err = Error::provider("openai", *msg);
2079 let d = err
2080 .auth_diagnostic()
2081 .unwrap_or_else(|| panic!("no diagnostic for: {msg}"));
2082 assert_eq!(
2083 d.code,
2084 AuthDiagnosticCode::QuotaExceeded,
2085 "wrong code for: {msg}"
2086 );
2087 }
2088 }
2089
2090 #[test]
2092 fn e2e_openai_compatible_providers_env_keys() {
2093 let providers_and_keys: &[(&str, &str)] = &[
2094 ("groq", "GROQ_API_KEY"),
2095 ("deepinfra", "DEEPINFRA_API_KEY"),
2096 ("cerebras", "CEREBRAS_API_KEY"),
2097 ("openrouter", "OPENROUTER_API_KEY"),
2098 ("mistral", "MISTRAL_API_KEY"),
2099 ("moonshotai", "MOONSHOT_API_KEY"),
2100 ("moonshotai", "KIMI_API_KEY"),
2101 ("alibaba", "DASHSCOPE_API_KEY"),
2102 ("alibaba", "QWEN_API_KEY"),
2103 ("deepseek", "DEEPSEEK_API_KEY"),
2104 ("perplexity", "PERPLEXITY_API_KEY"),
2105 ("xai", "XAI_API_KEY"),
2106 ];
2107 for (provider, expected_key) in providers_and_keys {
2108 let keys = crate::provider_metadata::provider_auth_env_keys(provider);
2109 assert!(
2110 keys.contains(expected_key),
2111 "provider {provider} missing env key {expected_key}, got: {keys:?}"
2112 );
2113 }
2114 }
2115
2116 #[test]
2118 fn e2e_key_hint_format_consistency() {
2119 let hint = provider_key_hint("anthropic");
2121 assert!(hint.contains("ANTHROPIC_API_KEY"), "hint: {hint}");
2122 assert!(hint.contains("/login"), "hint: {hint}");
2123
2124 let hint = provider_key_hint("github-copilot");
2126 assert!(hint.contains("/login"), "hint: {hint}");
2127
2128 let hint = provider_key_hint("openai");
2130 assert!(hint.contains("OPENAI_API_KEY"), "hint: {hint}");
2131 assert!(!hint.contains("/login"), "hint: {hint}");
2132
2133 let hint = provider_key_hint("my-custom-proxy");
2135 assert!(hint.contains("my-custom-proxy"), "hint: {hint}");
2136 }
2137
2138 #[test]
2140 fn e2e_empty_message_no_diagnostic() {
2141 let err = Error::provider("openai", "");
2142 assert!(err.auth_diagnostic().is_none());
2143 }
2144
2145 #[test]
2148 fn overflow_prompt_is_too_long() {
2149 assert!(is_context_overflow(
2150 "prompt is too long: 150000 tokens",
2151 None,
2152 None
2153 ));
2154 }
2155
2156 #[test]
2157 fn overflow_input_too_long_for_model() {
2158 assert!(is_context_overflow(
2159 "input is too long for requested model",
2160 None,
2161 None,
2162 ));
2163 }
2164
2165 #[test]
2166 fn overflow_exceeds_context_window() {
2167 assert!(is_context_overflow(
2168 "exceeds the context window",
2169 None,
2170 None
2171 ));
2172 }
2173
2174 #[test]
2175 fn overflow_input_token_count_exceeds_maximum() {
2176 assert!(is_context_overflow(
2177 "input token count of 50000 exceeds the maximum of 32000",
2178 None,
2179 None,
2180 ));
2181 }
2182
2183 #[test]
2184 fn overflow_maximum_prompt_length() {
2185 assert!(is_context_overflow(
2186 "maximum prompt length is 32000",
2187 None,
2188 None,
2189 ));
2190 }
2191
2192 #[test]
2193 fn overflow_reduce_length_of_messages() {
2194 assert!(is_context_overflow(
2195 "reduce the length of the messages",
2196 None,
2197 None,
2198 ));
2199 }
2200
2201 #[test]
2202 fn overflow_maximum_context_length() {
2203 assert!(is_context_overflow(
2204 "maximum context length is 128000 tokens",
2205 None,
2206 None,
2207 ));
2208 }
2209
2210 #[test]
2211 fn overflow_exceeds_limit_of() {
2212 assert!(is_context_overflow(
2213 "exceeds the limit of 200000",
2214 None,
2215 None,
2216 ));
2217 }
2218
2219 #[test]
2220 fn overflow_exceeds_available_context_size() {
2221 assert!(is_context_overflow(
2222 "exceeds the available context size",
2223 None,
2224 None,
2225 ));
2226 }
2227
2228 #[test]
2229 fn overflow_greater_than_context_length() {
2230 assert!(is_context_overflow(
2231 "greater than the context length",
2232 None,
2233 None,
2234 ));
2235 }
2236
2237 #[test]
2238 fn overflow_context_window_exceeds_limit() {
2239 assert!(is_context_overflow(
2240 "context window exceeds limit",
2241 None,
2242 None,
2243 ));
2244 }
2245
2246 #[test]
2247 fn overflow_exceeded_model_token_limit() {
2248 assert!(is_context_overflow(
2249 "exceeded model token limit",
2250 None,
2251 None,
2252 ));
2253 }
2254
2255 #[test]
2256 fn overflow_context_length_exceeded_underscore() {
2257 assert!(is_context_overflow("context_length_exceeded", None, None));
2258 }
2259
2260 #[test]
2261 fn overflow_context_length_exceeded_space() {
2262 assert!(is_context_overflow("context length exceeded", None, None));
2263 }
2264
2265 #[test]
2266 fn overflow_too_many_tokens() {
2267 assert!(is_context_overflow("too many tokens", None, None));
2268 }
2269
2270 #[test]
2271 fn overflow_token_limit_exceeded() {
2272 assert!(is_context_overflow("token limit exceeded", None, None));
2273 }
2274
2275 #[test]
2276 fn overflow_cerebras_400_no_body() {
2277 assert!(is_context_overflow("400 (no body)", None, None));
2278 }
2279
2280 #[test]
2281 fn overflow_cerebras_413_no_body() {
2282 assert!(is_context_overflow("413 (no body)", None, None));
2283 }
2284
2285 #[test]
2286 fn overflow_mistral_status_code_pattern() {
2287 assert!(is_context_overflow("413 status code (no body)", None, None,));
2288 }
2289
2290 #[test]
2291 fn overflow_case_insensitive() {
2292 assert!(is_context_overflow("PROMPT IS TOO LONG", None, None));
2293 assert!(is_context_overflow("Token Limit Exceeded", None, None));
2294 }
2295
2296 #[test]
2297 fn overflow_silent_usage_exceeds_window() {
2298 assert!(is_context_overflow(
2299 "some error",
2300 Some(250_000),
2301 Some(200_000),
2302 ));
2303 }
2304
2305 #[test]
2306 fn overflow_usage_within_window() {
2307 assert!(!is_context_overflow(
2308 "some error",
2309 Some(100_000),
2310 Some(200_000),
2311 ));
2312 }
2313
2314 #[test]
2315 fn overflow_no_usage_info() {
2316 assert!(!is_context_overflow("some error", None, None));
2317 }
2318
2319 #[test]
2320 fn overflow_negative_not_matched() {
2321 assert!(!is_context_overflow("rate limit exceeded", None, None));
2322 assert!(!is_context_overflow("server error 500", None, None));
2323 assert!(!is_context_overflow("authentication error", None, None));
2324 assert!(!is_context_overflow("", None, None));
2325 }
2326
2327 #[test]
2330 fn retryable_rate_limit() {
2331 assert!(is_retryable_error("429 rate limit exceeded", None, None));
2332 }
2333
2334 #[test]
2335 fn retryable_too_many_requests() {
2336 assert!(is_retryable_error("too many requests", None, None));
2337 }
2338
2339 #[test]
2340 fn retryable_overloaded() {
2341 assert!(is_retryable_error("API overloaded", None, None));
2342 }
2343
2344 #[test]
2345 fn retryable_server_500() {
2346 assert!(is_retryable_error(
2347 "HTTP 500 internal server error",
2348 None,
2349 None
2350 ));
2351 }
2352
2353 #[test]
2354 fn retryable_server_502() {
2355 assert!(is_retryable_error("502 bad gateway", None, None));
2356 }
2357
2358 #[test]
2359 fn retryable_server_503() {
2360 assert!(is_retryable_error("503 service unavailable", None, None));
2361 }
2362
2363 #[test]
2364 fn retryable_server_504() {
2365 assert!(is_retryable_error("504 gateway timeout", None, None));
2366 }
2367
2368 #[test]
2369 fn retryable_service_unavailable() {
2370 assert!(is_retryable_error("service unavailable", None, None));
2371 }
2372
2373 #[test]
2374 fn retryable_server_error() {
2375 assert!(is_retryable_error("server error", None, None));
2376 }
2377
2378 #[test]
2379 fn retryable_internal_error() {
2380 assert!(is_retryable_error("internal error occurred", None, None));
2381 }
2382
2383 #[test]
2384 fn retryable_connection_error() {
2385 assert!(is_retryable_error("connection error", None, None));
2386 }
2387
2388 #[test]
2389 fn retryable_connection_refused() {
2390 assert!(is_retryable_error("connection refused", None, None));
2391 }
2392
2393 #[test]
2394 fn retryable_other_side_closed() {
2395 assert!(is_retryable_error("other side closed", None, None));
2396 }
2397
2398 #[test]
2399 fn retryable_fetch_failed() {
2400 assert!(is_retryable_error("fetch failed", None, None));
2401 }
2402
2403 #[test]
2404 fn retryable_upstream_connect() {
2405 assert!(is_retryable_error("upstream connect error", None, None));
2406 }
2407
2408 #[test]
2409 fn retryable_reset_before_headers() {
2410 assert!(is_retryable_error("reset before headers", None, None));
2411 }
2412
2413 #[test]
2414 fn retryable_terminated() {
2415 assert!(is_retryable_error("request terminated", None, None));
2416 }
2417
2418 #[test]
2419 fn retryable_retry_delay() {
2420 assert!(is_retryable_error("retry delay 30s", None, None));
2421 }
2422
2423 #[test]
2424 fn not_retryable_context_overflow() {
2425 assert!(!is_retryable_error("prompt is too long", None, None));
2427 assert!(!is_retryable_error(
2428 "exceeds the context window",
2429 None,
2430 None,
2431 ));
2432 assert!(!is_retryable_error("too many tokens", None, None));
2433 }
2434
2435 #[test]
2436 fn not_retryable_auth_errors() {
2437 assert!(!is_retryable_error("invalid api key", None, None));
2438 assert!(!is_retryable_error("unauthorized access", None, None));
2439 assert!(!is_retryable_error("permission denied", None, None));
2440 }
2441
2442 #[test]
2443 fn not_retryable_empty_message() {
2444 assert!(!is_retryable_error("", None, None));
2445 }
2446
2447 #[test]
2448 fn not_retryable_generic_error() {
2449 assert!(!is_retryable_error("something went wrong", None, None));
2450 }
2451
2452 #[test]
2453 fn not_retryable_silent_overflow() {
2454 assert!(!is_retryable_error(
2457 "500 server error",
2458 Some(250_000),
2459 Some(200_000),
2460 ));
2461 }
2462
2463 #[test]
2464 fn retryable_case_insensitive() {
2465 assert!(is_retryable_error("RATE LIMIT", None, None));
2466 assert!(is_retryable_error("Service Unavailable", None, None));
2467 }
2468
2469 mod proptest_error {
2470 use super::*;
2471 use proptest::prelude::*;
2472
2473 const ALL_DIAGNOSTIC_CODES: &[AuthDiagnosticCode] = &[
2474 AuthDiagnosticCode::MissingApiKey,
2475 AuthDiagnosticCode::InvalidApiKey,
2476 AuthDiagnosticCode::QuotaExceeded,
2477 AuthDiagnosticCode::MissingOAuthAuthorizationCode,
2478 AuthDiagnosticCode::OAuthTokenExchangeFailed,
2479 AuthDiagnosticCode::OAuthTokenRefreshFailed,
2480 AuthDiagnosticCode::MissingAzureDeployment,
2481 AuthDiagnosticCode::MissingRegion,
2482 AuthDiagnosticCode::MissingProject,
2483 AuthDiagnosticCode::MissingProfile,
2484 AuthDiagnosticCode::MissingEndpoint,
2485 AuthDiagnosticCode::MissingCredentialChain,
2486 AuthDiagnosticCode::UnknownAuthFailure,
2487 ];
2488
2489 proptest! {
2490 #[test]
2492 fn as_str_non_empty_dotted(idx in 0..13usize) {
2493 let code = ALL_DIAGNOSTIC_CODES[idx];
2494 let s = code.as_str();
2495 assert!(!s.is_empty());
2496 assert!(s.contains('.'), "diagnostic code should be dotted: {s}");
2497 }
2498
2499 #[test]
2501 fn as_str_unique(a in 0..13usize, b in 0..13usize) {
2502 if a != b {
2503 assert_ne!(
2504 ALL_DIAGNOSTIC_CODES[a].as_str(),
2505 ALL_DIAGNOSTIC_CODES[b].as_str()
2506 );
2507 }
2508 }
2509
2510 #[test]
2512 fn remediation_non_empty(idx in 0..13usize) {
2513 let code = ALL_DIAGNOSTIC_CODES[idx];
2514 assert!(!code.remediation().is_empty());
2515 }
2516
2517 #[test]
2519 fn redaction_policy_constant(idx in 0..13usize) {
2520 let code = ALL_DIAGNOSTIC_CODES[idx];
2521 assert_eq!(code.redaction_policy(), "redact-secrets");
2522 }
2523
2524 #[test]
2526 fn hostcall_code_known(msg in "[a-z ]{1,20}") {
2527 let known = ["invalid_request", "io", "denied", "timeout", "internal"];
2528 let errors = [
2529 Error::config(msg.clone()),
2530 Error::session(msg.clone()),
2531 Error::auth(msg.clone()),
2532 Error::validation(msg.clone()),
2533 Error::api(msg),
2534 ];
2535 for e in &errors {
2536 assert!(known.contains(&e.hostcall_error_code()));
2537 }
2538 }
2539
2540 #[test]
2542 fn category_code_format(msg in "[a-z ]{1,20}") {
2543 let errors = [
2544 Error::config(msg.clone()),
2545 Error::session(msg.clone()),
2546 Error::auth(msg.clone()),
2547 Error::validation(msg.clone()),
2548 Error::extension(msg.clone()),
2549 Error::api(msg),
2550 ];
2551 for e in &errors {
2552 let code = e.category_code();
2553 assert!(!code.is_empty());
2554 assert!(code.chars().all(|c| c.is_ascii_lowercase()));
2555 }
2556 }
2557
2558 #[test]
2560 fn context_overflow_token_based(
2561 input_tokens in 100_001..500_000u64,
2562 window in 1..100_000u32
2563 ) {
2564 assert!(is_context_overflow(
2565 "",
2566 Some(input_tokens),
2567 Some(window)
2568 ));
2569 }
2570
2571 #[test]
2573 fn context_overflow_within_window(
2574 window in 100..200_000u32,
2575 offset in 0..100u64
2576 ) {
2577 let input = u64::from(window).saturating_sub(offset);
2578 assert!(!is_context_overflow(
2579 "some normal error",
2580 Some(input),
2581 Some(window)
2582 ));
2583 }
2584
2585 #[test]
2587 fn context_overflow_pattern_detection(idx in 0..OVERFLOW_PATTERNS.len()) {
2588 let pattern = OVERFLOW_PATTERNS[idx];
2589 assert!(is_context_overflow(pattern, None, None));
2590 }
2591
2592 #[test]
2594 fn context_overflow_case_insensitive(idx in 0..OVERFLOW_PATTERNS.len()) {
2595 let pattern = OVERFLOW_PATTERNS[idx];
2596 assert!(is_context_overflow(&pattern.to_uppercase(), None, None));
2597 }
2598
2599 #[test]
2601 fn retryable_empty_is_false(_dummy in 0..1u8) {
2602 assert!(!is_retryable_error("", None, None));
2603 }
2604
2605 #[test]
2607 fn overflow_not_retryable(idx in 0..OVERFLOW_PATTERNS.len()) {
2608 let pattern = OVERFLOW_PATTERNS[idx];
2609 assert!(!is_retryable_error(pattern, None, None));
2610 }
2611
2612 #[test]
2614 fn retryable_known_patterns(idx in 0..8usize) {
2615 let patterns = [
2616 "overloaded",
2617 "rate limit exceeded",
2618 "too many requests",
2619 "429 status code",
2620 "502 bad gateway",
2621 "503 service unavailable",
2622 "connection error",
2623 "fetch failed",
2624 ];
2625 assert!(is_retryable_error(patterns[idx], None, None));
2626 }
2627
2628 #[test]
2630 fn random_not_retryable(s in "[a-z]{20,40}") {
2631 assert!(!is_retryable_error(&s, None, None));
2632 }
2633
2634 #[test]
2636 fn constructor_category_consistency(msg in "[a-z]{1,10}") {
2637 assert_eq!(Error::config(&msg).category_code(), "config");
2638 assert_eq!(Error::session(&msg).category_code(), "session");
2639 assert_eq!(Error::auth(&msg).category_code(), "auth");
2640 assert_eq!(Error::validation(&msg).category_code(), "validation");
2641 assert_eq!(Error::extension(&msg).category_code(), "extension");
2642 assert_eq!(Error::api(&msg).category_code(), "api");
2643 }
2644 }
2645 }
2646}