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