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