Skip to main content

vtcode_llm/providers/
error_handling.rs

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