use rskit_ai::GenAiError;
use rskit_errors::{AppError, ErrorCode};
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct ApiError {
pub status: u16,
pub provider: String,
pub message: String,
pub error_type: Option<String>,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} API error (HTTP {}): {}",
self.provider, self.status, self.message
)
}
}
impl std::error::Error for ApiError {}
impl ApiError {
#[must_use]
pub fn to_genai_error(&self) -> GenAiError {
let kind = self
.error_type
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase();
if self.status == 429 || kind.contains("rate") {
GenAiError::RateLimited
} else if self.status == 404 || kind.contains("model_not_found") {
GenAiError::ModelNotFound
} else if kind.contains("context") || kind.contains("token") && kind.contains("limit") {
GenAiError::ContextLengthExceeded
} else if kind.contains("content") || kind.contains("safety") || kind.contains("filter") {
GenAiError::ContentFilter
} else if self.status == 503 || kind.contains("overloaded") {
GenAiError::ModelOverloaded
} else {
GenAiError::InvalidRequest(self.message.clone())
}
}
}
impl From<ApiError> for AppError {
fn from(e: ApiError) -> Self {
let code = match e.status {
401 => ErrorCode::Unauthorized,
403 => ErrorCode::Forbidden,
404 => ErrorCode::NotFound,
429 => ErrorCode::RateLimited,
_ => ErrorCode::ExternalService,
};
Self::new(code, e.to_string())
.with_detail("provider", e.provider)
.with_detail("status", e.status.to_string())
}
}
#[derive(Deserialize)]
struct OpenAiErrorBody {
error: Option<OpenAiErrorDetail>,
}
#[derive(Deserialize)]
struct OpenAiErrorDetail {
message: Option<String>,
#[serde(rename = "type")]
error_type: Option<String>,
}
pub fn parse_openai_error(status: u16, body: &str) -> ApiError {
let (message, error_type) = serde_json::from_str::<OpenAiErrorBody>(body)
.ok()
.and_then(|b| b.error)
.map_or_else(
|| (body.to_string(), None),
|e| (e.message.unwrap_or_else(|| body.to_string()), e.error_type),
);
ApiError {
status,
provider: "openai".to_string(),
message,
error_type,
}
}
#[derive(Deserialize)]
struct AnthropicErrorBody {
error: Option<AnthropicErrorDetail>,
}
#[derive(Deserialize)]
struct AnthropicErrorDetail {
message: Option<String>,
#[serde(rename = "type")]
error_type: Option<String>,
}
pub fn parse_anthropic_error(status: u16, body: &str) -> ApiError {
let (message, error_type) = serde_json::from_str::<AnthropicErrorBody>(body)
.ok()
.and_then(|b| b.error)
.map_or_else(
|| (body.to_string(), None),
|e| (e.message.unwrap_or_else(|| body.to_string()), e.error_type),
);
ApiError {
status,
provider: "anthropic".to_string(),
message,
error_type,
}
}
#[derive(Deserialize)]
struct GeminiErrorBody {
error: Option<GeminiErrorDetail>,
}
#[derive(Deserialize)]
struct GeminiErrorDetail {
message: Option<String>,
status: Option<String>,
}
pub fn parse_gemini_error(status: u16, body: &str) -> ApiError {
let (message, error_type) = serde_json::from_str::<GeminiErrorBody>(body)
.ok()
.and_then(|b| b.error)
.map_or_else(
|| (body.to_string(), None),
|e| (e.message.unwrap_or_else(|| body.to_string()), e.status),
);
ApiError {
status,
provider: "gemini".to_string(),
message,
error_type,
}
}
pub const fn estimate_tokens(text: &str) -> usize {
text.len() / 4
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_openai_error_structured() {
let body = r#"{"error":{"message":"Rate limit exceeded","type":"rate_limit_error"}}"#;
let err = parse_openai_error(429, body);
assert_eq!(err.status, 429);
assert_eq!(err.provider, "openai");
assert_eq!(err.message, "Rate limit exceeded");
assert_eq!(err.error_type.as_deref(), Some("rate_limit_error"));
}
#[test]
fn parse_openai_error_plain_text() {
let err = parse_openai_error(500, "internal error");
assert_eq!(err.message, "internal error");
assert!(err.error_type.is_none());
}
#[test]
fn parse_anthropic_error_structured() {
let body = r#"{"error":{"message":"Invalid API key","type":"authentication_error"}}"#;
let err = parse_anthropic_error(401, body);
assert_eq!(err.status, 401);
assert_eq!(err.provider, "anthropic");
assert_eq!(err.message, "Invalid API key");
}
#[test]
fn parse_anthropic_error_plain_text() {
let err = parse_anthropic_error(500, "server error");
assert_eq!(err.message, "server error");
}
#[test]
fn parse_gemini_error_structured() {
let body =
r#"{"error":{"message":"API key not valid","code":400,"status":"INVALID_ARGUMENT"}}"#;
let err = parse_gemini_error(400, body);
assert_eq!(err.status, 400);
assert_eq!(err.provider, "gemini");
assert_eq!(err.message, "API key not valid");
assert_eq!(err.error_type.as_deref(), Some("INVALID_ARGUMENT"));
}
#[test]
fn parse_gemini_error_plain_text() {
let err = parse_gemini_error(500, "oops");
assert_eq!(err.message, "oops");
}
#[test]
fn api_error_into_app_error() {
let api_err = ApiError {
status: 429,
provider: "openai".to_string(),
message: "rate limited".to_string(),
error_type: None,
};
assert_eq!(api_err.to_genai_error(), GenAiError::RateLimited);
let converted: AppError = api_err.into();
assert_eq!(converted.code(), ErrorCode::RateLimited);
}
#[test]
fn estimate_tokens_basic() {
assert_eq!(estimate_tokens("hello world!"), 3); assert_eq!(estimate_tokens(""), 0);
}
}