use thiserror::Error;
#[derive(Debug, Error)]
#[error("{0}")]
pub struct ParseError(pub String);
impl ParseError {
pub fn new(msg: impl Into<String>) -> Self {
Self(msg.into())
}
}
#[derive(Debug, Error)]
#[error("{message}")]
pub struct LlmError {
pub kind: LlmErrorKind,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmErrorKind {
ContextLengthExceeded,
MalformedToolCall,
RateLimited,
StreamError,
ContentFiltered,
InvalidRequest,
Configuration,
Other,
}
impl LlmError {
pub fn new(kind: LlmErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
pub fn classify(message: impl Into<String>) -> Self {
let message = message.into();
let kind = Self::classify_message(&message);
Self { kind, message }
}
fn classify_message(msg: &str) -> LlmErrorKind {
if msg.contains("maximum context length")
|| msg.contains("context_length_exceeded")
|| msg.contains("too many tokens")
|| msg.contains("exceeds the model's context")
|| msg.contains("reduce the length of the input")
|| msg.contains("maximum input length")
|| msg.contains("prompt is too long")
|| msg.contains("input too long")
|| msg.contains("input size exceeds")
{
return LlmErrorKind::ContextLengthExceeded;
}
if msg.contains("failed to parse JSON")
|| msg.contains("invalid_request_error") && msg.contains("tool")
{
return LlmErrorKind::MalformedToolCall;
}
if msg.contains("rate_limit") || msg.contains("Rate limit") {
return LlmErrorKind::RateLimited;
}
if msg.contains("LLM stream error") || msg.contains("stream failed") {
return LlmErrorKind::StreamError;
}
match http_status(msg) {
Some(401..=404) => LlmErrorKind::Configuration,
Some(413) => LlmErrorKind::ContextLengthExceeded,
Some(408 | 429 | 500..=599) => LlmErrorKind::StreamError,
Some(400..=499) => LlmErrorKind::InvalidRequest,
_ => LlmErrorKind::Other,
}
}
pub fn is_recoverable(&self) -> bool {
matches!(
self.kind,
LlmErrorKind::ContextLengthExceeded | LlmErrorKind::MalformedToolCall
)
}
}
impl LlmErrorKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::ContextLengthExceeded => "context_length_exceeded",
Self::MalformedToolCall => "malformed_tool_call",
Self::RateLimited => "rate_limited",
Self::StreamError => "stream_error",
Self::ContentFiltered => "content_filtered",
Self::InvalidRequest => "invalid_request",
Self::Configuration => "configuration",
Self::Other => "other",
}
}
}
fn http_status(msg: &str) -> Option<u16> {
msg.match_indices("error").find_map(|(at, word)| {
let rest = msg[at + word.len()..].trim_start_matches([' ', '(']);
let digits = rest.get(..3)?;
let more = rest[3..].starts_with(|c: char| c.is_ascii_digit());
if more || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits
.parse()
.ok()
.filter(|status| (400..600).contains(status))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_context_length_openai() {
let err = LlmError::classify(
"OpenAI error: API error 400: {\"error\":{\"message\":\"This model's maximum context length is 128000 tokens\",\"type\":\"invalid_request_error\",\"code\":\"context_length_exceeded\"}}",
);
assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
assert!(err.is_recoverable());
}
#[test]
fn classify_input_size_openrouter() {
let err = LlmError::classify(
"openrouter error: API error 400 Bad Request: {\"error\":{\"message\":\"The total text input size exceeds 8 MB\",\"code\":400}}",
);
assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
}
#[test]
fn classify_context_length_anthropic() {
let err = LlmError::classify(
"Anthropic error: API error 400: prompt is too long: 200000 tokens > 200000 maximum",
);
assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
}
#[test]
fn classify_context_length_too_many_tokens() {
let err = LlmError::classify("error: too many tokens in the input");
assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
}
#[test]
fn classify_context_length_reduce_input() {
let err = LlmError::classify("Please reduce the length of the input messages");
assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
}
#[test]
fn classify_malformed_tool_call() {
let err = LlmError::classify("failed to parse JSON for tool call arguments");
assert_eq!(err.kind, LlmErrorKind::MalformedToolCall);
assert!(err.is_recoverable());
}
#[test]
fn classify_rate_limited() {
let err = LlmError::classify("Rate limit exceeded, please retry after 30s");
assert_eq!(err.kind, LlmErrorKind::RateLimited);
assert!(!err.is_recoverable());
}
#[test]
fn classify_other() {
let err = LlmError::classify("Internal server error");
assert_eq!(err.kind, LlmErrorKind::Other);
assert!(!err.is_recoverable());
}
#[test]
fn classify_by_http_status() {
for (message, kind) in [
(
"openrouter error: API error 401 Unauthorized: {}",
LlmErrorKind::Configuration,
),
(
"LLM provider stream error (400): Provider returned error",
LlmErrorKind::InvalidRequest,
),
(
"OpenAI API error 413 Payload Too Large: ",
LlmErrorKind::ContextLengthExceeded,
),
(
"API error 503 Service Unavailable",
LlmErrorKind::StreamError,
),
("API error 429 Too Many Requests", LlmErrorKind::StreamError),
("error 4000 things", LlmErrorKind::Other),
] {
assert_eq!(LlmError::classify(message).kind, kind, "{message}");
}
}
#[test]
fn llm_error_display() {
let err = LlmError::new(LlmErrorKind::ContextLengthExceeded, "too long");
assert_eq!(err.to_string(), "too long");
}
#[test]
fn llm_error_downcast_from_anyhow() {
let anyhow_err: anyhow::Error =
LlmError::new(LlmErrorKind::ContextLengthExceeded, "test").into();
let llm_err = anyhow_err.downcast_ref::<LlmError>().unwrap();
assert_eq!(llm_err.kind, LlmErrorKind::ContextLengthExceeded);
}
#[test]
fn llm_error_kind_as_str() {
assert_eq!(
LlmErrorKind::ContextLengthExceeded.as_str(),
"context_length_exceeded"
);
assert_eq!(
LlmErrorKind::MalformedToolCall.as_str(),
"malformed_tool_call"
);
assert_eq!(LlmErrorKind::RateLimited.as_str(), "rate_limited");
assert_eq!(LlmErrorKind::Other.as_str(), "other");
}
}