1use 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
18const STATUS_UNAUTHORIZED: u16 = 401;
20const STATUS_FORBIDDEN: u16 = 403;
21const STATUS_BAD_REQUEST: u16 = 400;
22const STATUS_TOO_MANY_REQUESTS: u16 = 429;
23
24const 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#[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#[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#[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 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#[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 let lower = error_text.to_lowercase();
104 RATE_LIMIT_PATTERNS.iter().any(|pattern| lower.contains(pattern))
105}
106
107#[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#[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#[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#[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 let error_message = sanitize_provider_diagnostic(extract_human_error_message(body).as_bytes());
150 let diagnostic = sanitize_provider_diagnostic(body.as_bytes());
151
152 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
265pub 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 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 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 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 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 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 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 #[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}