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