1use 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
16pub 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
22const 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#[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#[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#[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 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#[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 let lower = error_text.to_lowercase();
117 RATE_LIMIT_PATTERNS
118 .iter()
119 .any(|pattern| lower.contains(pattern))
120}
121
122#[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#[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#[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#[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 let error_message = extract_human_error_message(body);
177
178 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
301pub 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 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 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 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 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 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 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}