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