1use 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
17pub 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
23const 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#[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#[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#[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 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#[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 let lower = error_text.to_lowercase();
118 RATE_LIMIT_PATTERNS
119 .iter()
120 .any(|pattern| lower.contains(pattern))
121}
122
123#[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#[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#[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#[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 let error_message = extract_human_error_message(body);
178
179 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
302pub 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 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 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 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 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 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 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}