Skip to main content

vtcode_core/llm/providers/
error_handling.rs

1//! Centralized error handling for LLM providers
2//! Eliminates duplicate error handling code across providers
3
4use crate::llm::error_display;
5use crate::llm::provider::{LLMError, LLMErrorMetadata};
6use reqwest::Response;
7use serde_json::Value;
8
9#[derive(Debug, Clone, Default)]
10struct ApiResponseMetadata {
11    request_id: Option<String>,
12    organization_id: Option<String>,
13    retry_after: Option<String>,
14}
15
16/// HTTP status codes for common error types
17pub const STATUS_UNAUTHORIZED: u16 = 401;
18pub const STATUS_FORBIDDEN: u16 = 403;
19pub const STATUS_BAD_REQUEST: u16 = 400;
20pub const STATUS_TOO_MANY_REQUESTS: u16 = 429;
21
22/// Common rate limit error patterns (pre-lowercased for efficient matching)
23const RATE_LIMIT_PATTERNS: &[&str] = &[
24    "insufficient_quota",
25    "resource_exhausted",
26    "quota",
27    "rate limit",
28    "rate_limit",
29    "ratelimit",
30    "ratelimitexceeded",
31    "concurrency",
32    "frequency",
33    "usage limit",
34    "too many requests",
35    "daily call limit",
36    "package has expired",
37];
38
39/// Handle HTTP response errors for Gemini provider
40#[cold]
41pub async fn handle_gemini_http_error(response: Response) -> Result<Response, LLMError> {
42    if response.status().is_success() {
43        return Ok(response);
44    }
45
46    let status = response.status();
47    let metadata = extract_response_metadata(&response);
48    let error_text = response.text().await.unwrap_or_default();
49    Err(parse_api_error_with_metadata(
50        "Gemini",
51        status,
52        &error_text,
53        metadata,
54    ))
55}
56
57/// Handle HTTP response errors for Anthropic provider
58#[cold]
59pub async fn handle_anthropic_http_error(response: Response) -> Result<Response, LLMError> {
60    if response.status().is_success() {
61        return Ok(response);
62    }
63
64    let status = response.status();
65    let metadata = extract_response_metadata(&response);
66    let error_text = response.text().await.unwrap_or_default();
67    Err(parse_api_error_with_metadata(
68        "Anthropic",
69        status,
70        &error_text,
71        metadata,
72    ))
73}
74
75/// Handle HTTP response errors for OpenAI-compatible providers
76#[cold]
77pub async fn handle_openai_http_error(
78    response: Response,
79    provider_name: &'static str,
80    _api_key_env_var: &str,
81) -> Result<Response, LLMError> {
82    if response.status().is_success() {
83        return Ok(response);
84    }
85
86    let status = response.status();
87    let metadata = extract_response_metadata(&response);
88    let error_text = response.text().await.unwrap_or_default();
89
90    // Universal diagnostic logging — helps debug post-tool follow-up failures
91    // and transient API issues across all OpenAI-compatible providers.
92    tracing::warn!(
93        provider = provider_name,
94        status = %status,
95        body = %error_text,
96        "{} HTTP error",
97        provider_name
98    );
99
100    Err(parse_api_error_with_metadata(
101        provider_name,
102        status,
103        &error_text,
104        metadata,
105    ))
106}
107
108/// Check if an error is a rate limit error based on status code and message
109#[cold]
110pub fn is_rate_limit_error(status_code: u16, error_text: &str) -> bool {
111    if status_code == STATUS_TOO_MANY_REQUESTS {
112        return true;
113    }
114
115    // Optimize: Lowercase once and use pre-lowercased patterns
116    let lower = error_text.to_lowercase();
117    RATE_LIMIT_PATTERNS
118        .iter()
119        .any(|pattern| lower.contains(pattern))
120}
121
122/// Handle network errors with consistent formatting
123#[cold]
124pub fn format_network_error(provider: &str, error: &impl std::fmt::Display) -> LLMError {
125    let formatted_error =
126        error_display::format_llm_error(provider, &format!("network error: {}", error));
127    LLMError::Network {
128        message: formatted_error,
129        metadata: None,
130    }
131}
132
133/// Handle JSON parsing errors with consistent formatting
134#[cold]
135pub fn format_parse_error(provider: &str, error: &impl std::fmt::Display) -> LLMError {
136    let formatted_error =
137        error_display::format_llm_error(provider, &format!("failed to parse response: {}", error));
138    LLMError::Provider {
139        message: formatted_error,
140        metadata: None,
141    }
142}
143
144/// Format HTTP error with status code and message
145#[cold]
146pub fn format_http_error(provider: &str, status: reqwest::StatusCode, error_text: &str) -> String {
147    error_display::format_llm_error(provider, &format!("http {}: {}", status, error_text))
148}
149
150/// Parse standard API error response body into LLMError.
151///
152/// Handles multiple provider error formats:
153/// - OpenAI/DeepSeek/ZAI: `{"error": {"message": "..."}}`
154/// - Anthropic: `{"type": "error", "error": {"message": "..."}}`
155/// - Gemini: `{"error": {"message": "...", "status": "..."}}`
156/// - HuggingFace: `{"error": "..."}`
157///
158/// Falls back to raw body if JSON parsing fails.
159#[cold]
160pub fn parse_api_error(
161    provider_name: &'static str,
162    status: reqwest::StatusCode,
163    body: &str,
164) -> LLMError {
165    parse_api_error_with_metadata(provider_name, status, body, ApiResponseMetadata::default())
166}
167
168#[cold]
169fn parse_api_error_with_metadata(
170    provider_name: &'static str,
171    status: reqwest::StatusCode,
172    body: &str,
173    response_metadata: ApiResponseMetadata,
174) -> LLMError {
175    // Try to extract a meaningful error message from JSON
176    let error_message = extract_human_error_message(body);
177
178    // Categorize by status code
179    let status_code = status.as_u16();
180
181    match status_code {
182        401 | 403 => LLMError::Authentication {
183            message: error_display::format_llm_error(
184                provider_name,
185                &authentication_error_message(provider_name, &error_message),
186            ),
187            metadata: Some(LLMErrorMetadata::new(
188                provider_name,
189                Some(status_code),
190                Some("authentication_error".to_string()),
191                response_metadata.request_id.clone(),
192                response_metadata.organization_id.clone(),
193                response_metadata.retry_after.clone(),
194                Some(body.to_string()),
195            )),
196        },
197        402 => LLMError::InvalidRequest {
198            message: error_display::format_llm_error(
199                provider_name,
200                &format!("insufficient balance: {}", error_message),
201            ),
202            metadata: Some(LLMErrorMetadata::new(
203                provider_name,
204                Some(status_code),
205                Some("insufficient_balance".to_string()),
206                response_metadata.request_id.clone(),
207                response_metadata.organization_id.clone(),
208                response_metadata.retry_after.clone(),
209                Some(body.to_string()),
210            )),
211        },
212        422 => LLMError::InvalidRequest {
213            message: error_display::format_llm_error(
214                provider_name,
215                &format!("invalid parameters: {}", error_message),
216            ),
217            metadata: Some(LLMErrorMetadata::new(
218                provider_name,
219                Some(status_code),
220                Some("invalid_parameters".to_string()),
221                response_metadata.request_id.clone(),
222                response_metadata.organization_id.clone(),
223                response_metadata.retry_after.clone(),
224                Some(body.to_string()),
225            )),
226        },
227        429 => LLMError::RateLimit {
228            metadata: Some(LLMErrorMetadata::new(
229                provider_name,
230                Some(status_code),
231                Some("rate_limit_error".to_string()),
232                response_metadata.request_id.clone(),
233                response_metadata.organization_id.clone(),
234                response_metadata.retry_after.clone(),
235                Some(error_message),
236            )),
237        },
238        400 if is_rate_limit_error(status_code, body) => LLMError::RateLimit {
239            metadata: Some(LLMErrorMetadata::new(
240                provider_name,
241                Some(status_code),
242                Some("quota_exceeded".to_string()),
243                response_metadata.request_id.clone(),
244                response_metadata.organization_id.clone(),
245                response_metadata.retry_after.clone(),
246                Some(error_message),
247            )),
248        },
249        400 => LLMError::InvalidRequest {
250            message: error_display::format_llm_error(
251                provider_name,
252                &format!("invalid request: {}", error_message),
253            ),
254            metadata: Some(LLMErrorMetadata::new(
255                provider_name,
256                Some(status_code),
257                Some("invalid_request".to_string()),
258                response_metadata.request_id.clone(),
259                response_metadata.organization_id.clone(),
260                response_metadata.retry_after.clone(),
261                Some(body.to_string()),
262            )),
263        },
264        _ => LLMError::Provider {
265            message: error_display::format_llm_error(
266                provider_name,
267                &format!("http {}: {}", status, error_message),
268            ),
269            metadata: Some(LLMErrorMetadata::new(
270                provider_name,
271                Some(status_code),
272                None,
273                response_metadata.request_id,
274                response_metadata.organization_id,
275                response_metadata.retry_after,
276                Some(body.to_string()),
277            )),
278        },
279    }
280}
281
282fn authentication_error_message(provider_name: &str, error_message: &str) -> String {
283    let trimmed = error_message.trim();
284    if provider_name.eq_ignore_ascii_case("Moonshot") {
285        return format!(
286            "authentication failed: {}. use a MOONSHOT_API_KEY from https://platform.kimi.ai/console/api-keys; Kimi web or app login credentials do not work for the API",
287            trimmed
288        );
289    }
290
291    if provider_name.eq_ignore_ascii_case("Qwen") {
292        return format!(
293            "authentication failed: {}. set QWEN_API_KEY to your DashScope API key from https://dashscope.console.aliyun.com",
294            trimmed
295        );
296    }
297
298    format!("authentication failed: {}", trimmed)
299}
300
301/// Extract the most human-readable error message from a provider's JSON error body.
302///
303/// Handles all known provider response schemas:
304/// - OpenAI/DeepSeek/ZAI/Anthropic: `{"error": {"message": "..."}}`
305/// - HuggingFace: `{"error": "..."}`
306/// - Gemini: `{"error": {"status": "..."}}`
307/// - FastAPI / OpenAI alternate: `{"detail": "..."}`
308/// - Generic: `{"message": "..."}`
309///
310/// Falls back to the raw body if no known field is found.
311pub fn extract_human_error_message(body: &str) -> String {
312    let Ok(json) = serde_json::from_str::<Value>(body) else {
313        return body.to_string();
314    };
315
316    // OpenAI/DeepSeek/ZAI/Anthropic: {"error": {"message": "..."}}
317    if let Some(msg) = json
318        .get("error")
319        .and_then(|e| e.get("message"))
320        .and_then(|m| m.as_str())
321        .filter(|s| !s.trim().is_empty())
322    {
323        return msg.to_string();
324    }
325    // Mistral: {"object":"error","message":{"detail":[{"msg":"..."}]}}
326    if let Some(detail) = json
327        .get("message")
328        .and_then(|m| m.get("detail"))
329        .and_then(|d| d.as_array())
330        && let Some(first) = detail
331            .first()
332            .and_then(|d| d.get("msg"))
333            .and_then(|m| m.as_str())
334    {
335        return first.to_string();
336    }
337    // HuggingFace simple: {"error": "..."}
338    if let Some(msg) = json
339        .get("error")
340        .and_then(|e| e.as_str())
341        .filter(|s| !s.trim().is_empty())
342    {
343        return msg.to_string();
344    }
345    // FastAPI / OpenAI alternate: {"detail": "..."}
346    if let Some(msg) = json
347        .get("detail")
348        .and_then(|d| d.as_str())
349        .filter(|s| !s.trim().is_empty())
350    {
351        return msg.to_string();
352    }
353    // Gemini: {"error": {"status": "..."}}
354    if let Some(msg) = json
355        .get("error")
356        .and_then(|e| e.get("status"))
357        .and_then(|s| s.as_str())
358        .filter(|s| !s.trim().is_empty())
359    {
360        return msg.to_string();
361    }
362    // Top-level message: {"message": "..."}
363    if let Some(msg) = json
364        .get("message")
365        .and_then(|m| m.as_str())
366        .filter(|s| !s.trim().is_empty())
367    {
368        return msg.to_string();
369    }
370
371    body.to_string()
372}
373
374fn extract_response_metadata(response: &Response) -> ApiResponseMetadata {
375    ApiResponseMetadata {
376        request_id: extract_header(
377            response,
378            &["request-id", "x-request-id", "openai-request-id"],
379        ),
380        organization_id: extract_header(
381            response,
382            &[
383                "anthropic-organization-id",
384                "openai-organization",
385                "x-organization-id",
386            ],
387        ),
388        retry_after: extract_header(response, &["retry-after"]),
389    }
390}
391
392fn extract_header(response: &Response, names: &[&str]) -> Option<String> {
393    names.iter().find_map(|name| {
394        response
395            .headers()
396            .get(*name)
397            .and_then(|value| value.to_str().ok())
398            .map(ToOwned::to_owned)
399    })
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_rate_limit_detection() {
408        assert!(is_rate_limit_error(429, ""));
409        assert!(is_rate_limit_error(400, "insufficient_quota"));
410        assert!(is_rate_limit_error(400, "RESOURCE_EXHAUSTED"));
411        assert!(is_rate_limit_error(400, "rate limit exceeded"));
412        assert!(!is_rate_limit_error(400, "invalid request"));
413        assert!(!is_rate_limit_error(200, ""));
414    }
415
416    #[test]
417    fn test_status_codes() {
418        assert_eq!(STATUS_UNAUTHORIZED, 401);
419        assert_eq!(STATUS_FORBIDDEN, 403);
420        assert_eq!(STATUS_BAD_REQUEST, 400);
421        assert_eq!(STATUS_TOO_MANY_REQUESTS, 429);
422    }
423
424    #[test]
425    fn parse_openai_rate_limit_error_preserves_provider_message() {
426        let error = parse_api_error(
427            "OpenAI",
428            reqwest::StatusCode::TOO_MANY_REQUESTS,
429            r#"{"error":{"message":"Project rate limit exceeded for this model.","type":"rate_limit_error"}}"#,
430        );
431
432        match error {
433            LLMError::RateLimit { metadata } => {
434                assert_eq!(
435                    metadata.as_ref().and_then(|meta| meta.message.as_deref()),
436                    Some("Project rate limit exceeded for this model.")
437                );
438            }
439            other => panic!("expected rate limit error, got {other:?}"),
440        }
441    }
442
443    #[test]
444    fn parse_moonshot_auth_error_includes_platform_key_guidance() {
445        let error = parse_api_error(
446            "Moonshot",
447            reqwest::StatusCode::UNAUTHORIZED,
448            r#"{"error":{"message":"Invalid Authentication","type":"invalid_authentication_error"}}"#,
449        );
450
451        match error {
452            LLMError::Authentication { message, metadata } => {
453                assert!(message.contains("Invalid Authentication"));
454                assert!(message.contains("MOONSHOT_API_KEY"));
455                assert!(message.contains("platform.kimi.ai/console/api-keys"));
456                assert_eq!(
457                    metadata.as_ref().and_then(|meta| meta.code.as_deref()),
458                    Some("authentication_error")
459                );
460            }
461            other => panic!("expected authentication error, got {other:?}"),
462        }
463    }
464
465    #[test]
466    fn extract_openai_error_message() {
467        let body = r#"{"error":{"message":"Model not found","type":"invalid_request_error"}}"#;
468        assert_eq!(extract_human_error_message(body), "Model not found");
469    }
470
471    #[test]
472    fn extract_detail_field() {
473        let body = r#"{"detail":"The 'gpt-5.4' model is not supported with this method."}"#;
474        assert_eq!(
475            extract_human_error_message(body),
476            "The 'gpt-5.4' model is not supported with this method."
477        );
478    }
479
480    #[test]
481    fn extract_huggingface_error_string() {
482        let body = r#"{"error":"Model is currently loading"}"#;
483        assert_eq!(
484            extract_human_error_message(body),
485            "Model is currently loading"
486        );
487    }
488
489    #[test]
490    fn extract_top_level_message() {
491        let body = r#"{"message":"Unauthorized access"}"#;
492        assert_eq!(extract_human_error_message(body), "Unauthorized access");
493    }
494
495    #[test]
496    fn extract_gemini_status() {
497        let body = r#"{"error":{"status":"PERMISSION_DENIED","code":403}}"#;
498        assert_eq!(extract_human_error_message(body), "PERMISSION_DENIED");
499    }
500
501    #[test]
502    fn extract_falls_back_to_raw_body() {
503        let body = "Internal Server Error";
504        assert_eq!(extract_human_error_message(body), body);
505    }
506
507    #[test]
508    fn extract_falls_back_for_unknown_json_schema() {
509        let body = r#"{"code":500,"status":"error"}"#;
510        assert_eq!(extract_human_error_message(body), body);
511    }
512}