1use crate::provider::BrainError;
8
9pub fn humanize_brain_error(err: &BrainError, provider_label: &str, model: &str) -> String {
22 match err {
23 BrainError::RateLimit { retry_after } => {
24 humanize_rate_limit(provider_label, *retry_after)
25 }
26 BrainError::ServerError { status, body } => {
27 humanize_http_error(*status, body, provider_label, model)
28 }
29 BrainError::Timeout => {
30 format!(
31 "⏱️ {provider} a mis trop de temps à répondre. Réessaie ou change de provider (sparrow route set <provider>).",
32 provider = provider_label,
33 )
34 }
35 BrainError::Refusal(msg) => {
36 format!(
37 "🚫 {provider} a refusé la requête : {msg}\n→ Reformule ta demande ou change de modèle avec sparrow model --set <provider>:<model>.",
38 provider = provider_label,
39 msg = msg,
40 )
41 }
42 BrainError::Unknown(msg) => {
43 format!(
44 "❓ Erreur inconnue chez {provider} : {msg}\n→ Lance sparrow doctor pour un diagnostic complet.",
45 provider = provider_label,
46 msg = msg,
47 )
48 }
49 }
50}
51
52fn humanize_http_error(status: u16, body: &str, provider_label: &str, model: &str) -> String {
54 match status {
55 401 | 403 => humanize_unauthorized(provider_label),
56 404 => {
57 if body.contains("model") || body.contains("Model") {
58 humanize_model_not_found(provider_label, model)
59 } else {
60 format!(
61 "🔍 Ressource introuvable chez {provider}. Vérifie l'URL de base (sparrow config --edit).",
62 provider = provider_label,
63 )
64 }
65 }
66 429 => humanize_rate_limit(provider_label, None),
67 500..=599 => {
68 if body.to_lowercase().contains("overloaded")
69 || body.to_lowercase().contains("capacity")
70 {
71 format!(
72 "🏋️ {provider} est surchargé. Réessaie dans quelques minutes ou change de provider (sparrow route set <provider>).",
73 provider = provider_label,
74 )
75 } else {
76 format!(
77 "💥 Erreur serveur {provider} (HTTP {status}). C'est probablement temporaire — réessaie dans 30 secondes.\n→ Détails : {body}",
78 provider = provider_label,
79 status = status,
80 body = body,
81 )
82 }
83 }
84 _ => format!(
85 "⚠️ Erreur HTTP {status} chez {provider} : {body}\n→ Lance sparrow doctor pour un diagnostic.",
86 status = status,
87 provider = provider_label,
88 body = body,
89 ),
90 }
91}
92
93fn humanize_unauthorized(provider_label: &str) -> String {
95 let (url, signup_hint) = match provider_label.to_lowercase().as_str() {
96 "anthropic" => (
97 "https://console.anthropic.com/settings/keys",
98 " (payant, ~20$/mois de crédits offerts)",
99 ),
100 "nvidia nim" | "nvidia" => (
101 "https://build.nvidia.com/explore/discover",
102 " (gratuit — crée un compte NVIDIA Developer)",
103 ),
104 "openai" | "openai codex" => (
105 "https://platform.openai.com/api-keys",
106 " (payant, crédits gratuits à l'inscription)",
107 ),
108 "google gemini" | "gemini" => (
109 "https://aistudio.google.com/app/apikey",
110 " (gratuit jusqu'à 1500 requêtes/jour)",
111 ),
112 "groq" => (
113 "https://console.groq.com/keys",
114 " (gratuit — généreux tier gratuit)",
115 ),
116 "deepseek" => (
117 "https://platform.deepseek.com/api_keys",
118 " (très bon marché, ~0.27$/M tokens)",
119 ),
120 "openrouter" => (
121 "https://openrouter.ai/keys",
122 " (crédits gratuits à l'inscription)",
123 ),
124 "xai" | "xai (grok)" => (
125 "https://console.x.ai/",
126 " (payant)",
127 ),
128 _ => ("", ""),
129 };
130
131 if url.is_empty() {
132 format!(
133 "🔑 Ta clé API pour {provider} est invalide ou expirée.\n\
134 → Vérifie ta clé avec : sparrow auth list\n\
135 → Ajoutes-en une nouvelle : sparrow auth add {provider_lower}",
136 provider = provider_label,
137 provider_lower = provider_label.to_lowercase(),
138 )
139 } else {
140 format!(
141 "🔑 Ta clé API {provider} est invalide ou expirée.\n\
142 → Va sur {url} pour en créer une{signup}\n\
143 → Puis ajoute-la avec : sparrow auth add {provider_lower}\n\
144 → Ou exporte-la : export {env_var}=\"ta-clé\"",
145 provider = provider_label,
146 url = url,
147 signup = signup_hint,
148 provider_lower = provider_label.to_lowercase().replace(' ', "-"),
149 env_var = provider_env_var(provider_label),
150 )
151 }
152}
153
154fn humanize_rate_limit(provider_label: &str, retry_after: Option<u64>) -> String {
156 let wait = match retry_after {
157 Some(s) if s > 0 => format!("{s} secondes"),
158 _ => "quelques minutes".to_string(),
159 };
160 format!(
161 "⏳ T'as envoyé trop de requêtes à {provider}. Réessaie dans {wait}.\n\
162 → Astuce : utilise un modèle moins cher pour les tâches simples (sparrow route set nvidia).",
163 provider = provider_label,
164 wait = wait,
165 )
166}
167
168fn humanize_model_not_found(provider_label: &str, model: &str) -> String {
170 format!(
171 "🤷 Le modèle \"{model}\" n'existe pas chez {provider}.\n\
172 → Liste les modèles dispos : sparrow model --list\n\
173 → Change de modèle : sparrow model --set {provider_lower}:<model>",
174 model = model,
175 provider = provider_label,
176 provider_lower = provider_label.to_lowercase().replace(' ', "-"),
177 )
178}
179
180fn provider_env_var(provider_label: &str) -> String {
182 match provider_label.to_lowercase().as_str() {
183 "anthropic" => "ANTHROPIC_API_KEY".into(),
184 "nvidia nim" | "nvidia" => "NVIDIA_API_KEY".into(),
185 "openai" | "openai codex" => "OPENAI_API_KEY".into(),
186 "google gemini" | "gemini" => "GEMINI_API_KEY".into(),
187 "groq" => "GROQ_API_KEY".into(),
188 "deepseek" => "DEEPSEEK_API_KEY".into(),
189 "openrouter" => "OPENROUTER_API_KEY".into(),
190 "xai" | "xai (grok)" => "XAI_API_KEY".into(),
191 "huggingface" | "hugging face" => "HF_TOKEN".into(),
192 "nous" | "nous portal" => "NOUS_API_KEY".into(),
193 "novita" | "novitaai" => "NOVITA_API_KEY".into(),
194 "alibaba" | "alibaba cloud" => "DASHSCOPE_API_KEY".into(),
195 other => format!("{}_API_KEY", other.to_uppercase().replace(' ', "_").replace('-', "_")),
196 }
197}
198
199pub fn humanize_connection_error(provider_label: &str, error_msg: &str) -> String {
203 let lower = error_msg.to_lowercase();
204
205 if lower.contains("connection refused") || lower.contains("connect refused") {
206 format!(
207 "🔌 Impossible de contacter {provider}. Le serveur a refusé la connexion.\n\
208 → Vérifie l'URL de base : sparrow config --edit\n\
209 → Si t'utilises Ollama : ollama serve doit tourner.",
210 provider = provider_label,
211 )
212 } else if lower.contains("dns") || lower.contains("no address") || lower.contains("name") {
213 format!(
214 "🌐 Impossible de résoudre le nom d'hôte de {provider}. Check ta connexion internet ou DNS.",
215 provider = provider_label,
216 )
217 } else if lower.contains("timeout") || lower.contains("timed out") {
218 format!(
219 "⏱️ Timeout en contactant {provider}. Check ta connexion ou VPN.\n\
220 → Si le problème persiste, change de provider : sparrow route set nvidia",
221 provider = provider_label,
222 )
223 } else if lower.contains("ssl") || lower.contains("tls") || lower.contains("certificate") {
224 format!(
225 "🔒 Erreur SSL/TLS avec {provider}. Vérifie tes certificats ou désactive le VPN.\n\
226 → Détail : {error_msg}",
227 provider = provider_label,
228 error_msg = error_msg,
229 )
230 } else {
231 format!(
232 "📡 Problème de connexion vers {provider} : {error_msg}\n\
233 → Check ta connexion ou VPN.\n\
234 → Lance sparrow doctor pour un diagnostic complet.",
235 provider = provider_label,
236 error_msg = error_msg,
237 )
238 }
239}
240
241pub fn humanize_config_error(error_type: &str, context: &str) -> String {
243 match error_type {
244 "no_provider" => format!(
245 "⚙️ Aucun provider configuré.\n\
246 → Lance le setup : sparrow setup\n\
247 → Ou définis une variable d'environnement : export {ctx}_API_KEY=\"ta-clé\"\n\
248 → Providers gratuits dispos : NVIDIA (sparrow auth add nvidia), Groq, Gemini",
249 ctx = context.to_uppercase(),
250 ),
251 "no_model" => format!(
252 "⚙️ Aucun modèle défini pour le provider \"{ctx}\".\n\
253 → Liste les modèles : sparrow model --list\n\
254 → Définis un modèle : sparrow model --set {ctx}:<nom-du-modèle>",
255 ctx = context,
256 ),
257 "no_config_file" => format!(
258 "📄 Pas de fichier de config Sparrow trouvé.\n\
259 → Premier lancement ? Lance sparrow setup\n\
260 → Sinon, crée ~/.config/sparrow/config.toml manuellement",
261 ),
262 "config_parse_error" => format!(
263 "📄 Erreur de parsing dans le fichier de config.\n\
264 → Édite le fichier : sparrow config --edit\n\
265 → Détail de l'erreur : {ctx}\n\
266 → Format attendu : TOML valide (voir https://toml.io)",
267 ctx = context,
268 ),
269 "invalid_autonomy" => format!(
270 "⚙️ Niveau d'autonomie invalide : \"{ctx}\".\n\
271 → Valeurs acceptées : supervised, trusted, autonomous\n\
272 → Modifie : sparrow config --edit",
273 ctx = context,
274 ),
275 "invalid_sandbox" => format!(
276 "⚙️ Type de sandbox invalide : \"{ctx}\".\n\
277 → Valeurs acceptées : local, local-hardened, docker\n\
278 → Modifie : sparrow config --edit",
279 ctx = context,
280 ),
281 "budget_exceeded" => format!(
282 "💰 Budget dépassé !\n\
283 → Budget actuel : ${ctx}\n\
284 → Augmente le budget : sparrow config --edit (section [budget])\n\
285 → Ou utilise un provider gratuit : sparrow route set nvidia",
286 ctx = context,
287 ),
288 _ => format!(
289 "⚠️ Erreur de configuration : {ctx}\n\
290 → Lance sparrow doctor pour un diagnostic complet.\n\
291 → Édite la config : sparrow config --edit",
292 ctx = context,
293 ),
294 }
295}
296
297pub fn humanize_anyhow(err: &anyhow::Error, provider_label: &str, model: &str) -> String {
303 let msg = format!("{err:#}");
304
305 if let Some(status) = extract_http_status(&msg) {
307 return humanize_http_error(status, &msg, provider_label, model);
308 }
309
310 if msg.contains("Connection refused")
312 || msg.contains("connect")
313 || msg.contains("DNS")
314 || msg.contains("timeout")
315 || msg.contains("SSL")
316 || msg.contains("TLS")
317 {
318 return humanize_connection_error(provider_label, &msg);
319 }
320
321 if msg.contains("config") || msg.contains("toml") || msg.contains("parse") {
323 return humanize_config_error("config_parse_error", &msg);
324 }
325
326 format!(
328 "💥 Oups ! Une erreur est survenue : {msg}\n\
329 → Lance sparrow doctor pour un diagnostic.\n\
330 → Si le problème persiste, ouvre une issue : https://github.com/ucav/Sparrow/issues",
331 msg = msg,
332 )
333}
334
335fn extract_http_status(msg: &str) -> Option<u16> {
337 let re = regex::Regex::new(r"(?i)(?:HTTP\s+|status:\s*|^\s*)(\d{3})\b").ok()?;
339 re.captures(msg)
340 .and_then(|caps| caps.get(1))
341 .and_then(|m| m.as_str().parse::<u16>().ok())
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn test_unauthorized_anthropic() {
350 let msg = humanize_http_error(401, "", "Anthropic", "claude-sonnet-4-6");
351 assert!(msg.contains("invalide"));
352 assert!(msg.contains("console.anthropic.com"));
353 assert!(msg.contains("ANTHROPIC_API_KEY"));
354 }
355
356 #[test]
357 fn test_rate_limit() {
358 let msg = humanize_rate_limit("Groq", Some(30));
359 assert!(msg.contains("trop de requêtes"));
360 assert!(msg.contains("30 secondes"));
361 }
362
363 #[test]
364 fn test_model_not_found() {
365 let msg = humanize_model_not_found("NVIDIA NIM", "gpt-5-ultra");
366 assert!(msg.contains("n'existe pas"));
367 assert!(msg.contains("gpt-5-ultra"));
368 }
369
370 #[test]
371 fn test_connection_refused() {
372 let msg = humanize_connection_error("Ollama", "Connection refused (os error 111)");
373 assert!(msg.contains("Impossible de contacter"));
374 assert!(msg.contains("ollama serve"));
375 }
376
377 #[test]
378 fn test_config_no_provider() {
379 let msg = humanize_config_error("no_provider", "ANTHROPIC");
380 assert!(msg.contains("Aucun provider"));
381 }
382
383 #[test]
384 fn test_extract_http_status() {
385 assert_eq!(extract_http_status("HTTP 401 Unauthorized"), Some(401));
386 assert_eq!(extract_http_status("status: 429 Too Many Requests"), Some(429));
387 assert_eq!(extract_http_status("500 Internal Server Error"), Some(500));
388 assert_eq!(extract_http_status("no status here"), None);
389 }
390}