refactor(rust): make the exception mapper a pure rule table

Rebuild exception_type around text rules per provider family and one shared
status table. The mapper takes the context, an injected redactor and the
original failure, and returns a PublicError with the message, the real
upstream response and the debug text. Every divergence from the Python
mapper and every known gap is listed in the module header

Match Python on a standalone 429 with an unknown status and on Cohere's
rules for failures without a status. Drop python_repr and the unread
public_failures fixtures
This commit is contained in:
Yujong Lee 2026-09-18 13:59:36 -07:00
parent e036e256ef
commit 836bf7d897
28 changed files with 807 additions and 2734 deletions

View file

@ -2092,7 +2092,6 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_with",
"strum",
"thiserror 2.0.19",
"url",
]

View file

@ -12,7 +12,6 @@ serde.workspace = true
serde_json.workspace = true
serde_path_to_error = "0.1"
serde_with.workspace = true
strum.workspace = true
thiserror.workspace = true
url.workspace = true

View file

@ -1,232 +1,115 @@
use super::Mapping;
use super::public::{PublicFailure, StatusClass};
use super::rules::{Kind, ResponseChoice, Rule, apply, contains_any};
use super::public::PublicError;
use super::rules::{Rule, contains_any};
const fn with_response(class: StatusClass) -> Kind {
Kind::Status {
class,
response: ResponseChoice::Provider,
}
}
fn original(mapping: &Mapping<'_>) -> String {
format!("CohereException - {}", mapping.original.message)
}
fn status_is(mapping: &Mapping<'_>, statuses: &[u16]) -> bool {
mapping
.original
.status
.is_some_and(|status| statuses.contains(&status))
}
/// `_map_cohere_exception`, in its branch order. A failure no rule claims falls through to
/// the status table.
const RULES: &[Rule] = &[
Rule {
when: |mapping| {
/// The text branches of `_map_cohere_exception`, in its order.
pub(super) const RULES: &[Rule] = &[
Rule::new(
|mapping| {
contains_any(
&mapping.error_str,
&["invalid api token", "No API key provided."],
)
},
kind: with_response(StatusClass::Authentication),
message: original,
debug: false,
},
Rule {
when: |mapping| mapping.error_str.contains("invalid type: parameter"),
kind: with_response(StatusClass::BadRequest),
message: original,
debug: false,
},
Rule {
when: |mapping| mapping.error_str.contains("too many tokens"),
kind: with_response(StatusClass::ContextWindowExceeded),
message: original,
debug: false,
},
Rule {
when: |mapping| {
PublicError::Authentication,
),
Rule::new(
|mapping| mapping.error_str.contains("invalid type: parameter"),
PublicError::BadRequest,
),
Rule::new(
|mapping| mapping.error_str.contains("too many tokens"),
PublicError::ContextWindowExceeded,
),
Rule::new(
|mapping| {
mapping
.error_str
.to_lowercase()
.contains("internal server error")
},
kind: with_response(StatusClass::InternalServer),
message: |mapping| format!("CohereException - {}", mapping.error_str),
debug: false,
},
Rule {
when: |mapping| status_is(mapping, &[400, 498]),
kind: with_response(StatusClass::BadRequest),
message: original,
debug: false,
},
Rule {
when: |mapping| status_is(mapping, &[408]),
kind: Kind::Timeout(None),
message: original,
debug: false,
},
Rule {
when: |mapping| status_is(mapping, &[500]),
kind: with_response(StatusClass::InternalServer),
message: original,
debug: false,
},
PublicError::InternalServer,
),
Rule::new(
|mapping| mapping.status.is_none() && mapping.error_str.contains("invalid type:"),
PublicError::BadRequest,
),
Rule::new(
|mapping| mapping.status.is_none() && mapping.error_str.contains("Unexpected server error"),
PublicError::InternalServer,
),
];
pub(super) fn map(mapping: &Mapping<'_>) -> Option<PublicFailure> {
apply(RULES, mapping).map(|failure| PublicFailure {
llm_provider: Some("cohere".to_string()),
..failure
})
}
#[cfg(test)]
mod tests {
use super::super::testing::{context, failure, http, status, upstream};
use super::super::{ExceptionFamily, OriginalException, PublicKind};
use super::super::rules::first_match;
use super::super::testing::mapping;
use super::*;
fn mapped(provider: &str, original: &OriginalException) -> Option<PublicFailure> {
let context = context(provider, ExceptionFamily::Cohere);
map(&Mapping::new(&context, original))
fn classified(text: &str) -> Option<PublicError> {
classified_with(Some(400), text)
}
fn cohere(class: StatusClass, status_code: u16, body: &str, message: &str) -> PublicFailure {
failure(
status(class, upstream(status_code, body)),
message,
"cohere",
)
fn classified_with(status: Option<u16>, text: &str) -> Option<PublicError> {
first_match(RULES, &mapping(status, text)).map(|rule| rule.error)
}
#[rstest::rstest]
#[case::invalid_token(
500,
"invalid api token",
cohere(
StatusClass::Authentication,
500,
"invalid api token",
"CohereException - invalid api token"
)
)]
#[case::no_api_key(
500,
"No API key provided.",
cohere(
StatusClass::Authentication,
500,
"No API key provided.",
"CohereException - No API key provided."
)
)]
#[case::invalid_parameter(
500,
"invalid type: parameter x",
cohere(
StatusClass::BadRequest,
500,
"invalid type: parameter x",
"CohereException - invalid type: parameter x"
)
)]
#[case::too_many_tokens(
500,
"too many tokens",
cohere(
StatusClass::ContextWindowExceeded,
500,
"too many tokens",
"CohereException - too many tokens"
)
)]
#[case::internal_server_text(
400,
"Internal Server Error",
cohere(
StatusClass::InternalServer,
400,
"Internal Server Error",
"CohereException - Internal Server Error"
)
)]
#[case::bad_request(
400,
"rejected",
cohere(StatusClass::BadRequest, 400, "rejected", "CohereException - rejected")
)]
#[case::invalid_token_status(
498,
"rejected",
cohere(StatusClass::BadRequest, 498, "rejected", "CohereException - rejected")
)]
#[case::request_timeout(408, "rejected", failure(PublicKind::Timeout { status: None }, "CohereException - rejected", "cohere"))]
#[case::internal_server(
500,
"rejected",
cohere(
StatusClass::InternalServer,
500,
"rejected",
"CohereException - rejected"
)
)]
fn each_rule_maps_and_reports_cohere(
#[case] status_code: u16,
#[case] body: &str,
#[case] expected: PublicFailure,
) {
assert_eq!(mapped("azure_ai", &http(status_code, body)), Some(expected));
}
#[rstest::rstest]
#[case::unmapped_status(409)]
#[case::unauthorized(401)]
fn statuses_without_a_rule_fall_through(#[case] status_code: u16) {
assert_eq!(mapped("cohere", &http(status_code, "rejected")), None);
}
#[test]
fn the_internal_server_rule_uses_the_redacted_text() {
let body = "internal server error Bearer abcdefghijklmnop";
assert_eq!(
mapped("cohere", &http(400, body)),
Some(cohere(
StatusClass::InternalServer,
400,
body,
"CohereException - internal server error REDACTED"
))
);
#[case::invalid_token("invalid api token", PublicError::Authentication)]
#[case::no_api_key("No API key provided.", PublicError::Authentication)]
#[case::invalid_parameter("invalid type: parameter x", PublicError::BadRequest)]
#[case::too_many_tokens("too many tokens", PublicError::ContextWindowExceeded)]
#[case::internal_server_text("Internal Server Error", PublicError::InternalServer)]
#[case::internal_server_any_case("INTERNAL server ERROR", PublicError::InternalServer)]
fn each_text_rule_claims_its_marker(#[case] text: &str, #[case] expected: PublicError) {
assert_eq!(classified(text), Some(expected));
}
#[rstest::rstest]
#[case::token_before_parameter(
"invalid api token invalid type: parameter",
StatusClass::Authentication
PublicError::Authentication
)]
#[case::parameter_before_tokens(
"invalid type: parameter too many tokens",
StatusClass::BadRequest
PublicError::BadRequest
)]
#[case::tokens_before_internal(
"too many tokens Internal Server Error",
StatusClass::ContextWindowExceeded
PublicError::ContextWindowExceeded
)]
#[case::internal_before_status("Internal Server Error", StatusClass::InternalServer)]
fn the_earlier_rule_wins_when_two_apply(#[case] body: &str, #[case] class: StatusClass) {
assert_eq!(
mapped("cohere", &http(400, body)),
Some(cohere(
class,
400,
body,
&format!("CohereException - {body}")
))
);
fn the_earlier_rule_wins_when_two_apply(#[case] text: &str, #[case] expected: PublicError) {
assert_eq!(classified(text), Some(expected));
}
#[rstest::rstest]
#[case::invalid_type(None, "invalid type: x", Some(PublicError::BadRequest))]
#[case::unexpected_server_error(
None,
"Unexpected server error",
Some(PublicError::InternalServer)
)]
#[case::invalid_type_before_unexpected(
None,
"invalid type: x Unexpected server error",
Some(PublicError::BadRequest)
)]
#[case::internal_before_invalid_type(
None,
"internal server error invalid type: x",
Some(PublicError::InternalServer)
)]
#[case::invalid_type_with_a_status(Some(500), "invalid type: x", None)]
#[case::unexpected_with_a_status(Some(400), "Unexpected server error", None)]
fn the_trailing_rules_only_claim_failures_without_a_status(
#[case] status: Option<u16>,
#[case] text: &str,
#[case] expected: Option<PublicError>,
) {
assert_eq!(classified_with(status, text), expected);
}
#[test]
fn text_without_a_marker_is_left_to_the_status_table() {
assert_eq!(classified("rejected"), None);
}
}

View file

@ -1,18 +1,47 @@
//! A port of Python's `exception_type` for the routes that run in Rust.
//! A port of Python's `exception_type` for the routes that run in Rust. Rust decides the
//! public class, the message and the debug text; Python only builds the class.
//!
//! DIVERGENCES: where the Python mapper is inconsistent, the port follows one rule instead.
//! - The message is always `{Provider}Exception - {redacted text}`. Python's per-branch
//! labels (`RateLimitError: `, `litellm.RateLimitError: `, `Vertex_aiException BadRequestError`)
//! are dropped because every public class already prefixes `litellm.{Class}: `.
//! - The upstream response is always the real one. Python swaps in made-up `httpx.Response`
//! stubs on some Vertex branches, losing the body and `retry-after`.
//! - The debug text is always attached; Python passes it on some branches only.
//! - No family rule turns a status into a class; the shared status table owns that. So a
//! Vertex 502 is a `BadGatewayError` and an OpenAI-family 403 is a `PermissionDeniedError`.
//! Three rules read the status only to gate a text match, as Python does: the standalone
//! `429`, Vertex's wrapped 429 behind a 5xx, and Cohere's rules for failures with no status.
//! - A timeout text marker on an HTTP failure keeps the upstream response. Python's `Timeout`
//! carries none.
//! - Every family matches and reports the redacted text. Python's OpenAI mapper builds the
//! message from the unredacted text.
//! - A refused connection is an `APIConnectionError`, not the 500 Python's HTTP handler
//! synthesizes.
//! - Dropped Python rules: Vertex's bare `403` substring (it matches `4031 tokens`), Vertex's
//! `IndexError` quota marker (a Python client crash), the OpenAI SDK's missing-`api_key`
//! text and its `OPENAI` renaming, Cohere's `llm_provider="cohere"` override, and Cohere's
//! `CohereConnectionError` check (a Python SDK class name).
//!
//! KNOWN_GAPS: differences from the Python mapper that no Rust route can reach today. Each
//! one stops being acceptable at its trigger.
//! - The Vertex partner-model API base for "claude" models is not built into
//! `extra_information`. Trigger: a Vertex route whose models include Anthropic partner
//! models; then `api_base` gets that branch and a table row.
//! - The Vertex partner-model API base for "claude" models is not built into the debug text.
//! Trigger: a Vertex route whose models include Anthropic partner models.
//! - The debug text's `API Base` line is only the non-streaming Vertex URL. Python prefers an
//! explicit or provider-resolved `api_base`, uses `:streamGenerateContent` when streaming,
//! and has Gemini and OpenAI defaults. Trigger: the first route wired to this mapper, since
//! every route knows its `api_base`.
//! - The debug text has no `Messages:` line, which Python adds when
//! `redact_messages_in_exceptions` is off. Trigger: a wired route that carries messages.
//! - Python reports the provider `get_llm_provider` resolves for a stripped model name when
//! that name happens to be in the model cost map. Trigger: a route whose model names
//! overlap the cost map; that needs the provider resolution port, not a classifier change.
//! - The generic `APIConnectionError` fallback appends `traceback.format_exc()` to the
//! message. Rust has no Python traceback and does not invent one; a sweep row that reaches
//! it compares the message before the traceback.
//! - `litellm_proxy` errors are not unwrapped into the proxied exception. Trigger: a Rust
//! route that calls a LiteLLM proxy.
//! - Only the OpenAI-compatible, Vertex AI and Cohere mappers are ported; every other
//! provider goes straight to the status table. Trigger: a Rust route for such a provider.
use super::secret_redaction::{redact_string, secret_redaction_enabled};
use super::secret_redaction::SecretRedactor;
mod cohere;
mod openai;
@ -22,10 +51,10 @@ mod rules;
mod status;
mod vertex_ai;
pub use original::{ExceptionFamily, LocalClass, OriginalException};
pub use public::{HttpStub, PublicFailure, PublicKind, ResponseArg, StatusClass, UpstreamResponse};
pub use original::{ExceptionFamily, OriginalException};
pub use public::{MappedFailure, PublicError, UpstreamResponse};
const DOCS_URL: &str = "https://docs.litellm.ai/docs";
use rules::{Rule, contains_any, first_match};
const TIMEOUT_MARKERS: &[&str] = &[
"Request Timeout Error",
@ -37,11 +66,8 @@ const TIMEOUT_MARKERS: &[&str] = &[
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ExceptionContext {
pub model: String,
pub custom_llm_provider: Option<String>,
pub family: ExceptionFamily,
pub custom_llm_provider: String,
pub asynchronous: bool,
pub suppress_debug_info: bool,
pub redact_messages_in_exceptions: bool,
pub vertex_project: Option<String>,
pub vertex_location: Option<String>,
pub model_group: Option<String>,
@ -50,73 +76,93 @@ pub struct ExceptionContext {
pub user_api_key_team_alias: Option<String>,
}
/// The attributes `exception_type` reads off the Python exception: a provider error
/// (`BaseLLMException`) carries a status, a response and a request, a plain exception
/// carries only its text.
struct Raised {
/// What the rules read: the status of a provider response, if any, and the redacted text.
struct Mapping {
status: Option<u16>,
status_is_synthesized: bool,
message: String,
response: Option<UpstreamResponse>,
error_str: String,
}
impl Raised {
fn provider(
status: u16,
message: String,
body: String,
headers: Vec<(String, String)>,
) -> Self {
Self {
status: Some(status),
status_is_synthesized: false,
message,
response: Some(UpstreamResponse {
status,
body,
headers,
pub fn exception_type(
context: &ExceptionContext,
redactor: Option<&SecretRedactor>,
original: &OriginalException,
) -> MappedFailure {
let (status, text, upstream) = match original {
OriginalException::Http {
status,
body,
headers,
} => (
Some(*status),
body.clone(),
Some(UpstreamResponse {
status: *status,
body: body.clone(),
headers: headers.clone(),
}),
),
OriginalException::Connection { message } | OriginalException::Plain { message } => {
(None, message.clone(), None)
}
}
fn plain(message: String) -> Self {
Self {
status: None,
status_is_synthesized: false,
message,
response: None,
}
}
fn new(original: &OriginalException, asynchronous: bool) -> Self {
match original {
OriginalException::Http {
status,
body,
headers,
} => Self::provider(*status, body.clone(), body.clone(), headers.clone()),
OriginalException::Connection { message } => Self {
status_is_synthesized: true,
..Self::provider(500, message.clone(), String::new(), Vec::new())
},
OriginalException::Timeout {
timeout_seconds,
elapsed_seconds,
} => Self::provider(
408,
timeout_message(asynchronous, *timeout_seconds, *elapsed_seconds),
String::new(),
Vec::new(),
),
OriginalException::Response { message }
| OriginalException::Local { message, .. }
| OriginalException::Public { message, .. } => Self::plain(message.clone()),
}
OriginalException::Timeout {
timeout_seconds,
elapsed_seconds,
} => (
None,
timeout_message(context.asynchronous, *timeout_seconds, *elapsed_seconds),
None,
),
};
let mapping = Mapping {
status,
error_str: match redactor {
Some(redactor) => redactor.redact(&text),
None => text,
},
};
let family = ExceptionFamily::for_provider(&context.custom_llm_provider);
let (error, hint) = classify(family, original, &mapping);
MappedFailure {
error,
message: format!(
"{} - {}{hint}",
exception_provider(&context.custom_llm_provider),
mapping.error_str
),
upstream,
debug_info: extra_information(context, api_base(context).as_deref()),
}
}
/// The text `litellm.Timeout` carries when the Python HTTP handler times out: the sync
/// and async handlers word it differently.
fn classify(
family: ExceptionFamily,
original: &OriginalException,
mapping: &Mapping,
) -> (PublicError, &'static str) {
const TIMEOUT: PublicError = PublicError::Timeout { status: 408 };
if matches!(original, OriginalException::Timeout { .. })
|| contains_any(&mapping.error_str, TIMEOUT_MARKERS)
{
return (TIMEOUT, "");
}
if let Some(rule) = first_match(family_rules(family), mapping) {
return (rule.error, rule.hint);
}
let by_status = mapping.status.and_then(status::classify);
(by_status.unwrap_or(PublicError::ApiConnection), "")
}
fn family_rules(family: ExceptionFamily) -> &'static [Rule] {
match family {
ExceptionFamily::OpenAiCompatible => openai::RULES,
ExceptionFamily::VertexAi => vertex_ai::RULES,
ExceptionFamily::Cohere => cohere::RULES,
ExceptionFamily::Other => &[],
}
}
/// The text the Python HTTP handler's timeout carries: the sync and async handlers word it
/// differently.
fn timeout_message(
asynchronous: bool,
timeout_seconds: Option<f64>,
@ -126,11 +172,9 @@ fn timeout_message(
if asynchronous {
let elapsed =
python_float(elapsed_seconds.map(|seconds| (seconds * 1000.0).round() / 1000.0));
format!(
"litellm.Timeout: Connection timed out. Timeout passed={timeout}, time taken={elapsed} seconds"
)
format!("Connection timed out. Timeout passed={timeout}, time taken={elapsed} seconds")
} else {
format!("litellm.Timeout: Connection timed out after {timeout} seconds.")
format!("Connection timed out after {timeout} seconds.")
}
}
@ -142,113 +186,10 @@ fn python_float(value: Option<f64>) -> String {
}
}
/// Everything the rules read: the original as Python sees it and the text `exception_type`
/// derives from the context before any provider mapper runs.
struct Mapping<'a> {
context: &'a ExceptionContext,
original: Raised,
provider: &'a str,
error_str: String,
exception_provider: String,
extra_information: String,
}
impl<'a> Mapping<'a> {
fn new(context: &'a ExceptionContext, original: &OriginalException) -> Self {
let original = Raised::new(original, context.asynchronous);
let error_str = if secret_redaction_enabled() {
redact_string(&original.message)
} else {
original.message.clone()
};
Self {
context,
original,
provider: context.custom_llm_provider.as_deref().unwrap_or_default(),
error_str,
exception_provider: match &context.custom_llm_provider {
None => "None".to_string(),
Some(provider) => exception_provider(provider),
},
extra_information: extra_information(context, api_base(context).as_deref()),
}
}
fn failure(&self, kind: PublicKind, message: String, debug: bool) -> PublicFailure {
PublicFailure {
kind,
message,
model: self.context.model.clone(),
llm_provider: self.context.custom_llm_provider.clone(),
litellm_debug_info: debug.then(|| self.extra_information.clone()),
litellm_response_headers: None,
print_banner: false,
}
}
}
pub fn exception_type(context: &ExceptionContext, original: &OriginalException) -> PublicFailure {
if let OriginalException::Public { class, message } = original {
return PublicFailure {
kind: PublicKind::Status {
status_class: *class,
response: None,
},
message: message.clone(),
model: context.model.clone(),
llm_provider: context.custom_llm_provider.clone(),
litellm_debug_info: None,
litellm_response_headers: None,
print_banner: false,
};
}
let mapping = Mapping::new(context, original);
let litellm_response_headers = mapping
.original
.response
.as_ref()
.map(|response| response.headers.clone())
.filter(|headers| !headers.is_empty());
PublicFailure {
litellm_response_headers,
print_banner: !context.suppress_debug_info,
..map(&mapping)
}
}
fn map(mapping: &Mapping<'_>) -> PublicFailure {
if rules::contains_any(&mapping.error_str, TIMEOUT_MARKERS) {
return mapping.failure(
PublicKind::Timeout { status: None },
format!(
"APITimeoutError - Request timed out. Error_str: {}",
mapping.error_str
),
true,
);
}
let provider_failure = match mapping.context.family {
ExceptionFamily::OpenAiCompatible => openai::map(mapping),
ExceptionFamily::VertexAi => vertex_ai::map(mapping),
ExceptionFamily::Cohere => cohere::map(mapping),
ExceptionFamily::Other => None,
};
provider_failure
.or_else(|| status::map(mapping))
.unwrap_or_else(|| unmapped(mapping))
}
/// The `APIConnectionError` Python raises when no mapper claimed the failure: with the
/// provider prefix for a provider error, with the bare text for a plain exception.
fn unmapped(mapping: &Mapping<'_>) -> PublicFailure {
let message = match mapping.original.status {
Some(_) => format!("{} - {}", mapping.exception_provider, mapping.error_str),
None => mapping.original.message.clone(),
};
mapping.failure(PublicKind::ApiConnection, message, false)
}
fn exception_provider(provider: &str) -> String {
if provider == "openai" {
return "OpenAIException".to_string();
}
let mut characters = provider.chars();
match characters.next() {
Some(first) => format!("{}{}Exception", first.to_uppercase(), characters.as_str()),
@ -256,18 +197,6 @@ fn exception_provider(provider: &str) -> String {
}
}
fn python_capitalize(value: &str) -> String {
let mut characters = value.chars();
match characters.next() {
Some(first) => format!(
"{}{}",
first.to_uppercase(),
characters.as_str().to_lowercase()
),
None => String::new(),
}
}
fn api_base(context: &ExceptionContext) -> Option<String> {
match (&context.vertex_location, &context.vertex_project) {
(Some(location), Some(project)) => Some(format!(
@ -311,132 +240,99 @@ fn extra_information(context: &ExceptionContext, api_base: Option<&str>) -> Stri
#[cfg(test)]
mod testing {
use super::*;
use super::Mapping;
pub(super) const DEBUG: &str = "\nModel: ocr-model";
pub(super) fn context(provider: &str, family: ExceptionFamily) -> ExceptionContext {
ExceptionContext {
model: "ocr-model".into(),
custom_llm_provider: Some(provider.into()),
family,
suppress_debug_info: true,
..ExceptionContext::default()
}
}
pub(super) fn http(status: u16, body: &str) -> OriginalException {
OriginalException::Http {
pub(super) fn mapping(status: Option<u16>, text: &str) -> Mapping {
Mapping {
status,
body: body.into(),
headers: vec![("retry-after".into(), "7".into())],
}
}
pub(super) fn upstream(status: u16, body: &str) -> Option<ResponseArg> {
Some(ResponseArg::Upstream(UpstreamResponse {
status,
body: body.into(),
headers: vec![("retry-after".into(), "7".into())],
}))
}
pub(super) fn status(class: StatusClass, response: Option<ResponseArg>) -> PublicKind {
PublicKind::Status {
status_class: class,
response,
}
}
/// The failure a rule builds before `exception_type` adds the response headers and the
/// banner flag.
pub(super) fn failure(kind: PublicKind, message: &str, provider: &str) -> PublicFailure {
PublicFailure {
kind,
message: message.into(),
model: "ocr-model".into(),
llm_provider: Some(provider.into()),
litellm_debug_info: None,
litellm_response_headers: None,
print_banner: false,
}
}
pub(super) fn with_debug(failure: PublicFailure) -> PublicFailure {
PublicFailure {
litellm_debug_info: Some(DEBUG.into()),
..failure
error_str: text.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::testing::{DEBUG, context, failure, http, status, upstream, with_debug};
use super::*;
fn openai() -> ExceptionContext {
context("mistral", ExceptionFamily::OpenAiCompatible)
const DEBUG: &str = "\nModel: ocr-model";
fn context(provider: &str) -> ExceptionContext {
ExceptionContext {
model: "ocr-model".into(),
custom_llm_provider: provider.into(),
..ExceptionContext::default()
}
}
fn redactor() -> SecretRedactor {
SecretRedactor::new(16)
}
fn headers() -> Vec<(String, String)> {
vec![("retry-after".into(), "7".into())]
}
fn http(status: u16, body: &str) -> OriginalException {
OriginalException::Http {
status,
body: body.into(),
headers: headers(),
}
}
fn upstream(status: u16, body: &str) -> Option<UpstreamResponse> {
Some(UpstreamResponse {
status,
body: body.into(),
headers: headers(),
})
}
fn mapped(provider: &str, original: &OriginalException) -> MappedFailure {
exception_type(&context(provider), Some(&redactor()), original)
}
#[rstest::rstest]
#[case::openai_family("mistral", "rate limit reached", PublicError::RateLimit)]
#[case::vertex_family("vertex_ai", "Resource exhausted", PublicError::RateLimit)]
#[case::cohere_family("cohere", "too many tokens", PublicError::ContextWindowExceeded)]
fn a_family_text_rule_beats_the_status_and_keeps_the_real_response(
#[case] provider: &str,
#[case] body: &str,
#[case] expected: PublicError,
) {
let failure = mapped(provider, &http(401, body));
assert_eq!(failure.error, expected);
assert_eq!(failure.upstream, upstream(401, body));
}
#[test]
fn a_public_original_passes_through_without_banner_debug_or_prefix() {
let original = OriginalException::Public {
class: StatusClass::UnsupportedParams,
message: "Invalid `req_format`".into(),
};
let context = ExceptionContext {
suppress_debug_info: false,
..openai()
};
fn the_other_family_has_no_text_rules() {
assert_eq!(
exception_type(&context, &original),
failure(
status(StatusClass::UnsupportedParams, None),
"Invalid `req_format`",
"mistral"
)
mapped("reducto", &http(401, "rate limit reached")).error,
PublicError::Authentication
);
}
#[rstest::rstest]
#[case::vertex_family_status_rule(ExceptionFamily::VertexAi, "vertex_ai", PublicFailure {
litellm_response_headers: Some(vec![("retry-after".into(), "7".into())]),
..with_debug(failure(status(StatusClass::BadRequest, upstream(409, "rejected")), "Vertex_aiException - rejected", "vertex_ai"))
})]
#[case::cohere_family(ExceptionFamily::Cohere, "cohere", PublicFailure {
litellm_response_headers: Some(vec![("retry-after".into(), "7".into())]),
..with_debug(failure(status(StatusClass::BadRequest, upstream(409, "rejected")), "CohereException - rejected", "cohere"))
})]
#[case::other_family(ExceptionFamily::Other, "reducto", PublicFailure {
litellm_response_headers: Some(vec![("retry-after".into(), "7".into())]),
..with_debug(failure(status(StatusClass::BadRequest, upstream(409, "rejected")), "ReductoException - rejected", "reducto"))
})]
fn families_without_a_409_rule_reach_the_status_table(
#[case] family: ExceptionFamily,
#[case::openai_403_is_permission_denied("mistral", 403, PublicError::PermissionDenied)]
#[case::openai_409_is_bad_request("mistral", 409, PublicError::BadRequest)]
#[case::vertex_502_is_bad_gateway("vertex_ai", 502, PublicError::BadGateway)]
#[case::vertex_504_is_a_timeout("vertex_ai", 504, PublicError::Timeout { status: 504 })]
#[case::cohere_498_is_bad_request("cohere", 498, PublicError::BadRequest)]
#[case::other_503("reducto", 503, PublicError::ServiceUnavailable)]
fn without_a_text_rule_every_family_uses_the_status_table(
#[case] provider: &str,
#[case] expected: PublicFailure,
#[case] status: u16,
#[case] expected: PublicError,
) {
assert_eq!(
exception_type(&context(provider, family), &http(409, "rejected")),
expected
);
}
#[test]
fn the_openai_family_claims_a_409_before_the_status_table() {
assert_eq!(
exception_type(&openai(), &http(409, "rejected")),
PublicFailure {
litellm_response_headers: Some(vec![("retry-after".into(), "7".into())]),
..with_debug(failure(
PublicKind::Api {
status: 409,
request_url: DOCS_URL
},
"APIError: MistralException - rejected",
"mistral"
))
mapped(provider, &http(status, "rejected")),
MappedFailure {
error: expected,
message: format!("{} - rejected", exception_provider(provider)),
upstream: upstream(status, "rejected"),
debug_info: DEBUG.into(),
}
);
}
@ -447,148 +343,126 @@ mod tests {
#[case::timed_out_generating("Timed out generating response")]
#[case::read_operation("The read operation timed out")]
fn timeout_markers_win_over_every_family(#[case] marker: &str) {
let body = format!("rate limit {marker}");
for family in [
ExceptionFamily::OpenAiCompatible,
ExceptionFamily::VertexAi,
ExceptionFamily::Cohere,
ExceptionFamily::Other,
] {
let body = format!("rate limit invalid api token {marker}");
for provider in ["mistral", "vertex_ai", "cohere", "reducto"] {
assert_eq!(
exception_type(&context("mistral", family), &http(429, &body)),
PublicFailure {
litellm_response_headers: Some(vec![("retry-after".into(), "7".into())]),
..with_debug(failure(
PublicKind::Timeout { status: None },
&format!("APITimeoutError - Request timed out. Error_str: {body}"),
"mistral"
))
}
mapped(provider, &http(429, &body)).error,
PublicError::Timeout { status: 408 },
"{provider}"
);
}
}
#[test]
fn a_handler_timeout_is_a_408_without_a_response() {
let original = OriginalException::Timeout {
timeout_seconds: Some(0.5),
elapsed_seconds: Some(0.5031),
};
assert_eq!(
mapped("mistral", &original),
MappedFailure {
error: PublicError::Timeout { status: 408 },
message: "MistralException - Connection timed out after 0.5 seconds.".into(),
upstream: None,
debug_info: DEBUG.into(),
}
);
}
#[rstest::rstest]
#[case::provider_error_keeps_the_prefix(http(409, "rejected"), "ReductoException - rejected")]
#[case::synthesized_status_skips_the_status_table(
OriginalException::Connection { message: "refused".into() },
"ReductoException - refused"
)]
#[case::plain_exception_keeps_its_text(
OriginalException::Local { class: LocalClass::FileNotFound, message: "File not found: /a".into() },
"File not found: /a"
)]
fn unmapped_failures_are_connection_errors(
#[case::refused_connection(OriginalException::Connection { message: "refused".into() })]
#[case::unparseable_response(OriginalException::Plain { message: "refused".into() })]
#[case::informational_status(OriginalException::Http { status: 399, body: "refused".into(), headers: Vec::new() })]
fn a_failure_no_rule_or_status_claims_is_a_connection_error(
#[case] original: OriginalException,
#[case] message: &str,
) {
let context = context("reducto", ExceptionFamily::Other);
let expected = match original {
OriginalException::Http { .. } => with_debug(failure(
status(StatusClass::BadRequest, upstream(409, "rejected")),
message,
"reducto",
)),
OriginalException::Connection { .. } | OriginalException::Local { .. } => {
failure(PublicKind::ApiConnection, message, "reducto")
}
_ => unreachable!(),
};
let actual = exception_type(&context, &original);
assert_eq!(
PublicFailure {
litellm_response_headers: None,
..actual
},
expected
);
let failure = mapped("reducto", &original);
assert_eq!(failure.error, PublicError::ApiConnection);
assert_eq!(failure.message, "ReductoException - refused");
}
#[test]
fn a_missing_provider_renders_like_python_none() {
let context = ExceptionContext {
custom_llm_provider: None,
family: ExceptionFamily::Other,
..openai()
fn a_timeout_marker_on_a_response_keeps_the_response() {
let failure = mapped("reducto", &http(429, "Request timed out"));
assert_eq!(failure.error, PublicError::Timeout { status: 408 });
assert_eq!(failure.upstream, upstream(429, "Request timed out"));
}
#[test]
fn family_text_rules_also_classify_failures_without_a_response() {
let original = OriginalException::Plain {
message: "Request too large".into(),
};
assert_eq!(
exception_type(&context, &http(401, "rejected")),
PublicFailure {
llm_provider: None,
litellm_response_headers: Some(vec![("retry-after".into(), "7".into())]),
..with_debug(failure(
status(StatusClass::Authentication, upstream(401, "rejected")),
"None - rejected",
"unused"
))
}
);
assert_eq!(mapped("mistral", &original).error, PublicError::RateLimit);
}
#[rstest::rstest]
#[case::suppressed(true, false)]
#[case::printed(false, true)]
fn the_banner_prints_unless_debug_info_is_suppressed(
#[case] suppress_debug_info: bool,
#[case] print_banner: bool,
) {
let context = ExceptionContext {
suppress_debug_info,
..openai()
};
#[case::openai_family("mistral", "MistralException - rejected REDACTED")]
#[case::vertex_family("vertex_ai", "Vertex_aiException - rejected REDACTED")]
#[case::other_family("reducto", "ReductoException - rejected REDACTED")]
fn every_family_reports_the_redacted_text(#[case] provider: &str, #[case] message: &str) {
let failure = mapped(provider, &http(400, "rejected Bearer abcdefghijklmnop"));
assert_eq!(failure.message, message);
}
#[test]
fn redaction_runs_before_the_rules_see_the_text() {
let body = "db_password=rate_limit";
assert_eq!(
exception_type(&context, &http(400, "rejected")).print_banner,
print_banner
mapped("mistral", &http(400, body)).error,
PublicError::BadRequest
);
assert_eq!(
exception_type(&context("mistral"), None, &http(400, body)).error,
PublicError::RateLimit
);
}
#[test]
fn empty_upstream_headers_are_not_reported() {
let original = OriginalException::Http {
status: 400,
body: "rejected".into(),
headers: Vec::new(),
};
assert_eq!(
exception_type(&openai(), &original).litellm_response_headers,
None
);
}
#[test]
fn messages_are_redacted_before_markers_and_prefixes() {
fn without_a_redactor_the_text_is_kept() {
let body = "rejected Bearer abcdefghijklmnop";
assert_eq!(
exception_type(
&context("reducto", ExceptionFamily::Other),
&http(400, body)
)
.message,
"ReductoException - rejected REDACTED"
exception_type(&context("reducto"), None, &http(400, body)).message,
format!("ReductoException - {body}")
);
}
const SYNC_TIMEOUT: &str = "litellm.Timeout: Connection timed out after 0.5 seconds.";
#[test]
fn a_rule_hint_follows_the_message() {
let failure = mapped("mistral", &http(400, "invalid_encrypted_content"));
assert_eq!(failure.error, PublicError::BadRequest);
assert!(
failure
.message
.starts_with("MistralException - invalid_encrypted_content\n\n This error occurs")
);
}
#[rstest::rstest]
#[case::sync(false, Some(0.5), Some(0.5031), SYNC_TIMEOUT)]
#[case::sync(
false,
Some(0.5),
Some(0.5031),
"Connection timed out after 0.5 seconds."
)]
#[case::async_rounds_the_elapsed_time(
true,
Some(0.5),
Some(0.5031),
"litellm.Timeout: Connection timed out. Timeout passed=0.5, time taken=0.503 seconds"
"Connection timed out. Timeout passed=0.5, time taken=0.503 seconds"
)]
#[case::whole_seconds_keep_a_decimal(
true,
Some(600.0),
Some(2.0),
"litellm.Timeout: Connection timed out. Timeout passed=600.0, time taken=2.0 seconds"
"Connection timed out. Timeout passed=600.0, time taken=2.0 seconds"
)]
#[case::unknown_values_render_as_none(
true,
None,
None,
"litellm.Timeout: Connection timed out. Timeout passed=None, time taken=None seconds"
"Connection timed out. Timeout passed=None, time taken=None seconds"
)]
fn timeout_text_follows_the_delivery_mode(
#[case] asynchronous: bool,
@ -602,58 +476,6 @@ mod tests {
);
}
#[rstest::rstest]
#[case::sync(false, SYNC_TIMEOUT)]
#[case::async_(
true,
"litellm.Timeout: Connection timed out. Timeout passed=0.5, time taken=0.503 seconds"
)]
fn a_timeout_is_a_408_carrying_the_handler_text(
#[case] asynchronous: bool,
#[case] text: &str,
) {
let context = ExceptionContext {
asynchronous,
..openai()
};
let original = OriginalException::Timeout {
timeout_seconds: Some(0.5),
elapsed_seconds: Some(0.5031),
};
assert_eq!(
exception_type(&context, &original),
with_debug(failure(
PublicKind::Timeout { status: None },
&format!("Timeout Error: MistralException - {text}"),
"mistral"
))
);
}
#[test]
fn a_refused_connection_is_a_500_with_an_empty_response() {
assert_eq!(
exception_type(
&openai(),
&OriginalException::Connection {
message: "refused".into()
}
),
with_debug(failure(
status(
StatusClass::InternalServer,
Some(ResponseArg::Upstream(UpstreamResponse {
status: 500,
body: String::new(),
headers: Vec::new(),
}))
),
"InternalServerError: MistralException - refused",
"mistral"
))
);
}
#[test]
fn debug_information_follows_the_python_layout() {
let context = ExceptionContext {
@ -662,10 +484,10 @@ mod tests {
model_group: Some("ocr".into()),
deployment: Some("deployment".into()),
user_api_key_alias: Some("key".into()),
..openai()
..context("vertex_ai")
};
assert_eq!(
extra_information(&context, api_base(&context).as_deref()),
exception_type(&context, None, &http(400, "rejected")).debug_info,
concat!(
"\n\nKey Name: `key`\nTeam: `None`",
"\nModel: ocr-model",
@ -680,21 +502,20 @@ mod tests {
#[rstest::rstest]
#[case::bare(ExceptionContext::default(), "\nModel: ")]
#[case::redacted_messages(ExceptionContext { redact_messages_in_exceptions: true, model: "m".into(), ..ExceptionContext::default() }, "\nModel: m")]
#[case::team_alias(
ExceptionContext { model: "m".into(), user_api_key_alias: Some("key".into()), user_api_key_team_alias: Some("team".into()), redact_messages_in_exceptions: true, ..ExceptionContext::default() },
ExceptionContext { model: "m".into(), user_api_key_alias: Some("key".into()), user_api_key_team_alias: Some("team".into()), ..ExceptionContext::default() },
"\n\nKey Name: `key`\nTeam: `team`\nModel: m"
)]
#[case::team_alias_without_key_is_ignored(
ExceptionContext { model: "m".into(), user_api_key_team_alias: Some("team".into()), redact_messages_in_exceptions: true, ..ExceptionContext::default() },
ExceptionContext { model: "m".into(), user_api_key_team_alias: Some("team".into()), ..ExceptionContext::default() },
"\nModel: m"
)]
#[case::project_without_location_has_no_api_base(
ExceptionContext { model: "m".into(), vertex_project: Some("p".into()), redact_messages_in_exceptions: true, ..ExceptionContext::default() },
ExceptionContext { model: "m".into(), vertex_project: Some("p".into()), ..ExceptionContext::default() },
"\nModel: m\nvertex_project: `p`\n"
)]
#[case::location_without_project_has_no_api_base(
ExceptionContext { model: "m".into(), vertex_location: Some("l".into()), redact_messages_in_exceptions: true, ..ExceptionContext::default() },
ExceptionContext { model: "m".into(), vertex_location: Some("l".into()), ..ExceptionContext::default() },
"\nModel: m\nvertex_location: `l`\n"
)]
fn each_optional_context_field_adds_its_own_line(
@ -708,6 +529,7 @@ mod tests {
}
#[rstest::rstest]
#[case::openai_keeps_its_brand("openai", "OpenAIException")]
#[case::lowercase("mistral", "MistralException")]
#[case::keeps_the_rest("azure_ai", "Azure_aiException")]
#[case::empty("", "")]
@ -717,17 +539,4 @@ mod tests {
) {
assert_eq!(exception_provider(provider), expected);
}
#[rstest::rstest]
#[case::lowers_the_rest("vERTEX_AI", "Vertex_ai")]
#[case::empty("", "")]
fn python_capitalize_lowers_the_rest(#[case] value: &str, #[case] expected: &str) {
assert_eq!(python_capitalize(value), expected);
}
#[test]
fn debug_constant_matches_the_default_test_context() {
let context = openai();
assert_eq!(extra_information(&context, None), DEBUG);
}
}

View file

@ -1,85 +1,31 @@
use super::public::{PublicFailure, StatusClass};
use super::rules::{
ApiStatus, Kind, ResponseChoice, Rule, apply, contains_any, is_context_window_exceeded,
is_rate_limit,
};
use super::{DOCS_URL, Mapping};
const OPENAI_URL: &str = "https://api.openai.com/v1";
use super::public::PublicError;
use super::rules::{Rule, contains_any, is_context_window_exceeded, is_rate_limit};
const ENCRYPTED_CONTENT_HELP: &str = "\n\n This error occurs when load balancing Responses API across deployments with different API keys.\n Encrypted content is tied to the organization that created it and cannot be decrypted by other organizations.\n\n Solution: Enable 'encrypted_content_affinity' to route follow-up requests to the correct deployment:\n\n router_settings:\n enable_pre_call_checks: true\n optional_pre_call_checks:\n - encrypted_content_affinity\n\n Learn more: https://docs.litellm.ai/docs/response_api#encrypted-content-affinity-multi-region-load-balancing";
const fn with_response(class: StatusClass) -> Kind {
Kind::Status {
class,
response: ResponseChoice::Provider,
}
}
fn exception_provider(mapping: &Mapping<'_>) -> String {
if mapping.provider == "openai" {
"OpenAIException".to_string()
} else {
super::exception_provider(mapping.provider)
}
}
/// The raw message with OpenAI's own names swapped for the provider's.
fn message(mapping: &Mapping<'_>) -> String {
let provider = mapping.provider;
mapping
.original
.message
.replace("OPENAI", &provider.to_uppercase())
.replace("openai.OpenAIError", &format!("{provider}.{provider}Error"))
}
fn prefixed(mapping: &Mapping<'_>, label: &str) -> String {
format!(
"{label}{} - {}",
exception_provider(mapping),
message(mapping)
)
}
fn status_is(mapping: &Mapping<'_>, statuses: &[u16]) -> bool {
mapping
.original
.status
.is_some_and(|status| statuses.contains(&status))
}
/// `_map_openai_exception`, in its branch order.
const RULES: &[Rule] = &[
Rule {
when: |mapping| is_rate_limit(&mapping.error_str, mapping.original.status),
kind: with_response(StatusClass::RateLimit),
message: |mapping| prefixed(mapping, "RateLimitError: "),
debug: false,
},
Rule {
when: |mapping| is_context_window_exceeded(&mapping.error_str),
kind: with_response(StatusClass::ContextWindowExceeded),
message: |mapping| prefixed(mapping, "ContextWindowExceededError: "),
debug: true,
},
Rule {
when: |mapping| {
/// The text branches of `_map_openai_exception`, in its order.
pub(super) const RULES: &[Rule] = &[
Rule::new(
|mapping| is_rate_limit(&mapping.error_str, mapping.status),
PublicError::RateLimit,
),
Rule::new(
|mapping| is_context_window_exceeded(&mapping.error_str),
PublicError::ContextWindowExceeded,
),
Rule::new(
|mapping| {
mapping.error_str.contains("invalid_request_error")
&& mapping.error_str.contains("model_not_found")
},
kind: with_response(StatusClass::NotFound),
message: |mapping| prefixed(mapping, ""),
debug: true,
},
Rule {
when: |mapping| mapping.error_str.contains("A timeout occurred"),
kind: Kind::Timeout(None),
message: |mapping| prefixed(mapping, ""),
debug: true,
},
Rule {
when: |mapping| {
PublicError::NotFound,
),
Rule::new(
|mapping| mapping.error_str.contains("A timeout occurred"),
PublicError::Timeout { status: 408 },
),
Rule::new(
|mapping| {
let error_str = &mapping.error_str;
(error_str.contains("invalid_request_error")
&& error_str.contains("content_policy_violation"))
@ -89,38 +35,29 @@ const RULES: &[Rule] = &[
.to_lowercase()
.contains("request was rejected as a result of the safety system")
},
kind: with_response(StatusClass::ContentPolicyViolation),
message: |mapping| prefixed(mapping, "ContentPolicyViolationError: "),
debug: true,
},
PublicError::ContentPolicyViolation,
),
Rule {
when: |mapping| {
contains_any(
&mapping.error_str,
&["invalid_encrypted_content", "could not be verified"],
)
},
kind: with_response(StatusClass::BadRequest),
message: |mapping| {
format!(
"{} - {}{ENCRYPTED_CONTENT_HELP}",
exception_provider(mapping),
message(mapping)
)
},
debug: true,
hint: ENCRYPTED_CONTENT_HELP,
..Rule::new(
|mapping| {
contains_any(
&mapping.error_str,
&["invalid_encrypted_content", "could not be verified"],
)
},
PublicError::BadRequest,
)
},
Rule {
when: |mapping| {
Rule::new(
|mapping| {
mapping.error_str.contains("invalid_request_error")
&& !mapping.error_str.contains("Incorrect API key provided")
},
kind: with_response(StatusClass::BadRequest),
message: |mapping| prefixed(mapping, ""),
debug: true,
},
Rule {
when: |mapping| {
PublicError::BadRequest,
),
Rule::new(
|mapping| {
contains_any(
&mapping.error_str,
&[
@ -129,458 +66,127 @@ const RULES: &[Rule] = &[
],
)
},
kind: Kind::Status {
class: StatusClass::InternalServer,
response: ResponseChoice::Omitted,
},
message: |mapping| prefixed(mapping, ""),
debug: false,
},
Rule {
when: |mapping| mapping.error_str.contains("Request too large"),
kind: with_response(StatusClass::RateLimit),
message: |mapping| prefixed(mapping, "RateLimitError: "),
debug: true,
},
Rule {
when: |mapping| {
mapping.error_str.contains("The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable")
},
kind: with_response(StatusClass::Authentication),
message: |mapping| prefixed(mapping, "AuthenticationError: "),
debug: true,
},
Rule {
when: |mapping| {
PublicError::InternalServer,
),
Rule::new(
|mapping| mapping.error_str.contains("Request too large"),
PublicError::RateLimit,
),
Rule::new(
|mapping| {
mapping
.error_str
.contains("Mistral API raised a streaming error")
},
kind: Kind::Api {
status: ApiStatus::Fixed(500),
request_url: OPENAI_URL,
},
message: |mapping| prefixed(mapping, ""),
debug: true,
},
Rule {
when: |mapping| mapping.original.status.is_none(),
kind: Kind::ApiConnection,
message: |mapping| prefixed(mapping, "APIConnectionError: "),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, &[400, 422]),
kind: with_response(StatusClass::BadRequest),
message: |mapping| prefixed(mapping, ""),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, &[401]),
kind: with_response(StatusClass::Authentication),
message: |mapping| prefixed(mapping, "AuthenticationError: "),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, &[404]),
kind: with_response(StatusClass::NotFound),
message: |mapping| prefixed(mapping, "NotFoundError: "),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, &[408]),
kind: Kind::Timeout(None),
message: |mapping| prefixed(mapping, "Timeout Error: "),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, &[429]),
kind: with_response(StatusClass::RateLimit),
message: |mapping| prefixed(mapping, "RateLimitError: "),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, &[500]),
kind: with_response(StatusClass::InternalServer),
message: |mapping| prefixed(mapping, "InternalServerError: "),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, &[502]),
kind: with_response(StatusClass::BadGateway),
message: |mapping| prefixed(mapping, "BadGatewayError: "),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, &[503]),
kind: with_response(StatusClass::ServiceUnavailable),
message: |mapping| prefixed(mapping, "ServiceUnavailableError: "),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, &[504]),
kind: Kind::Timeout(Some(504)),
message: |mapping| prefixed(mapping, "Timeout Error: "),
debug: true,
},
Rule {
when: |_| true,
kind: Kind::Api {
status: ApiStatus::Original,
request_url: DOCS_URL,
},
message: |mapping| prefixed(mapping, "APIError: "),
debug: true,
},
PublicError::Api { status: 500 },
),
];
pub(super) fn map(mapping: &Mapping<'_>) -> Option<PublicFailure> {
apply(RULES, mapping)
}
#[cfg(test)]
mod tests {
use super::super::testing::{context, failure, http, status, upstream, with_debug};
use super::super::{ExceptionFamily, OriginalException, PublicKind};
use super::super::rules::first_match;
use super::super::testing::mapping;
use super::*;
fn mapped(provider: &str, original: &OriginalException) -> PublicFailure {
let context = context(provider, ExceptionFamily::OpenAiCompatible);
map(&Mapping::new(&context, original)).expect("the OpenAI table ends in a catch-all")
}
fn kind(class: StatusClass, status_code: u16, body: &str) -> PublicKind {
status(class, upstream(status_code, body))
fn classified(status: Option<u16>, text: &str) -> Option<PublicError> {
first_match(RULES, &mapping(status, text)).map(|rule| rule.error)
}
#[rstest::rstest]
#[case::rate_limit_phrase(
400,
"rate limit reached",
failure(
kind(StatusClass::RateLimit, 400, "rate limit reached"),
"RateLimitError: MistralException - rate limit reached",
"mistral",
)
)]
#[case::rate_limit_phrase("rate limit reached", PublicError::RateLimit)]
#[case::context_window(
500,
"This model's maximum context length is 10",
with_debug(failure(
kind(
StatusClass::ContextWindowExceeded,
500,
"This model's maximum context length is 10"
),
"ContextWindowExceededError: MistralException - This model's maximum context length is 10",
"mistral",
))
PublicError::ContextWindowExceeded
)]
#[case::model_not_found(
400,
"invalid_request_error model_not_found",
with_debug(failure(
kind(StatusClass::NotFound, 400, "invalid_request_error model_not_found"),
"MistralException - invalid_request_error model_not_found",
"mistral",
))
)]
#[case::timeout_occurred(400, "A timeout occurred", with_debug(failure(
PublicKind::Timeout { status: None },
"MistralException - A timeout occurred",
"mistral",
)))]
#[case::model_not_found("invalid_request_error model_not_found", PublicError::NotFound)]
#[case::timeout_occurred("A timeout occurred", PublicError::Timeout { status: 408 })]
#[case::content_policy_error_code(
400,
"invalid_request_error content_policy_violation",
with_debug(failure(
kind(
StatusClass::ContentPolicyViolation,
400,
"invalid_request_error content_policy_violation"
),
"ContentPolicyViolationError: MistralException - invalid_request_error content_policy_violation",
"mistral",
))
PublicError::ContentPolicyViolation
)]
#[case::content_policy_usage_policy(
400,
"Invalid prompt violating our usage policy",
with_debug(failure(
kind(
StatusClass::ContentPolicyViolation,
400,
"Invalid prompt violating our usage policy"
),
"ContentPolicyViolationError: MistralException - Invalid prompt violating our usage policy",
"mistral",
))
PublicError::ContentPolicyViolation
)]
#[case::content_policy_safety_system(
400,
"Request was rejected as a result of the safety system",
with_debug(failure(
kind(
StatusClass::ContentPolicyViolation,
400,
"Request was rejected as a result of the safety system"
),
"ContentPolicyViolationError: MistralException - Request was rejected as a result of the safety system",
"mistral",
))
)]
#[case::encrypted_content(400, "invalid_encrypted_content", with_debug(failure(
kind(StatusClass::BadRequest, 400, "invalid_encrypted_content"),
&format!("MistralException - invalid_encrypted_content{ENCRYPTED_CONTENT_HELP}"),
"mistral",
)))]
#[case::unverifiable_content(400, "could not be verified", with_debug(failure(
kind(StatusClass::BadRequest, 400, "could not be verified"),
&format!("MistralException - could not be verified{ENCRYPTED_CONTENT_HELP}"),
"mistral",
)))]
#[case::invalid_request(
429,
"invalid_request_error bad field",
with_debug(failure(
kind(StatusClass::BadRequest, 429, "invalid_request_error bad field"),
"MistralException - invalid_request_error bad field",
"mistral",
))
PublicError::ContentPolicyViolation
)]
#[case::encrypted_content("invalid_encrypted_content", PublicError::BadRequest)]
#[case::unverifiable_content("could not be verified", PublicError::BadRequest)]
#[case::invalid_request("invalid_request_error bad field", PublicError::BadRequest)]
#[case::unknown_server_error(
400,
"Web server is returning an unknown error",
failure(
status(StatusClass::InternalServer, None),
"MistralException - Web server is returning an unknown error",
"mistral",
)
PublicError::InternalServer
)]
#[case::server_had_an_error(
400,
"The server had an error processing your request.",
failure(
status(StatusClass::InternalServer, None),
"MistralException - The server had an error processing your request.",
"mistral",
)
PublicError::InternalServer
)]
#[case::request_too_large(
400,
"Request too large",
with_debug(failure(
kind(StatusClass::RateLimit, 400, "Request too large"),
"RateLimitError: MistralException - Request too large",
"mistral",
))
#[case::request_too_large("Request too large", PublicError::RateLimit)]
#[case::mistral_streaming_error(
"Mistral API raised a streaming error",
PublicError::Api { status: 500 }
)]
#[case::missing_client_api_key(
400,
"The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable",
with_debug(failure(
kind(
StatusClass::Authentication,
400,
"The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable",
),
"AuthenticationError: MistralException - The api_key client option must be set either by passing api_key to the client or by setting the MISTRAL_API_KEY environment variable",
"mistral",
))
)]
#[case::mistral_streaming_error(400, "Mistral API raised a streaming error", with_debug(failure(
PublicKind::Api { status: 500, request_url: OPENAI_URL },
"MistralException - Mistral API raised a streaming error",
"mistral",
)))]
fn each_text_rule_maps_by_the_body(
#[case] status_code: u16,
#[case] body: &str,
#[case] expected: PublicFailure,
) {
assert_eq!(mapped("mistral", &http(status_code, body)), expected);
}
#[rstest::rstest]
#[case::bad_request(
400,
kind(StatusClass::BadRequest, 400, "rejected"),
"MistralException - rejected"
)]
#[case::unprocessable(
422,
kind(StatusClass::BadRequest, 422, "rejected"),
"MistralException - rejected"
)]
#[case::authentication(
401,
kind(StatusClass::Authentication, 401, "rejected"),
"AuthenticationError: MistralException - rejected"
)]
#[case::not_found(
404,
kind(StatusClass::NotFound, 404, "rejected"),
"NotFoundError: MistralException - rejected"
)]
#[case::request_timeout(408, PublicKind::Timeout { status: None }, "Timeout Error: MistralException - rejected")]
#[case::rate_limited(
429,
kind(StatusClass::RateLimit, 429, "rejected"),
"RateLimitError: MistralException - rejected"
)]
#[case::internal_server(
500,
kind(StatusClass::InternalServer, 500, "rejected"),
"InternalServerError: MistralException - rejected"
)]
#[case::bad_gateway(
502,
kind(StatusClass::BadGateway, 502, "rejected"),
"BadGatewayError: MistralException - rejected"
)]
#[case::service_unavailable(
503,
kind(StatusClass::ServiceUnavailable, 503, "rejected"),
"ServiceUnavailableError: MistralException - rejected"
)]
#[case::gateway_timeout(504, PublicKind::Timeout { status: Some(504) }, "Timeout Error: MistralException - rejected")]
#[case::any_other_status(409, PublicKind::Api { status: 409, request_url: DOCS_URL }, "APIError: MistralException - rejected")]
fn each_status_rule_maps_by_the_status(
#[case] status_code: u16,
#[case] kind: PublicKind,
#[case] message: &str,
) {
assert_eq!(
mapped("mistral", &http(status_code, "rejected")),
with_debug(failure(kind, message, "mistral"))
);
}
#[test]
fn a_failure_without_a_status_is_a_connection_error() {
let original = OriginalException::Response {
message: "invalid OCR response field: pages".into(),
};
assert_eq!(
mapped("mistral", &original),
with_debug(failure(
PublicKind::ApiConnection,
"APIConnectionError: MistralException - invalid OCR response field: pages",
"mistral"
))
);
fn each_text_rule_claims_its_marker(#[case] text: &str, #[case] expected: PublicError) {
assert_eq!(classified(Some(400), text), Some(expected));
}
#[rstest::rstest]
#[case::rate_limit_before_context_window(
400,
"rate limit and This model's maximum context length is 10",
kind(
StatusClass::RateLimit,
400,
"rate limit and This model's maximum context length is 10"
),
"RateLimitError: MistralException - rate limit and This model's maximum context length is 10",
false
PublicError::RateLimit
)]
#[case::context_window_before_content_policy(
400,
"This model's maximum context length is 10 invalid_request_error content_policy_violation",
kind(
StatusClass::ContextWindowExceeded,
400,
"This model's maximum context length is 10 invalid_request_error content_policy_violation"
),
"ContextWindowExceededError: MistralException - This model's maximum context length is 10 invalid_request_error content_policy_violation",
true
PublicError::ContextWindowExceeded
)]
#[case::model_not_found_before_invalid_request(
400,
"invalid_request_error model_not_found",
kind(StatusClass::NotFound, 400, "invalid_request_error model_not_found"),
"MistralException - invalid_request_error model_not_found",
true
PublicError::NotFound
)]
#[case::timeout_before_invalid_request(
400,
"A timeout occurred invalid_request_error",
PublicKind::Timeout { status: None },
"MistralException - A timeout occurred invalid_request_error",
true
PublicError::Timeout { status: 408 }
)]
#[case::content_policy_before_invalid_request(
400,
"invalid_request_error content_policy_violation",
kind(
StatusClass::ContentPolicyViolation,
400,
"invalid_request_error content_policy_violation"
),
"ContentPolicyViolationError: MistralException - invalid_request_error content_policy_violation",
true
PublicError::ContentPolicyViolation
)]
#[case::invalid_request_with_a_bad_key_falls_to_the_status(
401,
"invalid_request_error Incorrect API key provided",
kind(
StatusClass::Authentication,
401,
"invalid_request_error Incorrect API key provided"
),
"AuthenticationError: MistralException - invalid_request_error Incorrect API key provided",
true
#[case::encrypted_content_before_invalid_request(
"invalid_request_error invalid_encrypted_content",
PublicError::BadRequest
)]
#[case::text_rules_before_status(
429,
"invalid_request_error bad field",
kind(StatusClass::BadRequest, 429, "invalid_request_error bad field"),
"MistralException - invalid_request_error bad field",
true
)]
#[case::echoed_429_is_not_a_rate_limit(
400,
"token 429 in the prompt",
kind(StatusClass::BadRequest, 400, "token 429 in the prompt"),
"MistralException - token 429 in the prompt",
true
)]
fn the_earlier_rule_wins_when_two_apply(
#[case] status_code: u16,
#[case] body: &str,
#[case] kind: PublicKind,
#[case] message: &str,
#[case] debug: bool,
fn the_earlier_rule_wins_when_two_apply(#[case] text: &str, #[case] expected: PublicError) {
assert_eq!(classified(Some(400), text), Some(expected));
}
#[rstest::rstest]
#[case::encrypted_content("invalid_encrypted_content", ENCRYPTED_CONTENT_HELP)]
#[case::plain_invalid_request("invalid_request_error bad field", "")]
fn only_encrypted_content_failures_carry_the_affinity_help(
#[case] text: &str,
#[case] hint: &str,
) {
let expected = failure(kind, message, "mistral");
assert_eq!(
mapped("mistral", &http(status_code, body)),
if debug {
with_debug(expected)
} else {
expected
}
first_match(RULES, &mapping(Some(400), text)).map(|rule| rule.hint),
Some(hint)
);
}
#[rstest::rstest]
#[case::provider_names_replace_openai(
"azure_ai",
"OPENAI said openai.OpenAIError",
"Azure_aiException - AZURE_AI said azure_ai.azure_aiError"
)]
#[case::openai_keeps_its_own_name("openai", "rejected", "OpenAIException - rejected")]
fn the_message_names_the_provider(
#[case] provider: &str,
#[case] body: &str,
#[case] message: &str,
) {
#[case::bad_key_is_left_to_the_status("invalid_request_error Incorrect API key provided")]
#[case::echoed_429_is_not_a_rate_limit("token 429 in the prompt")]
#[case::unmarked("rejected")]
fn text_without_a_marker_is_left_to_the_status_table(#[case] text: &str) {
assert_eq!(classified(Some(400), text), None);
}
#[test]
fn a_standalone_429_counts_only_with_a_429_status() {
assert_eq!(
mapped(provider, &http(400, body)),
with_debug(failure(
kind(StatusClass::BadRequest, 400, body),
message,
provider
))
classified(Some(429), "got 429 back"),
Some(PublicError::RateLimit)
);
}
}

View file

@ -1,14 +1,4 @@
use super::public::StatusClass;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LocalClass {
ValueError,
FileNotFound,
OsError,
}
/// A route failure in the shape Python's `exception_type` receives it, before any public
/// class is chosen.
/// A failure a Rust route produced, before any public class is chosen.
#[derive(Clone, Debug, PartialEq)]
pub enum OriginalException {
Http {
@ -23,27 +13,132 @@ pub enum OriginalException {
timeout_seconds: Option<f64>,
elapsed_seconds: Option<f64>,
},
Response {
message: String,
},
Local {
class: LocalClass,
message: String,
},
/// A failure Python raises as a public LiteLLM exception itself, which `exception_type`
/// hands back unchanged.
Public {
class: StatusClass,
/// A failure with no HTTP response behind it, such as an unparseable body or a local
/// file error.
Plain {
message: String,
},
}
/// Which of the provider-specific mappers in `exception_type` a route's provider uses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
/// Which provider-specific text rules apply before the shared status table.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExceptionFamily {
OpenAiCompatible,
VertexAi,
Cohere,
#[default]
Other,
}
/// `openai_compatible_providers` in `litellm/constants.py`.
const OPENAI_COMPATIBLE_PROVIDERS: &[&str] = &[
"anyscale",
"groq",
"nvidia_nim",
"cerebras",
"baseten",
"sambanova",
"ai21_chat",
"ai21",
"volcengine",
"codestral",
"deepseek",
"tencent",
"deepinfra",
"perplexity",
"xinference",
"xai",
"zai",
"together_ai",
"fireworks_ai",
"empower",
"friendliai",
"azure_ai",
"github",
"litellm_proxy",
"hosted_vllm",
"llamafile",
"lm_studio",
"galadriel",
"github_copilot",
"chatgpt",
"novita",
"meta_llama",
"publicai",
"synthetic",
"tensormesh",
"apertis",
"nano-gpt",
"poe",
"chutes",
"parasail",
"libertai",
"featherless_ai",
"nscale",
"nebius",
"dashscope",
"qwencloud",
"qwen_ai_platform",
"modelscope",
"moonshot",
"v0",
"helicone",
"morph",
"lambda_ai",
"inception",
"hyperbolic",
"vercel_ai_gateway",
"aiml",
"wandb",
"cometapi",
"clarifai",
"docker_model_runner",
"ragflow",
"pinstripes",
"darkbloom",
"meta",
"cognition",
"scx-ai",
];
impl ExceptionFamily {
/// The provider dispatch at the top of Python's `exception_type`, in its order.
pub fn for_provider(provider: &str) -> Self {
match provider {
"openai" | "text-completion-openai" | "custom_openai" | "mistral" | "runwayml" => {
Self::OpenAiCompatible
}
provider if OPENAI_COMPATIBLE_PROVIDERS.contains(&provider) => Self::OpenAiCompatible,
"vertex_ai" | "vertex_ai_beta" | "gemini" => Self::VertexAi,
"cohere" | "cohere_chat" => Self::Cohere,
_ => Self::Other,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[rstest::rstest]
#[case::openai("openai", ExceptionFamily::OpenAiCompatible)]
#[case::text_completion_openai("text-completion-openai", ExceptionFamily::OpenAiCompatible)]
#[case::custom_openai("custom_openai", ExceptionFamily::OpenAiCompatible)]
#[case::mistral("mistral", ExceptionFamily::OpenAiCompatible)]
#[case::runwayml("runwayml", ExceptionFamily::OpenAiCompatible)]
#[case::listed_compatible("azure_ai", ExceptionFamily::OpenAiCompatible)]
#[case::compatible_list_wins_over_its_own_mapper(
"together_ai",
ExceptionFamily::OpenAiCompatible
)]
#[case::vertex_ai("vertex_ai", ExceptionFamily::VertexAi)]
#[case::vertex_ai_beta("vertex_ai_beta", ExceptionFamily::VertexAi)]
#[case::gemini("gemini", ExceptionFamily::VertexAi)]
#[case::cohere("cohere", ExceptionFamily::Cohere)]
#[case::cohere_chat("cohere_chat", ExceptionFamily::Cohere)]
#[case::unported_mapper("anthropic", ExceptionFamily::Other)]
#[case::unknown("reducto", ExceptionFamily::Other)]
#[case::empty("", ExceptionFamily::Other)]
fn provider_selects_the_family(#[case] provider: &str, #[case] family: ExceptionFamily) {
assert_eq!(ExceptionFamily::for_provider(provider), family);
}
}

View file

@ -1,262 +1,77 @@
use serde::Serialize;
/// The public LiteLLM classes built from a status code alone: every one takes the same
/// constructor arguments.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, strum::EnumIter, strum::IntoStaticStr)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum StatusClass {
/// The public LiteLLM exception classes a Rust route failure can become. Python builds the
/// class; Rust decides which one.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PublicError {
BadRequest,
ContextWindowExceeded,
ContentPolicyViolation,
Authentication,
PermissionDenied,
NotFound,
Timeout { status: u16 },
RateLimit,
ContextWindowExceeded,
ContentPolicyViolation,
InternalServer,
BadGateway,
ServiceUnavailable,
UnsupportedParams,
ApiConnection,
Api { status: u16 },
}
impl StatusClass {
/// The `status_code` the Python class sets on itself.
impl PublicError {
/// The `status_code` the Python class carries.
pub const fn status_code(self) -> u16 {
match self {
Self::BadRequest
| Self::ContextWindowExceeded
| Self::ContentPolicyViolation
| Self::UnsupportedParams => 400,
Self::BadRequest | Self::ContextWindowExceeded | Self::ContentPolicyViolation => 400,
Self::Authentication => 401,
Self::PermissionDenied => 403,
Self::NotFound => 404,
Self::RateLimit => 429,
Self::InternalServer => 500,
Self::InternalServer | Self::ApiConnection => 500,
Self::BadGateway => 502,
Self::ServiceUnavailable => 503,
Self::Timeout { status } | Self::Api { status } => status,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpstreamResponse {
pub status: u16,
pub body: String,
pub headers: Vec<(String, String)>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct HttpStub {
pub status: u16,
pub method: &'static str,
pub url: &'static str,
pub content: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseArg {
Upstream(UpstreamResponse),
Stub(HttpStub),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PublicKind {
Status {
status_class: StatusClass,
response: Option<ResponseArg>,
},
Timeout {
status: Option<u16>,
},
ApiConnection,
Api {
status: u16,
request_url: &'static str,
},
}
/// Constructor arguments for the public LiteLLM exception, as `exception_type` passes them.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PublicFailure {
pub kind: PublicKind,
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MappedFailure {
pub error: PublicError,
pub message: String,
pub model: String,
pub llm_provider: Option<String>,
pub litellm_debug_info: Option<String>,
pub litellm_response_headers: Option<Vec<(String, String)>>,
pub print_banner: bool,
pub upstream: Option<UpstreamResponse>,
pub debug_info: String,
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::path::PathBuf;
use serde_json::Value;
use strum::IntoEnumIterator;
use super::*;
const REGENERATE: &str = "LITELLM_REGENERATE_PUBLIC_FAILURE_FIXTURES";
fn fixture_directory() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../tests/test_litellm/rust_bridge/fixtures/public_failures")
}
fn upstream(status: u16) -> ResponseArg {
ResponseArg::Upstream(UpstreamResponse {
status,
body: r#"{"message": "rejected"}"#.into(),
headers: vec![("retry-after".into(), "7".into())],
})
}
fn status_response(class: StatusClass) -> Option<ResponseArg> {
match class {
StatusClass::Authentication => None,
StatusClass::PermissionDenied => Some(ResponseArg::Stub(HttpStub {
status: 403,
method: "POST",
url: " https://cloud.google.com/vertex-ai/",
content: None,
})),
StatusClass::InternalServer => Some(ResponseArg::Stub(HttpStub {
status: 500,
method: "completion",
url: "https://github.com/BerriAI/litellm",
content: Some("upstream text".into()),
})),
class => Some(upstream(class.status_code())),
}
}
fn failure(kind: PublicKind, name: &str) -> PublicFailure {
let headers = matches!(
&kind,
PublicKind::Status {
response: Some(ResponseArg::Upstream(_)),
..
}
);
PublicFailure {
kind,
message: format!("MistralException - {name}"),
model: "ocr-model".into(),
llm_provider: Some("mistral".into()),
litellm_debug_info: Some("\nModel: ocr-model".into()),
litellm_response_headers: headers.then(|| vec![("retry-after".into(), "7".into())]),
print_banner: false,
}
}
/// One payload per public class the constructor can build; `test_failures.py` reads the
/// same files, so a shape change on either side fails there or here.
fn fixtures() -> Vec<(String, PublicFailure)> {
let statuses = StatusClass::iter().map(|class| {
let name: &'static str = class.into();
let name = format!("status_{name}");
let built = failure(
PublicKind::Status {
status_class: class,
response: status_response(class),
},
&name,
);
(name, built)
});
let others = [
(
"timeout_with_status",
PublicFailure {
print_banner: true,
..failure(
PublicKind::Timeout { status: Some(504) },
"timeout_with_status",
)
},
),
(
"timeout_without_status",
PublicFailure {
litellm_debug_info: None,
..failure(
PublicKind::Timeout { status: None },
"timeout_without_status",
)
},
),
(
"api_connection",
PublicFailure {
llm_provider: None,
..failure(PublicKind::ApiConnection, "api_connection")
},
),
(
"api",
failure(
PublicKind::Api {
status: 409,
request_url: "https://docs.litellm.ai/docs",
},
"api",
),
),
]
.map(|(name, built)| (name.to_string(), built));
statuses.chain(others).collect()
}
#[test]
fn serialized_payloads_match_the_golden_fixtures_python_reads() {
let directory = fixture_directory();
let regenerate = std::env::var_os(REGENERATE).is_some();
let expected = fixtures();
for (name, built) in &expected {
let path = directory.join(format!("{name}.json"));
let serialized = serde_json::to_value(built).unwrap();
if regenerate {
std::fs::create_dir_all(&directory).unwrap();
std::fs::write(
&path,
format!("{}\n", serde_json::to_string_pretty(&serialized).unwrap()),
)
.unwrap();
}
let golden: Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(serialized, golden, "{name}; set {REGENERATE}=1 to rewrite");
}
let on_disk: BTreeSet<String> = std::fs::read_dir(&directory)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
let generated: BTreeSet<String> = expected
.iter()
.map(|(name, _)| format!("{name}.json"))
.collect();
assert_eq!(on_disk, generated);
}
#[rstest::rstest]
#[case(StatusClass::BadRequest, 400)]
#[case(StatusClass::Authentication, 401)]
#[case(StatusClass::PermissionDenied, 403)]
#[case(StatusClass::NotFound, 404)]
#[case(StatusClass::RateLimit, 429)]
#[case(StatusClass::ContextWindowExceeded, 400)]
#[case(StatusClass::ContentPolicyViolation, 400)]
#[case(StatusClass::InternalServer, 500)]
#[case(StatusClass::BadGateway, 502)]
#[case(StatusClass::ServiceUnavailable, 503)]
#[case(StatusClass::UnsupportedParams, 400)]
#[case::bad_request(PublicError::BadRequest, 400)]
#[case::context_window(PublicError::ContextWindowExceeded, 400)]
#[case::content_policy(PublicError::ContentPolicyViolation, 400)]
#[case::authentication(PublicError::Authentication, 401)]
#[case::permission_denied(PublicError::PermissionDenied, 403)]
#[case::not_found(PublicError::NotFound, 404)]
#[case::request_timeout(PublicError::Timeout { status: 408 }, 408)]
#[case::gateway_timeout(PublicError::Timeout { status: 504 }, 504)]
#[case::rate_limit(PublicError::RateLimit, 429)]
#[case::internal_server(PublicError::InternalServer, 500)]
#[case::api_connection(PublicError::ApiConnection, 500)]
#[case::bad_gateway(PublicError::BadGateway, 502)]
#[case::service_unavailable(PublicError::ServiceUnavailable, 503)]
#[case::api(PublicError::Api { status: 501 }, 501)]
fn status_codes_are_the_ones_the_python_classes_set(
#[case] class: StatusClass,
#[case] error: PublicError,
#[case] status: u16,
) {
assert_eq!(class.status_code(), status);
assert_eq!(error.status_code(), status);
}
}

View file

@ -4,106 +4,29 @@ use fancy_regex::Regex;
use serde_json::Value;
use super::Mapping;
use super::public::{HttpStub, PublicFailure, PublicKind, ResponseArg, StatusClass};
use super::public::PublicError;
const GITHUB_URL: &str = "https://github.com/BerriAI/litellm";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ResponseChoice {
Omitted,
Provider,
Stub { status: u16, url: &'static str },
InternalServerStub,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ApiStatus {
Fixed(u16),
Original,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Kind {
Status {
class: StatusClass,
response: ResponseChoice,
},
Timeout(Option<u16>),
ApiConnection,
Api {
status: ApiStatus,
request_url: &'static str,
},
}
/// One branch of a Python `_map_*_exception` function: when it applies, the class it
/// raises, the message it builds, and whether it passes `litellm_debug_info`.
/// One text branch of a Python `_map_*_exception` function: when it applies, the class it
/// raises, and any help text appended to the message.
pub(super) struct Rule {
pub(super) when: fn(&Mapping<'_>) -> bool,
pub(super) kind: Kind,
pub(super) message: fn(&Mapping<'_>) -> String,
pub(super) debug: bool,
}
/// The first rule that applies decides the failure, as the `if`/`elif` chain does in Python.
pub(super) fn apply(rules: &[Rule], mapping: &Mapping<'_>) -> Option<PublicFailure> {
rules
.iter()
.find(|rule| (rule.when)(mapping))
.map(|rule| rule.build(mapping))
pub(super) when: fn(&Mapping) -> bool,
pub(super) error: PublicError,
pub(super) hint: &'static str,
}
impl Rule {
fn build(&self, mapping: &Mapping<'_>) -> PublicFailure {
let kind = match self.kind {
Kind::Status { class, response } => PublicKind::Status {
status_class: class,
response: response.resolve(mapping),
},
Kind::Timeout(status) => PublicKind::Timeout { status },
Kind::ApiConnection => PublicKind::ApiConnection,
Kind::Api {
status,
request_url,
} => PublicKind::Api {
status: match status {
ApiStatus::Fixed(status) => status,
ApiStatus::Original => mapping.original.status.unwrap_or(500),
},
request_url,
},
};
PublicFailure {
kind,
message: (self.message)(mapping),
model: mapping.context.model.clone(),
llm_provider: mapping.context.custom_llm_provider.clone(),
litellm_debug_info: self.debug.then(|| mapping.extra_information.clone()),
litellm_response_headers: None,
print_banner: false,
pub(super) const fn new(when: fn(&Mapping) -> bool, error: PublicError) -> Self {
Self {
when,
error,
hint: "",
}
}
}
impl ResponseChoice {
fn resolve(self, mapping: &Mapping<'_>) -> Option<ResponseArg> {
match self {
Self::Omitted => None,
Self::Provider => mapping.original.response.clone().map(ResponseArg::Upstream),
Self::Stub { status, url } => Some(ResponseArg::Stub(HttpStub {
status,
method: "POST",
url,
content: None,
})),
Self::InternalServerStub => Some(ResponseArg::Stub(HttpStub {
status: 500,
method: "completion",
url: GITHUB_URL,
content: Some(mapping.original.message.clone()),
})),
}
}
/// The first rule that applies decides the class, as the `if`/`elif` chain does in Python.
pub(super) fn first_match<'r>(rules: &'r [Rule], mapping: &Mapping) -> Option<&'r Rule> {
rules.iter().find(|rule| (rule.when)(mapping))
}
pub(super) fn contains_any(text: &str, markers: &[&str]) -> bool {
@ -117,7 +40,7 @@ static RATE_LIMIT_PHRASE: LazyLock<Regex> =
/// `ExceptionCheckers.is_error_str_rate_limit`.
pub(super) fn is_rate_limit(error_str: &str, status: Option<u16>) -> bool {
if STANDALONE_429.is_match(error_str).unwrap_or(false) && status == Some(429) {
if STANDALONE_429.is_match(error_str).unwrap_or(false) && matches!(status, None | Some(429)) {
return true;
}
let lower = error_str.to_lowercase();
@ -169,184 +92,34 @@ pub(super) fn body_error_code(error_str: &str) -> Option<i64> {
#[cfg(test)]
mod tests {
use super::super::testing::{context, failure, http};
use super::super::{ExceptionFamily, OriginalException, UpstreamResponse};
use super::super::testing::mapping;
use super::*;
fn first_marker(mapping: &Mapping<'_>) -> bool {
mapping.error_str.contains("first")
}
fn always(_: &Mapping<'_>) -> bool {
true
}
fn text(mapping: &Mapping<'_>) -> String {
format!("seen {}", mapping.error_str)
}
const ORDERED: &[Rule] = &[
Rule {
when: first_marker,
kind: Kind::Status {
class: StatusClass::NotFound,
response: ResponseChoice::Omitted,
},
message: text,
debug: false,
},
Rule {
when: always,
kind: Kind::ApiConnection,
message: text,
debug: true,
},
Rule::new(
|mapping| mapping.error_str.contains("first"),
PublicError::NotFound,
),
Rule::new(|_| true, PublicError::ApiConnection),
];
fn apply_one(kind: Kind, debug: bool, original: &OriginalException) -> Option<PublicFailure> {
let context = context("mistral", ExceptionFamily::OpenAiCompatible);
let mapping = Mapping::new(&context, original);
apply(
&[Rule {
when: always,
kind,
message: text,
debug,
}],
&mapping,
)
}
#[rstest::rstest]
#[case::earlier_rule_wins("first and second", failure(
PublicKind::Status { status_class: StatusClass::NotFound, response: None },
"seen first and second",
"mistral",
))]
#[case::later_rule_when_the_earlier_does_not_apply("second", PublicFailure {
litellm_debug_info: Some("\nModel: ocr-model".into()),
..failure(PublicKind::ApiConnection, "seen second", "mistral")
})]
fn the_first_applicable_rule_decides(#[case] body: &str, #[case] expected: PublicFailure) {
let context = context("mistral", ExceptionFamily::OpenAiCompatible);
let original = http(400, body);
assert_eq!(
apply(ORDERED, &Mapping::new(&context, &original)),
Some(expected)
);
#[case::earlier_rule_wins("first and second", PublicError::NotFound)]
#[case::later_rule_when_the_earlier_does_not_apply("second", PublicError::ApiConnection)]
fn the_first_applicable_rule_decides(#[case] text: &str, #[case] expected: PublicError) {
let rule = first_match(ORDERED, &mapping(Some(400), text));
assert_eq!(rule.map(|rule| rule.error), Some(expected));
}
#[test]
fn no_applicable_rule_leaves_the_failure_to_the_caller() {
let context = context("mistral", ExceptionFamily::OpenAiCompatible);
let original = http(400, "second");
assert_eq!(
apply(&ORDERED[..1], &Mapping::new(&context, &original)),
None
);
}
#[rstest::rstest]
#[case::omitted(ResponseChoice::Omitted, None)]
#[case::provider(ResponseChoice::Provider, Some(ResponseArg::Upstream(UpstreamResponse {
status: 400,
body: "body".into(),
headers: vec![("retry-after".into(), "7".into())],
})))]
#[case::stub(
ResponseChoice::Stub { status: 429, url: "https://stub.test" },
Some(ResponseArg::Stub(HttpStub { status: 429, method: "POST", url: "https://stub.test", content: None }))
)]
#[case::internal_server_stub(
ResponseChoice::InternalServerStub,
Some(ResponseArg::Stub(HttpStub {
status: 500,
method: "completion",
url: GITHUB_URL,
content: Some("body".into()),
}))
)]
fn response_choices_resolve_against_the_original(
#[case] response: ResponseChoice,
#[case] expected: Option<ResponseArg>,
) {
let built = apply_one(
Kind::Status {
class: StatusClass::BadRequest,
response,
},
false,
&http(400, "body"),
)
.unwrap();
assert_eq!(
built.kind,
PublicKind::Status {
status_class: StatusClass::BadRequest,
response: expected,
}
);
}
#[rstest::rstest]
#[case::fixed(ApiStatus::Fixed(500), http(409, "body"), 500)]
#[case::original(ApiStatus::Original, http(409, "body"), 409)]
#[case::original_without_a_status(
ApiStatus::Original,
OriginalException::Response { message: "body".into() },
500
)]
fn api_status_is_fixed_or_the_originals(
#[case] status: ApiStatus,
#[case] original: OriginalException,
#[case] expected: u16,
) {
let built = apply_one(
Kind::Api {
status,
request_url: "https://api.test",
},
false,
&original,
)
.unwrap();
assert_eq!(
built,
failure(
PublicKind::Api {
status: expected,
request_url: "https://api.test"
},
"seen body",
"mistral"
)
);
}
#[rstest::rstest]
#[case::with_debug(true, Some("\nModel: ocr-model"))]
#[case::without_debug(false, None)]
fn debug_rules_carry_the_extra_information(
#[case] debug: bool,
#[case] expected: Option<&str>,
) {
let built = apply_one(Kind::Timeout(Some(504)), debug, &http(504, "body")).unwrap();
assert_eq!(
built,
PublicFailure {
litellm_debug_info: expected.map(str::to_string),
..failure(
PublicKind::Timeout { status: Some(504) },
"seen body",
"mistral"
)
}
);
assert!(first_match(&ORDERED[..1], &mapping(Some(400), "second")).is_none());
}
#[rstest::rstest]
#[case::standalone_429_with_429_status("got 429 back", Some(429), true)]
#[case::standalone_429_with_other_status("got 429 back", Some(400), false)]
#[case::standalone_429_with_unknown_status("got 429 back", None, true)]
#[case::embedded_429("token4290", Some(429), false)]
#[case::phrase_spaced("Rate Limit reached", None, true)]
#[case::phrase_underscored("rate_limit", None, true)]

View file

@ -1,153 +1,49 @@
use super::public::{PublicFailure, StatusClass};
use super::rules::{ApiStatus, Kind, ResponseChoice, Rule, apply};
use super::{DOCS_URL, Mapping};
use super::public::PublicError;
const fn with_response(class: StatusClass) -> Kind {
Kind::Status {
class,
response: ResponseChoice::Provider,
}
}
fn message(mapping: &Mapping<'_>) -> String {
format!("{} - {}", mapping.exception_provider, mapping.error_str)
}
fn status(mapping: &Mapping<'_>) -> u16 {
mapping.original.status.unwrap_or_default()
}
/// `_map_exception_by_status`, the fallback for a provider error no provider mapper claimed.
const RULES: &[Rule] = &[
Rule {
when: |mapping| status(mapping) == 401,
kind: with_response(StatusClass::Authentication),
message,
debug: true,
},
Rule {
when: |mapping| status(mapping) == 403,
kind: with_response(StatusClass::PermissionDenied),
message,
debug: true,
},
Rule {
when: |mapping| status(mapping) == 404,
kind: with_response(StatusClass::NotFound),
message,
debug: true,
},
Rule {
when: |mapping| status(mapping) == 408,
kind: Kind::Timeout(None),
message,
debug: true,
},
Rule {
when: |mapping| status(mapping) == 429,
kind: with_response(StatusClass::RateLimit),
message,
debug: true,
},
Rule {
when: |mapping| status(mapping) == 500,
kind: with_response(StatusClass::InternalServer),
message,
debug: true,
},
Rule {
when: |mapping| status(mapping) == 502,
kind: with_response(StatusClass::BadGateway),
message,
debug: true,
},
Rule {
when: |mapping| status(mapping) == 503,
kind: with_response(StatusClass::ServiceUnavailable),
message,
debug: true,
},
Rule {
when: |mapping| status(mapping) == 504,
kind: Kind::Timeout(Some(504)),
message,
debug: true,
},
Rule {
when: |mapping| status(mapping) < 500,
kind: with_response(StatusClass::BadRequest),
message,
debug: true,
},
Rule {
when: |_| true,
kind: Kind::Api {
status: ApiStatus::Original,
request_url: DOCS_URL,
},
message,
debug: true,
},
];
/// Only a real provider status of 400 or more reaches the table; a status the HTTP handler
/// synthesized for a failure without a response does not.
pub(super) fn map(mapping: &Mapping<'_>) -> Option<PublicFailure> {
let status = mapping.original.status?;
if status < 400 || mapping.original.status_is_synthesized {
return None;
}
apply(RULES, mapping)
/// `_map_exception_by_status`, the one place a provider status picks a class. Statuses
/// below 400 are not failures the table claims.
pub(super) fn classify(status: u16) -> Option<PublicError> {
let error = match status {
..400 => return None,
401 => PublicError::Authentication,
403 => PublicError::PermissionDenied,
404 => PublicError::NotFound,
408 | 504 => PublicError::Timeout { status },
429 => PublicError::RateLimit,
500 => PublicError::InternalServer,
502 => PublicError::BadGateway,
503 => PublicError::ServiceUnavailable,
400..500 => PublicError::BadRequest,
_ => PublicError::Api { status },
};
Some(error)
}
#[cfg(test)]
mod tests {
use super::super::testing::{context, failure, http, upstream, with_debug};
use super::super::{ExceptionFamily, OriginalException, PublicKind};
use super::*;
fn mapped(original: &OriginalException) -> Option<PublicFailure> {
let context = context("reducto", ExceptionFamily::Other);
map(&Mapping::new(&context, original))
}
fn classified(class: StatusClass, status_code: u16) -> PublicKind {
PublicKind::Status {
status_class: class,
response: upstream(status_code, "rejected"),
}
}
#[rstest::rstest]
#[case::authentication(401, classified(StatusClass::Authentication, 401))]
#[case::permission_denied(403, classified(StatusClass::PermissionDenied, 403))]
#[case::not_found(404, classified(StatusClass::NotFound, 404))]
#[case::request_timeout(408, PublicKind::Timeout { status: None })]
#[case::rate_limited(429, classified(StatusClass::RateLimit, 429))]
#[case::internal_server(500, classified(StatusClass::InternalServer, 500))]
#[case::bad_gateway(502, classified(StatusClass::BadGateway, 502))]
#[case::service_unavailable(503, classified(StatusClass::ServiceUnavailable, 503))]
#[case::gateway_timeout(504, PublicKind::Timeout { status: Some(504) })]
#[case::lowest_client_error(400, classified(StatusClass::BadRequest, 400))]
#[case::other_client_error(409, classified(StatusClass::BadRequest, 409))]
#[case::highest_client_error(499, classified(StatusClass::BadRequest, 499))]
#[case::other_server_error(501, PublicKind::Api { status: 501, request_url: DOCS_URL })]
fn every_mapped_status_and_the_fallback(#[case] status_code: u16, #[case] kind: PublicKind) {
assert_eq!(
mapped(&http(status_code, "rejected")),
Some(with_debug(failure(
kind,
"ReductoException - rejected",
"reducto"
)))
);
}
#[rstest::rstest]
#[case::below_client_errors(http(399, "rejected"))]
#[case::synthesized(OriginalException::Connection { message: "refused".into() })]
#[case::no_status(OriginalException::Response { message: "bad body".into() })]
fn failures_the_table_does_not_claim(#[case] original: OriginalException) {
assert_eq!(mapped(&original), None);
#[case::below_client_errors(399, None)]
#[case::lowest_client_error(400, Some(PublicError::BadRequest))]
#[case::authentication(401, Some(PublicError::Authentication))]
#[case::permission_denied(403, Some(PublicError::PermissionDenied))]
#[case::not_found(404, Some(PublicError::NotFound))]
#[case::request_timeout(408, Some(PublicError::Timeout { status: 408 }))]
#[case::other_client_error(409, Some(PublicError::BadRequest))]
#[case::unprocessable(422, Some(PublicError::BadRequest))]
#[case::rate_limited(429, Some(PublicError::RateLimit))]
#[case::highest_client_error(499, Some(PublicError::BadRequest))]
#[case::internal_server(500, Some(PublicError::InternalServer))]
#[case::other_server_error(501, Some(PublicError::Api { status: 501 }))]
#[case::bad_gateway(502, Some(PublicError::BadGateway))]
#[case::service_unavailable(503, Some(PublicError::ServiceUnavailable))]
#[case::gateway_timeout(504, Some(PublicError::Timeout { status: 504 }))]
#[case::highest_server_error(599, Some(PublicError::Api { status: 599 }))]
fn every_mapped_status_and_the_fallback(
#[case] status: u16,
#[case] expected: Option<PublicError>,
) {
assert_eq!(classify(status), expected);
}
}

View file

@ -1,60 +1,17 @@
use super::public::{PublicFailure, PublicKind, ResponseArg, StatusClass, UpstreamResponse};
use super::rules::{
Kind, ResponseChoice, Rule, apply, body_error_code, contains_any, is_context_window_exceeded,
};
use super::{Mapping, python_capitalize};
const VERTEX_URL: &str = "https://cloud.google.com/vertex-ai/";
const VERTEX_URL_WITH_SPACE: &str = " https://cloud.google.com/vertex-ai/";
use super::public::PublicError;
use super::rules::{Rule, body_error_code, contains_any, is_context_window_exceeded};
const QUOTA_MARKERS: &[&str] = &[
"429 Quota exceeded",
"Quota exceeded for",
"Resource exhausted",
"IndexError: list index out of range",
"429 Unable to submit request because the service is temporarily out of capacity.",
];
const fn stubbed(class: StatusClass, status: u16, url: &'static str) -> Kind {
Kind::Status {
class,
response: ResponseChoice::Stub { status, url },
}
}
const fn bare(class: StatusClass) -> Kind {
Kind::Status {
class,
response: ResponseChoice::Omitted,
}
}
/// `{Provider}Exception{label} - {error_str}` with Python's `str.capitalize()`.
fn capitalized(mapping: &Mapping<'_>, label: &str) -> String {
format!(
"{}Exception{label} - {}",
python_capitalize(mapping.provider),
mapping.error_str
)
}
/// `litellm.{Class}: {provider}Exception - {error_str}` with the provider as given.
fn litellm_prefixed(mapping: &Mapping<'_>, class: &str) -> String {
format!(
"litellm.{class}: {}Exception - {}",
mapping.provider, mapping.error_str
)
}
fn status_is(mapping: &Mapping<'_>, status: u16) -> bool {
mapping.original.status == Some(status)
}
/// `_map_vertex_exception`, in its branch order. A failure no rule claims falls through
/// to the status table.
const RULES: &[Rule] = &[
Rule {
when: |mapping| {
/// The text branches of `_map_vertex_exception`, in its order.
pub(super) const RULES: &[Rule] = &[
Rule::new(
|mapping| {
contains_any(
&mapping.error_str,
&[
@ -63,54 +20,32 @@ const RULES: &[Rule] = &[
],
)
},
kind: stubbed(StatusClass::BadRequest, 400, VERTEX_URL_WITH_SPACE),
message: |mapping| litellm_prefixed(mapping, "BadRequestError"),
debug: true,
},
Rule {
when: |mapping| {
PublicError::BadRequest,
),
Rule::new(
|mapping| {
mapping
.error_str
.contains("400 Request payload size exceeds")
|| is_context_window_exceeded(&mapping.error_str)
},
kind: bare(StatusClass::ContextWindowExceeded),
message: |mapping| capitalized(mapping, ""),
debug: false,
},
Rule {
when: |mapping| is_context_window_exceeded(&mapping.error_str),
kind: bare(StatusClass::ContextWindowExceeded),
message: |mapping| format!("ContextWindowExceededError: {}", capitalized(mapping, "")),
debug: true,
},
Rule {
when: |mapping| {
PublicError::ContextWindowExceeded,
),
Rule::new(
|mapping| {
contains_any(
&mapping.error_str,
&["None Unknown Error.", "Content has no parts."],
)
},
kind: Kind::Status {
class: StatusClass::InternalServer,
response: ResponseChoice::InternalServerStub,
},
message: |mapping| litellm_prefixed(mapping, "InternalServerError"),
debug: true,
},
Rule {
when: |mapping| mapping.error_str.contains("API key not valid."),
kind: bare(StatusClass::Authentication),
message: |mapping| capitalized(mapping, ""),
debug: true,
},
Rule {
when: |mapping| mapping.error_str.contains("403"),
kind: stubbed(StatusClass::BadRequest, 403, VERTEX_URL_WITH_SPACE),
message: |mapping| capitalized(mapping, " BadRequestError"),
debug: true,
},
Rule {
when: |mapping| {
PublicError::InternalServer,
),
Rule::new(
|mapping| mapping.error_str.contains("API key not valid."),
PublicError::Authentication,
),
Rule::new(
|mapping| {
contains_any(
&mapping.error_str,
&[
@ -119,456 +54,124 @@ const RULES: &[Rule] = &[
],
)
},
kind: stubbed(
StatusClass::ContentPolicyViolation,
400,
VERTEX_URL_WITH_SPACE,
),
message: |mapping| capitalized(mapping, " ContentPolicyViolationError"),
debug: true,
},
Rule {
when: |mapping| {
PublicError::ContentPolicyViolation,
),
Rule::new(
|mapping| {
contains_any(&mapping.error_str, QUOTA_MARKERS)
|| (mapping
.original
.status
.is_some_and(|status| (500..600).contains(&status))
&& body_error_code(&mapping.error_str) == Some(429))
},
kind: stubbed(StatusClass::RateLimit, 429, VERTEX_URL_WITH_SPACE),
message: |mapping| litellm_prefixed(mapping, "RateLimitError"),
debug: true,
},
Rule {
when: |mapping| {
PublicError::RateLimit,
),
Rule::new(
|mapping| {
contains_any(
&mapping.error_str,
&["500 Internal Server Error", "The model is overloaded."],
)
},
kind: bare(StatusClass::InternalServer),
message: |mapping| litellm_prefixed(mapping, "InternalServerError"),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, 400),
kind: stubbed(StatusClass::BadRequest, 400, VERTEX_URL),
message: |mapping| capitalized(mapping, " BadRequestError"),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, 401),
kind: bare(StatusClass::Authentication),
message: |mapping| capitalized(mapping, ""),
debug: false,
},
Rule {
when: |mapping| status_is(mapping, 403),
kind: stubbed(StatusClass::PermissionDenied, 403, VERTEX_URL),
message: |mapping| capitalized(mapping, ""),
debug: false,
},
Rule {
when: |mapping| status_is(mapping, 404),
kind: bare(StatusClass::NotFound),
message: |mapping| capitalized(mapping, ""),
debug: false,
},
Rule {
when: |mapping| status_is(mapping, 408),
kind: Kind::Timeout(None),
message: |mapping| capitalized(mapping, ""),
debug: false,
},
Rule {
when: |mapping| status_is(mapping, 429),
kind: stubbed(StatusClass::RateLimit, 429, VERTEX_URL_WITH_SPACE),
message: |mapping| format!("litellm.RateLimitError: {}", capitalized(mapping, "")),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, 500),
kind: Kind::Status {
class: StatusClass::InternalServer,
response: ResponseChoice::InternalServerStub,
},
message: |mapping| capitalized(mapping, " InternalServerError"),
debug: true,
},
Rule {
when: |mapping| status_is(mapping, 502),
kind: Kind::ApiConnection,
message: |mapping| capitalized(mapping, ""),
debug: false,
},
Rule {
when: |mapping| status_is(mapping, 503),
kind: bare(StatusClass::ServiceUnavailable),
message: |mapping| capitalized(mapping, ""),
debug: false,
},
PublicError::InternalServer,
),
];
pub(super) fn map(mapping: &Mapping<'_>) -> Option<PublicFailure> {
apply(RULES, mapping).map(|failure| keep_upstream_response(mapping, failure))
}
/// Deliberate divergence from `_map_vertex_exception`, which replaces the provider response
/// with a stub and so drops the upstream body and `retry-after`. The response keeps the
/// status the public class carries.
fn keep_upstream_response(mapping: &Mapping<'_>, failure: PublicFailure) -> PublicFailure {
let (PublicKind::Status { status_class, .. }, Some(upstream), false) = (
&failure.kind,
&mapping.original.response,
mapping.original.status_is_synthesized,
) else {
return failure;
};
PublicFailure {
kind: PublicKind::Status {
status_class: *status_class,
response: Some(ResponseArg::Upstream(UpstreamResponse {
status: status_class.status_code(),
..upstream.clone()
})),
},
..failure
}
}
#[cfg(test)]
mod tests {
use super::super::testing::{context, failure, http, status, upstream, with_debug};
use super::super::{ExceptionFamily, HttpStub, OriginalException};
use super::super::rules::first_match;
use super::super::testing::mapping;
use super::*;
fn mapped(original: &OriginalException) -> Option<PublicFailure> {
let context = context("vertex_ai", ExceptionFamily::VertexAi);
map(&Mapping::new(&context, original))
}
fn kept(class: StatusClass, body: &str) -> PublicKind {
status(class, upstream(class.status_code(), body))
fn classified(status: Option<u16>, text: &str) -> Option<PublicError> {
first_match(RULES, &mapping(status, text)).map(|rule| rule.error)
}
#[rstest::rstest]
#[case::api_not_enabled(
400,
"Vertex AI API has not been used in project x",
with_debug(failure(
kept(
StatusClass::BadRequest,
"Vertex AI API has not been used in project x"
),
"litellm.BadRequestError: vertex_aiException - Vertex AI API has not been used in project x",
"vertex_ai",
))
)]
#[case::project_not_found(
400,
"Unable to find your project",
with_debug(failure(
kept(StatusClass::BadRequest, "Unable to find your project"),
"litellm.BadRequestError: vertex_aiException - Unable to find your project",
"vertex_ai",
))
PublicError::BadRequest
)]
#[case::project_not_found("Unable to find your project", PublicError::BadRequest)]
#[case::payload_too_large(
400,
"400 Request payload size exceeds the limit",
failure(
kept(
StatusClass::ContextWindowExceeded,
"400 Request payload size exceeds the limit"
),
"Vertex_aiException - 400 Request payload size exceeds the limit",
"vertex_ai",
)
PublicError::ContextWindowExceeded
)]
#[case::context_window(
500,
"This model's maximum context length is 10",
with_debug(failure(
kept(
StatusClass::ContextWindowExceeded,
"This model's maximum context length is 10"
),
"ContextWindowExceededError: Vertex_aiException - This model's maximum context length is 10",
"vertex_ai",
))
)]
#[case::unknown_error(
400,
"None Unknown Error.",
with_debug(failure(
kept(StatusClass::InternalServer, "None Unknown Error."),
"litellm.InternalServerError: vertex_aiException - None Unknown Error.",
"vertex_ai",
))
)]
#[case::no_parts(
400,
"Content has no parts.",
with_debug(failure(
kept(StatusClass::InternalServer, "Content has no parts."),
"litellm.InternalServerError: vertex_aiException - Content has no parts.",
"vertex_ai",
))
)]
#[case::api_key_not_valid(
400,
"API key not valid.",
with_debug(failure(
kept(StatusClass::Authentication, "API key not valid."),
"Vertex_aiException - API key not valid.",
"vertex_ai",
))
)]
#[case::forbidden_text(
400,
"got a 403",
with_debug(failure(
kept(StatusClass::BadRequest, "got a 403"),
"Vertex_aiException BadRequestError - got a 403",
"vertex_ai",
))
)]
#[case::response_blocked(
400,
"The response was blocked.",
with_debug(failure(
kept(StatusClass::ContentPolicyViolation, "The response was blocked."),
"Vertex_aiException ContentPolicyViolationError - The response was blocked.",
"vertex_ai",
))
PublicError::ContextWindowExceeded
)]
#[case::unknown_error("None Unknown Error.", PublicError::InternalServer)]
#[case::no_parts("Content has no parts.", PublicError::InternalServer)]
#[case::api_key_not_valid("API key not valid.", PublicError::Authentication)]
#[case::response_blocked("The response was blocked.", PublicError::ContentPolicyViolation)]
#[case::output_blocked(
400,
"Output blocked by content filtering policy",
with_debug(failure(
kept(
StatusClass::ContentPolicyViolation,
"Output blocked by content filtering policy"
),
"Vertex_aiException ContentPolicyViolationError - Output blocked by content filtering policy",
"vertex_ai",
))
PublicError::ContentPolicyViolation
)]
#[case::quota_marker(
400,
"Quota exceeded for aiplatform",
with_debug(failure(
kept(StatusClass::RateLimit, "Quota exceeded for aiplatform"),
"litellm.RateLimitError: vertex_aiException - Quota exceeded for aiplatform",
"vertex_ai",
))
#[case::quota_exceeded_429("429 Quota exceeded", PublicError::RateLimit)]
#[case::quota_exceeded_for("Quota exceeded for aiplatform", PublicError::RateLimit)]
#[case::resource_exhausted("Resource exhausted", PublicError::RateLimit)]
#[case::out_of_capacity(
"429 Unable to submit request because the service is temporarily out of capacity.",
PublicError::RateLimit
)]
#[case::wrapped_429(
503,
r#"{"error": {"code": "429"}}"#,
with_debug(failure(
kept(StatusClass::RateLimit, r#"{"error": {"code": "429"}}"#),
r#"litellm.RateLimitError: vertex_aiException - {"error": {"code": "429"}}"#,
"vertex_ai",
))
)]
#[case::overloaded(
400,
"The model is overloaded.",
with_debug(failure(
kept(StatusClass::InternalServer, "The model is overloaded."),
"litellm.InternalServerError: vertex_aiException - The model is overloaded.",
"vertex_ai",
))
)]
#[case::internal_server_text(
400,
"500 Internal Server Error",
with_debug(failure(
kept(StatusClass::InternalServer, "500 Internal Server Error"),
"litellm.InternalServerError: vertex_aiException - 500 Internal Server Error",
"vertex_ai",
))
)]
fn each_text_rule_maps_by_the_body(
#[case] status_code: u16,
#[case] body: &str,
#[case] expected: PublicFailure,
) {
assert_eq!(mapped(&http(status_code, body)), Some(expected));
#[case::internal_server_text("500 Internal Server Error", PublicError::InternalServer)]
#[case::overloaded("The model is overloaded.", PublicError::InternalServer)]
fn each_text_rule_claims_its_marker(#[case] text: &str, #[case] expected: PublicError) {
assert_eq!(classified(Some(400), text), Some(expected));
}
#[rstest::rstest]
#[case::bad_request(
400,
with_debug(failure(
kept(StatusClass::BadRequest, "rejected"),
"Vertex_aiException BadRequestError - rejected",
"vertex_ai"
))
)]
#[case::authentication(
401,
failure(
kept(StatusClass::Authentication, "rejected"),
"Vertex_aiException - rejected",
"vertex_ai"
)
)]
#[case::permission_denied(
403,
failure(
kept(StatusClass::PermissionDenied, "rejected"),
"Vertex_aiException - rejected",
"vertex_ai"
)
)]
#[case::not_found(
404,
failure(
kept(StatusClass::NotFound, "rejected"),
"Vertex_aiException - rejected",
"vertex_ai"
)
)]
#[case::request_timeout(408, failure(PublicKind::Timeout { status: None }, "Vertex_aiException - rejected", "vertex_ai"))]
#[case::rate_limited(
429,
with_debug(failure(
kept(StatusClass::RateLimit, "rejected"),
"litellm.RateLimitError: Vertex_aiException - rejected",
"vertex_ai"
))
)]
#[case::internal_server(
500,
with_debug(failure(
kept(StatusClass::InternalServer, "rejected"),
"Vertex_aiException InternalServerError - rejected",
"vertex_ai"
))
)]
#[case::bad_gateway(
502,
failure(
PublicKind::ApiConnection,
"Vertex_aiException - rejected",
"vertex_ai"
)
)]
#[case::service_unavailable(
503,
failure(
kept(StatusClass::ServiceUnavailable, "rejected"),
"Vertex_aiException - rejected",
"vertex_ai"
)
)]
fn each_status_rule_maps_by_the_status(
#[case] status_code: u16,
#[case] expected: PublicFailure,
#[case::server_error_wrapping_a_429(Some(503), Some(PublicError::RateLimit))]
#[case::lowest_server_error(Some(500), Some(PublicError::RateLimit))]
#[case::highest_server_error(Some(599), Some(PublicError::RateLimit))]
#[case::client_error(Some(400), None)]
#[case::no_status(None, None)]
fn a_wrapped_429_is_a_rate_limit_only_behind_a_server_error(
#[case] status: Option<u16>,
#[case] expected: Option<PublicError>,
) {
assert_eq!(mapped(&http(status_code, "rejected")), Some(expected));
}
#[rstest::rstest]
#[case::unmapped_status(409)]
#[case::gateway_timeout(504)]
fn statuses_without_a_rule_fall_through(#[case] status_code: u16) {
assert_eq!(mapped(&http(status_code, "rejected")), None);
}
#[rstest::rstest]
#[case::stub_without_an_upstream_response(
OriginalException::Response { message: "got a 403".into() },
status(StatusClass::BadRequest, Some(ResponseArg::Stub(HttpStub { status: 403, method: "POST", url: VERTEX_URL_WITH_SPACE, content: None })))
)]
#[case::stub_for_a_synthesized_status(
OriginalException::Connection { message: "got a 403".into() },
status(StatusClass::BadRequest, Some(ResponseArg::Stub(HttpStub { status: 403, method: "POST", url: VERTEX_URL_WITH_SPACE, content: None })))
)]
fn the_rule_response_stays_when_there_is_no_real_upstream_response(
#[case] original: OriginalException,
#[case] kind: PublicKind,
) {
assert_eq!(mapped(&original).map(|failure| failure.kind), Some(kind));
}
#[test]
fn a_synthesized_500_keeps_the_internal_server_stub() {
let original = OriginalException::Connection {
message: "refused".into(),
};
assert_eq!(
mapped(&original),
Some(with_debug(failure(
status(
StatusClass::InternalServer,
Some(ResponseArg::Stub(HttpStub {
status: 500,
method: "completion",
url: "https://github.com/BerriAI/litellm",
content: Some("refused".into()),
}))
),
"Vertex_aiException InternalServerError - refused",
"vertex_ai"
)))
classified(status, r#"{"error": {"code": "429"}}"#),
expected
);
}
#[rstest::rstest]
#[case::project_before_payload_size(
"Unable to find your project 400 Request payload size exceeds",
StatusClass::BadRequest,
"litellm.BadRequestError: vertex_aiException - Unable to find your project 400 Request payload size exceeds",
true
PublicError::BadRequest
)]
#[case::payload_size_before_context_window(
"400 Request payload size exceeds; This model's maximum context length is 10",
StatusClass::ContextWindowExceeded,
"Vertex_aiException - 400 Request payload size exceeds; This model's maximum context length is 10",
false
#[case::context_window_before_unknown_error(
"This model's maximum context length is 10 None Unknown Error.",
PublicError::ContextWindowExceeded
)]
#[case::api_key_before_forbidden(
"API key not valid. 403",
StatusClass::Authentication,
"Vertex_aiException - API key not valid. 403",
true
#[case::unknown_error_before_api_key(
"Content has no parts. API key not valid.",
PublicError::InternalServer
)]
#[case::forbidden_before_blocked(
"403 The response was blocked.",
StatusClass::BadRequest,
"Vertex_aiException BadRequestError - 403 The response was blocked.",
true
#[case::api_key_before_blocked(
"API key not valid. The response was blocked.",
PublicError::Authentication
)]
#[case::blocked_before_quota(
"The response was blocked. Resource exhausted",
StatusClass::ContentPolicyViolation,
"Vertex_aiException ContentPolicyViolationError - The response was blocked. Resource exhausted",
true
PublicError::ContentPolicyViolation
)]
#[case::quota_before_overloaded(
"Resource exhausted The model is overloaded.",
StatusClass::RateLimit,
"litellm.RateLimitError: vertex_aiException - Resource exhausted The model is overloaded.",
true
PublicError::RateLimit
)]
fn the_earlier_rule_wins_when_two_apply(
#[case] body: &str,
#[case] class: StatusClass,
#[case] message: &str,
#[case] debug: bool,
) {
let expected = failure(kept(class, body), message, "vertex_ai");
assert_eq!(
mapped(&http(401, body)),
Some(if debug {
with_debug(expected)
} else {
expected
})
);
fn the_earlier_rule_wins_when_two_apply(#[case] text: &str, #[case] expected: PublicError) {
assert_eq!(classified(Some(400), text), Some(expected));
}
#[rstest::rstest]
#[case::a_403_in_the_text("got a 403 from 4031 tokens")]
#[case::python_client_crash("IndexError: list index out of range")]
#[case::unmarked("rejected")]
fn text_without_a_marker_is_left_to_the_status_table(#[case] text: &str) {
assert_eq!(classified(Some(400), text), None);
}
}

View file

@ -4,7 +4,6 @@ pub mod exception_mapping_utils;
pub mod get_llm_provider_logic;
pub mod params;
pub mod prompt_templates;
pub mod python_repr;
pub mod secret_redaction;
pub mod serde_compat;
pub mod url_utils;

View file

@ -1,93 +0,0 @@
/// `repr()` of a Python `str`: single quotes unless the text holds a single quote and no
/// double quote, with backslashes, the chosen quote and control characters escaped.
pub fn python_str_repr(value: &str) -> String {
let quote = if value.contains('\'') && !value.contains('"') {
'"'
} else {
'\''
};
let escaped: String = value
.chars()
.map(|character| match character {
'\\' => "\\\\".to_string(),
'\t' => "\\t".to_string(),
'\n' => "\\n".to_string(),
'\r' => "\\r".to_string(),
character if character == quote => format!("\\{character}"),
character
if (character as u32) < 0x20 || (0x7f..0xa0).contains(&(character as u32)) =>
{
format!("\\x{:02x}", character as u32)
}
character => character.to_string(),
})
.collect();
format!("{quote}{escaped}{quote}")
}
/// `repr()` of the Python value a JSON value decodes to.
pub fn python_value_repr(value: &serde_json::Value) -> String {
use serde_json::Value;
match value {
Value::Null => "None".to_string(),
Value::Bool(true) => "True".to_string(),
Value::Bool(false) => "False".to_string(),
Value::Number(number) => number.to_string(),
Value::String(text) => python_str_repr(text),
Value::Array(items) => format!(
"[{}]",
items
.iter()
.map(python_value_repr)
.collect::<Vec<_>>()
.join(", ")
),
Value::Object(fields) => format!(
"{{{}}}",
fields
.iter()
.map(|(key, value)| format!(
"{}: {}",
python_str_repr(key),
python_value_repr(value)
))
.collect::<Vec<_>>()
.join(", ")
),
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{python_str_repr, python_value_repr};
#[rstest::rstest]
#[case::null(json!(null), "None")]
#[case::true_(json!(true), "True")]
#[case::false_(json!(false), "False")]
#[case::integer(json!(5), "5")]
#[case::float(json!(1.5), "1.5")]
#[case::string(json!("it's"), "\"it's\"")]
#[case::list(json!(["a", 1]), "['a', 1]")]
#[case::dict(json!({"format": "native"}), "{'format': 'native'}")]
#[case::empty_list(json!([]), "[]")]
fn value_repr_matches_python(#[case] value: serde_json::Value, #[case] expected: &str) {
assert_eq!(python_value_repr(&value), expected);
}
#[rstest::rstest]
#[case::plain("native", "'native'")]
#[case::single_quote("it's", "\"it's\"")]
#[case::both_quotes("it's \"x\"", "'it\\'s \"x\"'")]
#[case::double_quote("say \"x\"", "'say \"x\"'")]
#[case::backslash("a\\b", "'a\\\\b'")]
#[case::whitespace("a\tb\nc\rd", "'a\\tb\\nc\\rd'")]
#[case::control("a\u{1}b\u{7f}c\u{85}", "'a\\x01b\\x7fc\\x85'")]
#[case::unicode("café", "'café'")]
#[case::empty("", "''")]
fn matches_python_repr(#[case] value: &str, #[case] expected: &str) {
assert_eq!(python_str_repr(value), expected);
}
}

View file

@ -1,5 +1,3 @@
use std::sync::LazyLock;
use fancy_regex::Regex;
pub const REDACTED: &str = "REDACTED";
@ -51,21 +49,32 @@ fn secret_patterns(minimum_custom_key_length: usize) -> String {
.join("|")
}
static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
"(?i){}",
secret_patterns(minimum_custom_key_length())
))
.expect("secret redaction patterns compile")
});
pub fn redact_string(value: &str) -> String {
SECRET_RE.replace_all(value, REDACTED).into_owned()
/// Python's `_ENABLE_SECRET_REDACTION` pattern set, compiled once per configuration.
#[derive(Clone, Debug)]
pub struct SecretRedactor {
pattern: Regex,
}
pub fn secret_redaction_enabled() -> bool {
!std::env::var("LITELLM_DISABLE_REDACT_SECRETS")
.is_ok_and(|value| value.eq_ignore_ascii_case("true"))
impl SecretRedactor {
pub fn new(minimum_custom_key_length: usize) -> Self {
let pattern = Regex::new(&format!(
"(?i){}",
secret_patterns(minimum_custom_key_length)
))
.expect("secret redaction patterns compile");
Self { pattern }
}
/// `None` when `LITELLM_DISABLE_REDACT_SECRETS` turns redaction off.
pub fn from_env() -> Option<Self> {
let disabled = std::env::var("LITELLM_DISABLE_REDACT_SECRETS")
.is_ok_and(|value| value.eq_ignore_ascii_case("true"));
(!disabled).then(|| Self::new(minimum_custom_key_length()))
}
pub fn redact(&self, value: &str) -> String {
self.pattern.replace_all(value, REDACTED).into_owned()
}
}
#[cfg(test)]
@ -85,13 +94,16 @@ mod tests {
#[case::password_needs_word_boundary("db_password=hunter2", "REDACTED")]
#[case::plain_text_is_kept(r#"{"message": "rejected"}"#, r#"{"message": "rejected"}"#)]
fn redacts_the_same_spans_as_the_python_patterns(#[case] input: &str, #[case] expected: &str) {
assert_eq!(redact_string(input), expected);
assert_eq!(
SecretRedactor::new(DEFAULT_MINIMUM_CUSTOM_KEY_LENGTH).redact(input),
expected
);
}
#[test]
fn sk_threshold_follows_the_minimum_custom_key_length() {
let patterns = Regex::new(&format!("(?i){}", secret_patterns(8))).unwrap();
assert_eq!(patterns.replace_all("sk-abcde", REDACTED), REDACTED);
assert_eq!(patterns.replace_all("sk-abcd", REDACTED), "sk-abcd");
let redactor = SecretRedactor::new(8);
assert_eq!(redactor.redact("sk-abcde"), REDACTED);
assert_eq!(redactor.redact("sk-abcd"), "sk-abcd");
}
}

View file

@ -1,13 +0,0 @@
{
"kind": {
"type": "api",
"status": 409,
"request_url": "https://docs.litellm.ai/docs"
},
"message": "MistralException - api",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": null,
"print_banner": false
}

View file

@ -1,11 +0,0 @@
{
"kind": {
"type": "api_connection"
},
"message": "MistralException - api_connection",
"model": "ocr-model",
"llm_provider": null,
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": null,
"print_banner": false
}

View file

@ -1,13 +0,0 @@
{
"kind": {
"type": "status",
"status_class": "authentication",
"response": null
},
"message": "MistralException - status_authentication",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": null,
"print_banner": false
}

View file

@ -1,28 +0,0 @@
{
"kind": {
"type": "status",
"status_class": "bad_gateway",
"response": {
"type": "upstream",
"status": 502,
"body": "{\"message\": \"rejected\"}",
"headers": [
[
"retry-after",
"7"
]
]
}
},
"message": "MistralException - status_bad_gateway",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": [
[
"retry-after",
"7"
]
],
"print_banner": false
}

View file

@ -1,28 +0,0 @@
{
"kind": {
"type": "status",
"status_class": "bad_request",
"response": {
"type": "upstream",
"status": 400,
"body": "{\"message\": \"rejected\"}",
"headers": [
[
"retry-after",
"7"
]
]
}
},
"message": "MistralException - status_bad_request",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": [
[
"retry-after",
"7"
]
],
"print_banner": false
}

View file

@ -1,28 +0,0 @@
{
"kind": {
"type": "status",
"status_class": "content_policy_violation",
"response": {
"type": "upstream",
"status": 400,
"body": "{\"message\": \"rejected\"}",
"headers": [
[
"retry-after",
"7"
]
]
}
},
"message": "MistralException - status_content_policy_violation",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": [
[
"retry-after",
"7"
]
],
"print_banner": false
}

View file

@ -1,28 +0,0 @@
{
"kind": {
"type": "status",
"status_class": "context_window_exceeded",
"response": {
"type": "upstream",
"status": 400,
"body": "{\"message\": \"rejected\"}",
"headers": [
[
"retry-after",
"7"
]
]
}
},
"message": "MistralException - status_context_window_exceeded",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": [
[
"retry-after",
"7"
]
],
"print_banner": false
}

View file

@ -1,19 +0,0 @@
{
"kind": {
"type": "status",
"status_class": "internal_server",
"response": {
"type": "stub",
"status": 500,
"method": "completion",
"url": "https://github.com/BerriAI/litellm",
"content": "upstream text"
}
},
"message": "MistralException - status_internal_server",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": null,
"print_banner": false
}

View file

@ -1,28 +0,0 @@
{
"kind": {
"type": "status",
"status_class": "not_found",
"response": {
"type": "upstream",
"status": 404,
"body": "{\"message\": \"rejected\"}",
"headers": [
[
"retry-after",
"7"
]
]
}
},
"message": "MistralException - status_not_found",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": [
[
"retry-after",
"7"
]
],
"print_banner": false
}

View file

@ -1,19 +0,0 @@
{
"kind": {
"type": "status",
"status_class": "permission_denied",
"response": {
"type": "stub",
"status": 403,
"method": "POST",
"url": " https://cloud.google.com/vertex-ai/",
"content": null
}
},
"message": "MistralException - status_permission_denied",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": null,
"print_banner": false
}

View file

@ -1,28 +0,0 @@
{
"kind": {
"type": "status",
"status_class": "rate_limit",
"response": {
"type": "upstream",
"status": 429,
"body": "{\"message\": \"rejected\"}",
"headers": [
[
"retry-after",
"7"
]
]
}
},
"message": "MistralException - status_rate_limit",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": [
[
"retry-after",
"7"
]
],
"print_banner": false
}

View file

@ -1,28 +0,0 @@
{
"kind": {
"type": "status",
"status_class": "service_unavailable",
"response": {
"type": "upstream",
"status": 503,
"body": "{\"message\": \"rejected\"}",
"headers": [
[
"retry-after",
"7"
]
]
}
},
"message": "MistralException - status_service_unavailable",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": [
[
"retry-after",
"7"
]
],
"print_banner": false
}

View file

@ -1,28 +0,0 @@
{
"kind": {
"type": "status",
"status_class": "unsupported_params",
"response": {
"type": "upstream",
"status": 400,
"body": "{\"message\": \"rejected\"}",
"headers": [
[
"retry-after",
"7"
]
]
}
},
"message": "MistralException - status_unsupported_params",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": [
[
"retry-after",
"7"
]
],
"print_banner": false
}

View file

@ -1,12 +0,0 @@
{
"kind": {
"type": "timeout",
"status": 504
},
"message": "MistralException - timeout_with_status",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": "\nModel: ocr-model",
"litellm_response_headers": null,
"print_banner": true
}

View file

@ -1,12 +0,0 @@
{
"kind": {
"type": "timeout",
"status": null
},
"message": "MistralException - timeout_without_status",
"model": "ocr-model",
"llm_provider": "mistral",
"litellm_debug_info": null,
"litellm_response_headers": null,
"print_banner": false
}