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