Skip to main content

rskit_llm_common/
errors.rs

1//! Common error parsing and token estimation utilities.
2
3use rskit_ai::GenAiError;
4use rskit_errors::{AppError, ErrorCode};
5use serde::Deserialize;
6
7/// Structured API error returned by LLM providers.
8#[derive(Debug, Clone)]
9pub struct ApiError {
10    /// HTTP status code from the provider.
11    pub status: u16,
12    /// Provider name (e.g. "openai", "anthropic", "gemini").
13    pub provider: String,
14    /// Error message extracted from the response body.
15    pub message: String,
16    /// Optional error type/code from the provider.
17    pub error_type: Option<String>,
18}
19
20impl std::fmt::Display for ApiError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(
23            f,
24            "{} API error (HTTP {}): {}",
25            self.provider, self.status, self.message
26        )
27    }
28}
29
30impl std::error::Error for ApiError {}
31
32impl ApiError {
33    /// Map provider wire errors to canonical `GenAI` sentinels at the adapter boundary.
34    #[must_use]
35    pub fn to_genai_error(&self) -> GenAiError {
36        let kind = self
37            .error_type
38            .as_deref()
39            .unwrap_or_default()
40            .to_ascii_lowercase();
41        if self.status == 429 || kind.contains("rate") {
42            GenAiError::RateLimited
43        } else if self.status == 404 || kind.contains("model_not_found") {
44            GenAiError::ModelNotFound
45        } else if kind.contains("context") || kind.contains("token") && kind.contains("limit") {
46            GenAiError::ContextLengthExceeded
47        } else if kind.contains("content") || kind.contains("safety") || kind.contains("filter") {
48            GenAiError::ContentFilter
49        } else if self.status == 503 || kind.contains("overloaded") {
50            GenAiError::ModelOverloaded
51        } else {
52            GenAiError::InvalidRequest(self.message.clone())
53        }
54    }
55}
56
57impl From<ApiError> for AppError {
58    fn from(e: ApiError) -> Self {
59        let code = match e.status {
60            401 => ErrorCode::Unauthorized,
61            403 => ErrorCode::Forbidden,
62            404 => ErrorCode::NotFound,
63            429 => ErrorCode::RateLimited,
64            _ => ErrorCode::ExternalService,
65        };
66        Self::new(code, e.to_string())
67            .with_detail("provider", e.provider)
68            .with_detail("status", e.status.to_string())
69    }
70}
71
72// --- OpenAI error body ---
73
74#[derive(Deserialize)]
75struct OpenAiErrorBody {
76    error: Option<OpenAiErrorDetail>,
77}
78
79#[derive(Deserialize)]
80struct OpenAiErrorDetail {
81    message: Option<String>,
82    #[serde(rename = "type")]
83    error_type: Option<String>,
84}
85
86/// Parse an OpenAI-style error response body into an [`ApiError`].
87pub fn parse_openai_error(status: u16, body: &str) -> ApiError {
88    let (message, error_type) = serde_json::from_str::<OpenAiErrorBody>(body)
89        .ok()
90        .and_then(|b| b.error)
91        .map_or_else(
92            || (body.to_string(), None),
93            |e| (e.message.unwrap_or_else(|| body.to_string()), e.error_type),
94        );
95
96    ApiError {
97        status,
98        provider: "openai".to_string(),
99        message,
100        error_type,
101    }
102}
103
104// --- Anthropic error body ---
105
106#[derive(Deserialize)]
107struct AnthropicErrorBody {
108    error: Option<AnthropicErrorDetail>,
109}
110
111#[derive(Deserialize)]
112struct AnthropicErrorDetail {
113    message: Option<String>,
114    #[serde(rename = "type")]
115    error_type: Option<String>,
116}
117
118/// Parse an Anthropic-style error response body into an [`ApiError`].
119pub fn parse_anthropic_error(status: u16, body: &str) -> ApiError {
120    let (message, error_type) = serde_json::from_str::<AnthropicErrorBody>(body)
121        .ok()
122        .and_then(|b| b.error)
123        .map_or_else(
124            || (body.to_string(), None),
125            |e| (e.message.unwrap_or_else(|| body.to_string()), e.error_type),
126        );
127
128    ApiError {
129        status,
130        provider: "anthropic".to_string(),
131        message,
132        error_type,
133    }
134}
135
136// --- Gemini error body ---
137
138#[derive(Deserialize)]
139struct GeminiErrorBody {
140    error: Option<GeminiErrorDetail>,
141}
142
143#[derive(Deserialize)]
144struct GeminiErrorDetail {
145    message: Option<String>,
146    status: Option<String>,
147}
148
149/// Parse a Google Gemini-style error response body into an [`ApiError`].
150pub fn parse_gemini_error(status: u16, body: &str) -> ApiError {
151    let (message, error_type) = serde_json::from_str::<GeminiErrorBody>(body)
152        .ok()
153        .and_then(|b| b.error)
154        .map_or_else(
155            || (body.to_string(), None),
156            |e| (e.message.unwrap_or_else(|| body.to_string()), e.status),
157        );
158
159    ApiError {
160        status,
161        provider: "gemini".to_string(),
162        message,
163        error_type,
164    }
165}
166
167/// Rough token estimator (~4 chars per token).
168///
169/// Use when a dedicated tokenizer is unavailable.
170pub const fn estimate_tokens(text: &str) -> usize {
171    text.len() / 4
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn parse_openai_error_structured() {
180        let body = r#"{"error":{"message":"Rate limit exceeded","type":"rate_limit_error"}}"#;
181        let err = parse_openai_error(429, body);
182        assert_eq!(err.status, 429);
183        assert_eq!(err.provider, "openai");
184        assert_eq!(err.message, "Rate limit exceeded");
185        assert_eq!(err.error_type.as_deref(), Some("rate_limit_error"));
186    }
187
188    #[test]
189    fn parse_openai_error_plain_text() {
190        let err = parse_openai_error(500, "internal error");
191        assert_eq!(err.message, "internal error");
192        assert!(err.error_type.is_none());
193    }
194
195    #[test]
196    fn parse_anthropic_error_structured() {
197        let body = r#"{"error":{"message":"Invalid API key","type":"authentication_error"}}"#;
198        let err = parse_anthropic_error(401, body);
199        assert_eq!(err.status, 401);
200        assert_eq!(err.provider, "anthropic");
201        assert_eq!(err.message, "Invalid API key");
202    }
203
204    #[test]
205    fn parse_anthropic_error_plain_text() {
206        let err = parse_anthropic_error(500, "server error");
207        assert_eq!(err.message, "server error");
208    }
209
210    #[test]
211    fn parse_gemini_error_structured() {
212        let body =
213            r#"{"error":{"message":"API key not valid","code":400,"status":"INVALID_ARGUMENT"}}"#;
214        let err = parse_gemini_error(400, body);
215        assert_eq!(err.status, 400);
216        assert_eq!(err.provider, "gemini");
217        assert_eq!(err.message, "API key not valid");
218        assert_eq!(err.error_type.as_deref(), Some("INVALID_ARGUMENT"));
219    }
220
221    #[test]
222    fn parse_gemini_error_plain_text() {
223        let err = parse_gemini_error(500, "oops");
224        assert_eq!(err.message, "oops");
225    }
226
227    #[test]
228    fn api_error_into_app_error() {
229        let api_err = ApiError {
230            status: 429,
231            provider: "openai".to_string(),
232            message: "rate limited".to_string(),
233            error_type: None,
234        };
235        assert_eq!(api_err.to_genai_error(), GenAiError::RateLimited);
236        let converted: AppError = api_err.into();
237        assert_eq!(converted.code(), ErrorCode::RateLimited);
238    }
239
240    #[test]
241    fn estimate_tokens_basic() {
242        assert_eq!(estimate_tokens("hello world!"), 3); // 12 chars / 4
243        assert_eq!(estimate_tokens(""), 0);
244    }
245}