1use serde::{Deserialize, Serialize};
7use std::fmt;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct UserFacingError {
12 pub summary: String,
14 pub message: String,
16 pub suggestion: String,
18 pub category: ErrorCategory,
20 pub recoverable: bool,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ErrorCategory {
27 Connection,
29 Auth,
31 Config,
33 NotFound,
35 Temporary,
37 Internal,
39}
40
41#[derive(Debug)]
43pub enum ModelError {
44 Backend(BackendError),
46
47 Config(ConfigError),
49
50 ModelNotFound {
52 model: String,
53 searched: Vec<String>,
54 },
55
56 Timeout {
58 operation: String,
59 duration_secs: u64,
60 },
61
62 RateLimit { retry_after: Option<u64> },
64
65 InvalidRequest(String),
67
68 ParseError {
70 message: String,
71 raw: Option<String>,
72 },
73
74 StreamError(String),
76
77 Authentication(String),
79}
80
81impl fmt::Display for ModelError {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 match self {
84 ModelError::Backend(e) => write!(f, "Backend error: {}", e),
85 ModelError::Config(e) => write!(f, "Configuration error: {}", e),
86 ModelError::ModelNotFound { model, searched } => {
87 write!(
88 f,
89 "Model '{}' not found. Searched: {}",
90 model,
91 searched.join(", ")
92 )
93 },
94 ModelError::Timeout {
95 operation,
96 duration_secs,
97 } => {
98 write!(
99 f,
100 "Operation '{}' timed out after {} seconds",
101 operation, duration_secs
102 )
103 },
104 ModelError::RateLimit { retry_after } => {
105 if let Some(secs) = retry_after {
106 write!(f, "Rate limit exceeded. Retry after {} seconds", secs)
107 } else {
108 write!(f, "Rate limit exceeded")
109 }
110 },
111 ModelError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
112 ModelError::ParseError { message, raw } => {
113 if let Some(r) = raw {
114 write!(f, "Parse error: {} (raw: {})", message, r)
115 } else {
116 write!(f, "Parse error: {}", message)
117 }
118 },
119 ModelError::StreamError(msg) => write!(f, "Stream error: {}", msg),
120 ModelError::Authentication(msg) => write!(f, "Authentication error: {}", msg),
121 }
122 }
123}
124
125impl std::error::Error for ModelError {}
126
127impl ModelError {
128 pub fn to_user_facing(&self) -> UserFacingError {
130 match self {
131 ModelError::Backend(BackendError::ConnectionFailed { backend, url, .. }) => {
132 UserFacingError {
133 summary: format!("{} connection failed", backend),
134 message: format!("Could not connect to {} at {}", backend, url),
135 suggestion: if backend == "ollama" {
136 "Run 'ollama serve' to start Ollama, or check if it's running on the correct port".to_string()
137 } else {
138 format!("Check if {} is running and accessible", backend)
139 },
140 category: ErrorCategory::Connection,
141 recoverable: true,
142 }
143 },
144 ModelError::Backend(BackendError::NotAvailable { backend, reason }) => {
145 UserFacingError {
146 summary: format!("{} unavailable", backend),
147 message: format!("{} is not available: {}", backend, reason),
148 suggestion: if backend == "ollama" {
149 "Start Ollama with 'ollama serve' or pull the model with 'ollama pull <model>'".to_string()
150 } else {
151 format!("Ensure {} service is running and healthy", backend)
152 },
153 category: ErrorCategory::Connection,
154 recoverable: true,
155 }
156 },
157 ModelError::Backend(BackendError::HttpError { status, message }) => {
158 let (summary, suggestion) = match status {
159 401 | 403 => (
160 "Authentication failed",
161 "Check your API key in ~/.config/mermaid/config.toml",
162 ),
163 404 => (
164 "Model not found",
165 "Use :model <name> to switch models (auto-pulls if needed), or pull manually with 'ollama pull <name>'",
166 ),
167 429 => (
168 "Rate limited",
169 "Wait a moment before retrying, or switch to a local model",
170 ),
171 500..=599 => (
172 "Server error",
173 "The backend service is experiencing issues - try again later",
174 ),
175 _ => (
176 "Request failed",
177 "Check your network connection and backend configuration",
178 ),
179 };
180 UserFacingError {
181 summary: summary.to_string(),
182 message: format!("HTTP {}: {}", status, message),
183 suggestion: suggestion.to_string(),
184 category: if *status == 401 || *status == 403 {
185 ErrorCategory::Auth
186 } else if *status == 429 {
187 ErrorCategory::Temporary
188 } else {
189 ErrorCategory::Internal
190 },
191 recoverable: *status == 429 || *status >= 500,
192 }
193 },
194 ModelError::Backend(BackendError::UnexpectedResponse { backend, message }) => {
195 UserFacingError {
196 summary: "Unexpected response".to_string(),
197 message: format!("Received unexpected response from {}: {}", backend, message),
198 suggestion: "This might be a version mismatch - try updating the backend"
199 .to_string(),
200 category: ErrorCategory::Internal,
201 recoverable: false,
202 }
203 },
204 ModelError::Backend(BackendError::ProviderError {
205 provider,
206 code,
207 message,
208 }) => {
209 let code_str = code.as_deref().unwrap_or("unknown");
210 UserFacingError {
211 summary: format!("{} error", provider),
212 message: format!("{} returned error {}: {}", provider, code_str, message),
213 suggestion: format!(
214 "Check {} documentation for error code {}",
215 provider, code_str
216 ),
217 category: ErrorCategory::Internal,
218 recoverable: false,
219 }
220 },
221 ModelError::Config(ConfigError::MissingRequired(field)) => UserFacingError {
222 summary: "Missing configuration".to_string(),
223 message: format!("Required configuration '{}' is missing", field),
224 suggestion: format!("Add '{}' to ~/.config/mermaid/config.toml", field),
225 category: ErrorCategory::Config,
226 recoverable: false,
227 },
228 ModelError::Config(ConfigError::InvalidValue {
229 field,
230 value,
231 reason,
232 }) => UserFacingError {
233 summary: "Invalid configuration".to_string(),
234 message: format!("Invalid value '{}' for '{}': {}", value, field, reason),
235 suggestion: format!("Fix '{}' in ~/.config/mermaid/config.toml", field),
236 category: ErrorCategory::Config,
237 recoverable: false,
238 },
239 ModelError::Config(ConfigError::FileError { path, reason }) => UserFacingError {
240 summary: "Config file error".to_string(),
241 message: format!("Cannot read config file '{}': {}", path, reason),
242 suggestion: "Check file permissions and syntax".to_string(),
243 category: ErrorCategory::Config,
244 recoverable: false,
245 },
246 ModelError::ModelNotFound { model, searched } => UserFacingError {
247 summary: "Model not found".to_string(),
248 message: format!("Model '{}' not found in: {}", model, searched.join(", ")),
249 suggestion: format!(
250 "Pull the model with 'ollama pull {}' or check if the model name is correct",
251 model
252 ),
253 category: ErrorCategory::NotFound,
254 recoverable: false,
255 },
256 ModelError::Timeout {
257 operation,
258 duration_secs,
259 } => UserFacingError {
260 summary: "Request timed out".to_string(),
261 message: format!("'{}' timed out after {} seconds", operation, duration_secs),
262 suggestion: "The model might be overloaded - try a smaller model or wait and retry"
263 .to_string(),
264 category: ErrorCategory::Temporary,
265 recoverable: true,
266 },
267 ModelError::RateLimit { retry_after } => {
268 let wait_msg = retry_after
269 .map(|s| format!("Wait {} seconds", s))
270 .unwrap_or_else(|| "Wait a moment".to_string());
271 UserFacingError {
272 summary: "Rate limited".to_string(),
273 message: "Too many requests - rate limit exceeded".to_string(),
274 suggestion: format!(
275 "{}. Consider using a local Ollama model to avoid rate limits",
276 wait_msg
277 ),
278 category: ErrorCategory::Temporary,
279 recoverable: true,
280 }
281 },
282 ModelError::InvalidRequest(msg) => UserFacingError {
283 summary: "Invalid request".to_string(),
284 message: format!("The request was invalid: {}", msg),
285 suggestion: "Check your message format or try rephrasing".to_string(),
286 category: ErrorCategory::Internal,
287 recoverable: false,
288 },
289 ModelError::ParseError { message, .. } => UserFacingError {
290 summary: "Parse error".to_string(),
291 message: format!("Failed to parse response: {}", message),
292 suggestion:
293 "The model returned an unexpected format - try sending the message again"
294 .to_string(),
295 category: ErrorCategory::Internal,
296 recoverable: true,
297 },
298 ModelError::StreamError(msg) => UserFacingError {
299 summary: "Stream interrupted".to_string(),
300 message: format!("Connection lost during streaming: {}", msg),
301 suggestion: "Check your network connection and try again".to_string(),
302 category: ErrorCategory::Connection,
303 recoverable: true,
304 },
305 ModelError::Authentication(msg) => UserFacingError {
306 summary: "Authentication failed".to_string(),
307 message: format!("Authentication error: {}", msg),
308 suggestion:
309 "Check your API key in ~/.config/mermaid/config.toml or environment variables"
310 .to_string(),
311 category: ErrorCategory::Auth,
312 recoverable: false,
313 },
314 }
315 }
316}
317
318#[derive(Debug)]
320pub enum BackendError {
321 ConnectionFailed {
323 backend: String,
324 url: String,
325 reason: String,
326 },
327
328 NotAvailable { backend: String, reason: String },
330
331 HttpError { status: u16, message: String },
333
334 UnexpectedResponse { backend: String, message: String },
336
337 ProviderError {
339 provider: String,
340 code: Option<String>,
341 message: String,
342 },
343}
344
345impl fmt::Display for BackendError {
346 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347 match self {
348 BackendError::ConnectionFailed {
349 backend,
350 url,
351 reason,
352 } => {
353 write!(f, "Failed to connect to {} at {}: {}", backend, url, reason)
354 },
355 BackendError::NotAvailable { backend, reason } => {
356 write!(f, "Backend '{}' not available: {}", backend, reason)
357 },
358 BackendError::HttpError { status, message } => {
359 write!(f, "HTTP error {}: {}", status, message)
360 },
361 BackendError::UnexpectedResponse { backend, message } => {
362 write!(f, "Unexpected response from {}: {}", backend, message)
363 },
364 BackendError::ProviderError {
365 provider,
366 code,
367 message,
368 } => {
369 if let Some(c) = code {
370 write!(f, "{} error {}: {}", provider, c, message)
371 } else {
372 write!(f, "{} error: {}", provider, message)
373 }
374 },
375 }
376 }
377}
378
379impl std::error::Error for BackendError {}
380
381#[derive(Debug)]
383pub enum ConfigError {
384 MissingRequired(String),
386
387 InvalidValue {
389 field: String,
390 value: String,
391 reason: String,
392 },
393
394 FileError { path: String, reason: String },
396}
397
398impl fmt::Display for ConfigError {
399 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400 match self {
401 ConfigError::MissingRequired(field) => {
402 write!(f, "Missing required configuration: {}", field)
403 },
404 ConfigError::InvalidValue {
405 field,
406 value,
407 reason,
408 } => {
409 write!(f, "Invalid value for '{}': '{}' ({})", field, value, reason)
410 },
411 ConfigError::FileError { path, reason } => {
412 write!(f, "Error reading config file '{}': {}", path, reason)
413 },
414 }
415 }
416}
417
418impl std::error::Error for ConfigError {}
419
420pub type Result<T> = std::result::Result<T, ModelError>;
422
423impl From<anyhow::Error> for ModelError {
425 fn from(err: anyhow::Error) -> Self {
426 ModelError::InvalidRequest(err.to_string())
427 }
428}
429
430impl From<reqwest::Error> for ModelError {
432 fn from(err: reqwest::Error) -> Self {
433 if err.is_timeout() {
434 ModelError::Timeout {
435 operation: "HTTP request".to_string(),
436 duration_secs: 120,
437 }
438 } else if err.is_connect() {
439 ModelError::Backend(BackendError::ConnectionFailed {
440 backend: "unknown".to_string(),
441 url: err
442 .url()
443 .map(|u| u.to_string())
444 .unwrap_or_else(|| "unknown".to_string()),
445 reason: err.to_string(),
446 })
447 } else if err.is_status() {
448 let status = err.status().map(|s| s.as_u16()).unwrap_or(500);
449 ModelError::Backend(BackendError::HttpError {
450 status,
451 message: err.to_string(),
452 })
453 } else {
454 ModelError::Backend(BackendError::UnexpectedResponse {
455 backend: "unknown".to_string(),
456 message: err.to_string(),
457 })
458 }
459 }
460}
461
462impl From<serde_json::Error> for ModelError {
464 fn from(err: serde_json::Error) -> Self {
465 ModelError::ParseError {
466 message: err.to_string(),
467 raw: None,
468 }
469 }
470}