use serde::{Deserialize, Serialize};
use crate::user_facing_error::{
is_attestation_required_message, is_provider_quota_message, is_usage_limit_message,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BillingPressureReason {
InFlightBudgetExhausted,
InsufficientCredits,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RejectedProviderCapability {
AnthropicServerCompaction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LlmErrorKind {
Authentication,
QuotaExhausted,
BillingPressure {
reason: BillingPressureReason,
retry_after_secs: Option<u64>,
},
RateLimited,
Unavailable,
AttestationRequired,
InvalidRequest,
MalformedResponse,
Other,
}
impl LlmErrorKind {
pub fn from_provider_code(code: &str) -> Option<Self> {
let code = code.trim().to_ascii_lowercase();
match code.as_str() {
"insufficient_quota"
| "billing_hard_limit_reached"
| "credit_balance_too_low"
| "credit_balance_exhausted" => Some(Self::QuotaExhausted),
"authentication_error" | "invalid_api_key" | "permission_denied" => {
Some(Self::Authentication)
}
"rate_limit_exceeded" | "rate_limit_error" | "overloaded_error" => {
Some(Self::RateLimited)
}
"server_error"
| "internal_error"
| "processing_error"
| "service_unavailable"
| "timeout" => Some(Self::Unavailable),
"invalid_request_error" | "model_not_found" => Some(Self::InvalidRequest),
_ => None,
}
}
pub fn from_provider_status(status: u16, body: &str) -> Self {
if is_provider_quota_message(body) || is_usage_limit_message(body) {
return LlmErrorKind::QuotaExhausted;
}
if is_attestation_required_message(body) {
return LlmErrorKind::AttestationRequired;
}
match status {
401 | 403 => LlmErrorKind::Authentication,
429 => LlmErrorKind::RateLimited,
408 | 409 => LlmErrorKind::Unavailable,
501 => LlmErrorKind::Other,
500..=599 => LlmErrorKind::Unavailable,
400..=499 => LlmErrorKind::InvalidRequest,
_ => LlmErrorKind::Other,
}
}
pub fn from_error_text(text: &str) -> Self {
if is_provider_quota_message(text) || is_usage_limit_message(text) {
return LlmErrorKind::QuotaExhausted;
}
let lower = text.to_ascii_lowercase();
if lower.contains("throttlingexception")
|| lower.contains("toomanyrequestsexception")
|| lower.contains("rate limit")
|| lower.contains("too many requests")
{
return LlmErrorKind::RateLimited;
}
if lower.contains("accessdeniedexception")
|| lower.contains("unrecognizedclientexception")
|| lower.contains("expiredtokenexception")
|| lower.contains("invalidsignatureexception")
|| lower.contains("unauthorized")
{
return LlmErrorKind::Authentication;
}
if lower.contains("serviceunavailable")
|| lower.contains("service unavailable")
|| lower.contains("internalserverexception")
|| lower.contains("modelnotreadyexception")
{
return LlmErrorKind::Unavailable;
}
LlmErrorKind::Other
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LlmError {
pub kind: LlmErrorKind,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rejected_capability: Option<RejectedProviderCapability>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_after_secs: Option<u64>,
#[serde(default)]
pub retry_attempts: u32,
#[serde(default)]
pub retry_wait_ms: u64,
#[serde(default)]
pub retry_handled: bool,
}
impl LlmError {
pub fn new(kind: LlmErrorKind, message: impl Into<String>) -> Self {
LlmError {
kind,
message: message.into(),
status: None,
code: None,
rejected_capability: None,
retry_after_secs: None,
retry_attempts: 0,
retry_wait_ms: 0,
retry_handled: false,
}
}
#[must_use]
pub fn with_status(mut self, status: u16) -> Self {
self.status = Some(status);
self
}
#[must_use]
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
#[must_use]
pub fn with_rejected_capability(mut self, capability: RejectedProviderCapability) -> Self {
self.rejected_capability = Some(capability);
self
}
#[must_use]
pub fn with_retry_after_secs(mut self, secs: u64) -> Self {
self.retry_after_secs = Some(secs);
self
}
}
impl std::fmt::Display for LlmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
pub(crate) fn provider_error_code_in(body: &str) -> Option<String> {
let body = body.trim();
if !body.starts_with('{') || body.len() > MAX_ERROR_BODY_PARSE_BYTES {
return None;
}
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
let error = parsed.get("error")?;
let code = error
.get("code")
.and_then(|value| value.as_str())
.or_else(|| error.get("type").and_then(|value| value.as_str()))?;
let code = code.trim();
if code.is_empty() || code.len() > MAX_ERROR_CODE_BYTES {
return None;
}
Some(code.to_owned())
}
const MAX_ERROR_BODY_PARSE_BYTES: usize = 64 * 1024;
const MAX_ERROR_CODE_BYTES: usize = 128;