Skip to main content

pi/
error.rs

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