1use kagi_sdk::{CredentialKind, KagiError};
2
3#[derive(Debug, thiserror::Error)]
4pub enum StartupError {
5 #[error(
6 "invalid `{env_var}` value `{value}`; expected one of `auto`, `official`, or `session`"
7 )]
8 InvalidBackendMode {
9 env_var: &'static str,
10 value: String,
11 },
12
13 #[error("missing required credential `{env_var}` for `{mode}` backend mode{hint_suffix}")]
14 MissingCredential {
15 env_var: &'static str,
16 mode: &'static str,
17 hint_suffix: String,
18 },
19
20 #[error("invalid credential in `{env_var}`: {reason}")]
21 InvalidCredential {
22 env_var: &'static str,
23 reason: String,
24 },
25
26 #[error("failed to construct Kagi client: {reason}")]
27 ClientConstruction { reason: String },
28}
29
30#[derive(Debug, thiserror::Error)]
31#[error("{message}")]
32pub struct ToolFailure {
33 message: String,
34}
35
36impl ToolFailure {
37 pub fn message(&self) -> &str {
38 &self.message
39 }
40
41 pub fn parse_drift(reason: impl Into<String>) -> Self {
42 Self {
43 message: format!(
44 "Kagi returned an unexpected response shape for this capability ({})",
45 reason.into()
46 ),
47 }
48 }
49
50 pub fn from_kagi_error(error: KagiError) -> Self {
51 match error {
52 KagiError::InvalidCredential { kind, reason } => Self {
53 message: format!(
54 "Server credential is invalid for {kind}. Update the credential and restart ({reason})."
55 ),
56 },
57 KagiError::MissingCredentialConfiguration { reason }
58 | KagiError::InvalidClientConfiguration { reason } => Self {
59 message: format!("Server startup configuration is invalid ({reason})."),
60 },
61 KagiError::ConflictingCredentialConfiguration { .. } => Self {
62 message: "Server startup configuration is invalid (conflicting credential configuration)."
63 .to_string(),
64 },
65 KagiError::InvalidInput { field, reason } => Self {
66 message: format!(
67 "The server generated an invalid upstream request for `{field}` ({reason})."
68 ),
69 },
70 KagiError::UnsupportedAuthSurface { .. } | KagiError::UnsupportedCapability { .. } => {
71 Self {
72 message: "Selected backend mode does not support this capability. Update `KAGI_MCP_BACKEND` and restart.".to_string(),
73 }
74 }
75 KagiError::UnauthorizedBotToken { .. } => Self {
76 message: auth_failure_message(Some(CredentialKind::BotToken)),
77 },
78 KagiError::InvalidSession { .. } => Self {
79 message: auth_failure_message(Some(CredentialKind::SessionToken)),
80 },
81 KagiError::Transport { source, .. } => {
82 if source.is_timeout() {
83 return Self {
84 message: "Kagi request timed out. Retry shortly.".to_string(),
85 };
86 }
87
88 Self {
89 message: "Kagi transport request failed. Check network connectivity and retry.".to_string(),
90 }
91 }
92 KagiError::ResponseParse { reason, .. } => Self::parse_drift(reason),
93 KagiError::ApiFailure {
94 endpoint,
95 status,
96 code,
97 message,
98 ..
99 } => {
100 if api_failure_indicates_auth_failure(status, code.as_deref(), &message) {
101 return Self {
102 message: auth_failure_message(Some(endpoint.spec().allowed_credential)),
103 };
104 }
105
106 if status == 429 {
107 return Self {
108 message:
109 "Kagi rate-limited this request (HTTP 429). Retry after a short delay."
110 .to_string(),
111 };
112 }
113
114 if status >= 500 {
115 return Self {
116 message: format!(
117 "Kagi upstream service is currently failing (HTTP {status}). Retry later."
118 ),
119 };
120 }
121
122 if status < 400 {
123 let detail = code
124 .map(|code| format!("{code}: {message}"))
125 .unwrap_or(message);
126
127 return Self {
128 message: format!(
129 "Kagi reported an application-level failure (HTTP {status}): {detail}"
130 ),
131 };
132 }
133
134 Self {
135 message: format!(
136 "Kagi rejected the upstream request (HTTP {status}). Verify input and retry."
137 ),
138 }
139 }
140 }
141 }
142}
143
144fn auth_failure_message(expected_kind: Option<CredentialKind>) -> String {
145 let base =
146 "Authentication failed with Kagi. Verify the configured credential and restart the server.";
147
148 let guidance = match expected_kind {
149 Some(CredentialKind::BotToken) => {
150 " This backend expects an official bot token in `KAGI_API_KEY`; the configured value may belong to session-web auth (`KAGI_SESSION_TOKEN`) instead."
151 }
152 Some(CredentialKind::SessionToken) => {
153 " This backend expects a session-web token in `KAGI_SESSION_TOKEN`; the configured value may belong to official bot-token auth (`KAGI_API_KEY`) instead."
154 }
155 None => {
156 " `KAGI_API_KEY` should be used only for official bot tokens, and `KAGI_SESSION_TOKEN` should be used only for session-web tokens."
157 }
158 };
159
160 format!("{base}{guidance}")
161}
162
163fn api_failure_indicates_auth_failure(status: u16, code: Option<&str>, message: &str) -> bool {
164 if matches!(status, 401 | 403) {
165 return true;
166 }
167
168 if code.is_some_and(|raw_code| {
169 let normalized = raw_code.trim().to_ascii_lowercase();
170 matches!(normalized.as_str(), "unauthorized" | "invalid_session")
171 }) {
172 return true;
173 }
174
175 let normalized_message = message.trim().to_ascii_lowercase();
176 normalized_message == "unauthorized"
177 || normalized_message == "unauthorized: unauthorized"
178 || normalized_message.contains("invalid session")
179}