use std::fmt;
use serde_json::Value;
use crate::registry::{lookup, DialectId};
use crate::types::{Response, ValidationError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorCode {
Auth,
Billing,
RateLimit,
InvalidRequest,
ContextLength,
Timeout,
Server,
UnsupportedModel,
UnsupportedFeature,
NotConfigured,
UnknownModel,
AmbiguousModel,
Transport,
LockTimeout,
StreamAssembly,
CollectionLimit,
Provider,
AuthOperation,
}
impl ErrorCode {
pub const ALL: &'static [ErrorCode] = &[
ErrorCode::Auth,
ErrorCode::Billing,
ErrorCode::RateLimit,
ErrorCode::InvalidRequest,
ErrorCode::ContextLength,
ErrorCode::Timeout,
ErrorCode::Server,
ErrorCode::UnsupportedModel,
ErrorCode::UnsupportedFeature,
ErrorCode::NotConfigured,
ErrorCode::UnknownModel,
ErrorCode::AmbiguousModel,
ErrorCode::Transport,
ErrorCode::LockTimeout,
ErrorCode::StreamAssembly,
ErrorCode::CollectionLimit,
ErrorCode::Provider,
ErrorCode::AuthOperation,
];
pub fn as_str(self) -> &'static str {
match self {
ErrorCode::Auth => "auth",
ErrorCode::Billing => "billing",
ErrorCode::RateLimit => "rate_limit",
ErrorCode::InvalidRequest => "invalid_request",
ErrorCode::ContextLength => "context_length",
ErrorCode::Timeout => "timeout",
ErrorCode::Server => "server",
ErrorCode::UnsupportedModel => "unsupported_model",
ErrorCode::UnsupportedFeature => "unsupported_feature",
ErrorCode::NotConfigured => "not_configured",
ErrorCode::UnknownModel => "unknown_model",
ErrorCode::AmbiguousModel => "ambiguous_model",
ErrorCode::Transport => "transport",
ErrorCode::LockTimeout => "lock_timeout",
ErrorCode::StreamAssembly => "stream_assembly",
ErrorCode::CollectionLimit => "collection_limit",
ErrorCode::Provider => "provider",
ErrorCode::AuthOperation => "auth_operation",
}
}
pub fn parse(value: &str) -> Result<Self, ValidationError> {
ErrorCode::ALL
.iter()
.copied()
.find(|c| c.as_str() == value)
.ok_or_else(|| ValidationError::value(format!("unsupported error code: {value}")))
}
pub fn class(self) -> ErrorClass {
match self {
ErrorCode::Auth => ErrorClass::AuthError,
ErrorCode::Billing => ErrorClass::BillingError,
ErrorCode::RateLimit => ErrorClass::RateLimitError,
ErrorCode::InvalidRequest => ErrorClass::InvalidRequestError,
ErrorCode::ContextLength => ErrorClass::ContextLengthError,
ErrorCode::Timeout => ErrorClass::TimeoutError,
ErrorCode::Server => ErrorClass::ServerError,
ErrorCode::UnsupportedModel => ErrorClass::UnsupportedModelError,
ErrorCode::UnsupportedFeature => ErrorClass::UnsupportedFeatureError,
ErrorCode::NotConfigured => ErrorClass::NotConfiguredError,
ErrorCode::UnknownModel => ErrorClass::UnknownModelError,
ErrorCode::AmbiguousModel => ErrorClass::AmbiguousModelError,
ErrorCode::Transport => ErrorClass::TransportError,
ErrorCode::LockTimeout => ErrorClass::LockTimeoutError,
ErrorCode::StreamAssembly => ErrorClass::StreamAssemblyError,
ErrorCode::CollectionLimit => ErrorClass::CollectionLimitError,
ErrorCode::Provider => ErrorClass::ProviderError,
ErrorCode::AuthOperation => ErrorClass::AuthOperationError,
}
}
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorClass {
LM15Error,
TransportError,
LockTimeoutError,
StreamAssemblyError,
CollectionLimitError,
ConfigurationError,
NotConfiguredError,
UnknownModelError,
AmbiguousModelError,
CapabilityError,
UnsupportedFeatureError,
ProviderError,
AuthError,
BillingError,
RateLimitError,
InvalidRequestError,
ContextLengthError,
UnsupportedModelError,
TimeoutError,
ServerError,
AuthOperationError,
}
impl ErrorClass {
pub fn name(self) -> &'static str {
match self {
ErrorClass::LM15Error => "LM15Error",
ErrorClass::TransportError => "TransportError",
ErrorClass::LockTimeoutError => "LockTimeoutError",
ErrorClass::StreamAssemblyError => "StreamAssemblyError",
ErrorClass::CollectionLimitError => "CollectionLimitError",
ErrorClass::ConfigurationError => "ConfigurationError",
ErrorClass::NotConfiguredError => "NotConfiguredError",
ErrorClass::UnknownModelError => "UnknownModelError",
ErrorClass::AmbiguousModelError => "AmbiguousModelError",
ErrorClass::CapabilityError => "CapabilityError",
ErrorClass::UnsupportedFeatureError => "UnsupportedFeatureError",
ErrorClass::ProviderError => "ProviderError",
ErrorClass::AuthError => "AuthError",
ErrorClass::BillingError => "BillingError",
ErrorClass::RateLimitError => "RateLimitError",
ErrorClass::InvalidRequestError => "InvalidRequestError",
ErrorClass::ContextLengthError => "ContextLengthError",
ErrorClass::UnsupportedModelError => "UnsupportedModelError",
ErrorClass::TimeoutError => "TimeoutError",
ErrorClass::ServerError => "ServerError",
ErrorClass::AuthOperationError => "AuthOperationError",
}
}
pub fn parent(self) -> Option<ErrorClass> {
Some(match self {
ErrorClass::LM15Error => return None,
ErrorClass::TransportError
| ErrorClass::LockTimeoutError
| ErrorClass::StreamAssemblyError
| ErrorClass::CollectionLimitError
| ErrorClass::ConfigurationError
| ErrorClass::CapabilityError
| ErrorClass::ProviderError
| ErrorClass::AuthOperationError => ErrorClass::LM15Error,
ErrorClass::NotConfiguredError
| ErrorClass::UnknownModelError
| ErrorClass::AmbiguousModelError => ErrorClass::ConfigurationError,
ErrorClass::UnsupportedFeatureError => ErrorClass::CapabilityError,
ErrorClass::AuthError
| ErrorClass::BillingError
| ErrorClass::RateLimitError
| ErrorClass::InvalidRequestError
| ErrorClass::TimeoutError
| ErrorClass::ServerError => ErrorClass::ProviderError,
ErrorClass::ContextLengthError | ErrorClass::UnsupportedModelError => {
ErrorClass::InvalidRequestError
}
})
}
pub fn is_a(self, ancestor: ErrorClass) -> bool {
let mut current = Some(self);
while let Some(class) = current {
if class == ancestor {
return true;
}
current = class.parent();
}
false
}
pub fn code(self) -> ErrorCode {
match self {
ErrorClass::ContextLengthError => ErrorCode::ContextLength,
ErrorClass::UnsupportedModelError => ErrorCode::UnsupportedModel,
ErrorClass::AuthError => ErrorCode::Auth,
ErrorClass::BillingError => ErrorCode::Billing,
ErrorClass::RateLimitError => ErrorCode::RateLimit,
ErrorClass::InvalidRequestError => ErrorCode::InvalidRequest,
ErrorClass::TimeoutError => ErrorCode::Timeout,
ErrorClass::ServerError => ErrorCode::Server,
ErrorClass::UnsupportedFeatureError | ErrorClass::CapabilityError => {
ErrorCode::UnsupportedFeature
}
ErrorClass::NotConfiguredError | ErrorClass::ConfigurationError => {
ErrorCode::NotConfigured
}
ErrorClass::UnknownModelError => ErrorCode::UnknownModel,
ErrorClass::AmbiguousModelError => ErrorCode::AmbiguousModel,
ErrorClass::TransportError => ErrorCode::Transport,
ErrorClass::LockTimeoutError => ErrorCode::LockTimeout,
ErrorClass::StreamAssemblyError => ErrorCode::StreamAssembly,
ErrorClass::CollectionLimitError => ErrorCode::CollectionLimit,
ErrorClass::ProviderError | ErrorClass::LM15Error => ErrorCode::Provider,
ErrorClass::AuthOperationError => ErrorCode::AuthOperation,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DiagnosticHeaders(std::collections::BTreeMap<String, Vec<String>>);
impl DiagnosticHeaders {
pub fn from_headers(headers: &[(String, String)]) -> Self {
let mut out = std::collections::BTreeMap::<String, Vec<String>>::new();
for (name, value) in headers {
let name = name.to_ascii_lowercase();
if !diagnostic_header_name(&name)
|| value.is_empty()
|| value.len() > 256
|| !value.bytes().all(|b| (0x20..=0x7e).contains(&b))
{
continue;
}
let values = out.entry(name).or_default();
if values.len() < 4 {
values.push(value.clone());
}
}
Self(out)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn get(&self, name: &str) -> Option<&[String]> {
self.0.get(&name.to_ascii_lowercase()).map(Vec::as_slice)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &[String])> {
self.0
.iter()
.map(|(name, values)| (name.as_str(), values.as_slice()))
}
pub fn to_json(&self) -> Value {
Value::Object(
self.0
.iter()
.map(|(name, values)| {
(
name.clone(),
Value::Array(values.iter().cloned().map(Value::String).collect()),
)
})
.collect(),
)
}
}
pub fn diagnostic_header_name(name: &str) -> bool {
let name = name.to_ascii_lowercase();
if matches!(
name.as_str(),
"retry-after"
| "retry-after-ms"
| "x-ms-retry-after-ms"
| "x-ratelimit-type"
| "x-ratelimit-abusepenalty-active"
) {
return true;
}
for meter in ["requests", "tokens"] {
for kind in ["limit", "remaining", "reset", "renewalperiod"] {
if name == format!("x-ratelimit-{kind}-{meter}") {
return true;
}
}
}
for meter in ["requests", "tokens", "input-tokens", "output-tokens"] {
for kind in ["limit", "remaining", "reset"] {
if name == format!("anthropic-ratelimit-{meter}-{kind}") {
return true;
}
}
}
false
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ErrorMeta {
pub message: String,
pub provider: Option<String>,
pub provider_code: Option<String>,
pub status: Option<u16>,
pub request_id: Option<String>,
pub retry_after: Option<f64>,
pub rate_limit_headers: DiagnosticHeaders,
pub content_type: Option<String>,
pub body_excerpt: Option<String>,
pub credential_source: Option<crate::auth::CredentialSource>,
pub feature: Option<String>,
}
impl ErrorMeta {
pub fn http_response(&self) -> serde_json::Map<String, Value> {
let mut out = serde_json::Map::new();
if let Some(id) = self.request_id.as_ref().filter(|s| !s.is_empty()) {
out.insert("request_id".into(), Value::String(id.clone()));
}
if let Some(wait) = self.retry_after.filter(|v| v.is_finite() && *v >= 0.0) {
out.insert("retry_after".into(), Value::from(wait));
}
if !self.rate_limit_headers.is_empty() {
out.insert(
"rate_limit_headers".into(),
self.rate_limit_headers.to_json(),
);
}
out
}
pub fn apply_http_response(
&mut self,
value: &serde_json::Map<String, Value>,
) -> Result<(), ValidationError> {
let normalized =
crate::types::stream_http_response_from_json(&Value::Object(value.clone()))?;
if let Some(id) = normalized.get("request_id").and_then(Value::as_str) {
self.request_id = Some(id.into());
}
if let Some(wait) = normalized.get("retry_after").and_then(Value::as_f64) {
self.retry_after = Some(wait);
}
if let Some(headers) = normalized
.get("rate_limit_headers")
.and_then(Value::as_object)
{
let pairs = headers
.iter()
.flat_map(|(name, values)| {
values
.as_array()
.into_iter()
.flatten()
.filter_map(move |v| v.as_str().map(|v| (name.clone(), v.to_string())))
})
.collect::<Vec<_>>();
self.rate_limit_headers = DiagnosticHeaders::from_headers(&pairs);
}
Ok(())
}
pub fn new(message: impl Into<String>) -> Self {
ErrorMeta {
message: message.into(),
..Default::default()
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CollectionLimit {
pub meta: ErrorMeta,
pub limit: &'static str,
pub maximum: usize,
pub retained_bytes: usize,
pub retained_events: usize,
pub partial_events: std::sync::Arc<Vec<crate::types::LiveServerEvent>>,
pub rejected_event: Option<std::sync::Arc<crate::types::LiveServerEvent>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StreamAssembly {
pub meta: ErrorMeta,
pub partial: Option<Box<Response>>,
pub part_index: Option<u64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct UnknownModel {
pub meta: ErrorMeta,
pub model: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AmbiguousModel {
pub meta: ErrorMeta,
pub model: String,
pub providers: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LockTimeout {
pub meta: ErrorMeta,
pub path: String,
pub lock_path: String,
}
#[allow(clippy::enum_variant_names)]
#[derive(Debug, Clone, PartialEq)]
pub enum Lm15Error {
TransportError(ErrorMeta),
LockTimeoutError(LockTimeout),
StreamAssemblyError(StreamAssembly),
CollectionLimitError(CollectionLimit),
ConfigurationError(ErrorMeta),
NotConfiguredError(ErrorMeta),
UnknownModelError(UnknownModel),
AmbiguousModelError(AmbiguousModel),
CapabilityError(ErrorMeta),
UnsupportedFeatureError(ErrorMeta),
ProviderError(ErrorMeta),
AuthError(ErrorMeta),
BillingError(ErrorMeta),
RateLimitError(ErrorMeta),
InvalidRequestError(ErrorMeta),
ContextLengthError(ErrorMeta),
UnsupportedModelError(ErrorMeta),
TimeoutError(ErrorMeta),
ServerError(ErrorMeta),
AuthOperationError(Box<AuthOperation>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct AuthOperation {
pub meta: ErrorMeta,
pub reason: String,
pub stage: String,
pub commit_state: String,
pub recovery: String,
pub operation: Option<String>,
pub connection_id: Option<String>,
pub attempt_id: Option<String>,
pub method_id: Option<String>,
}
pub const AUTH_OPERATION_REASONS: &[&str] = &[
"method_unavailable",
"interaction_required",
"login_in_progress",
"login_expired",
"login_denied",
"invalid_login_state",
"connection_exists",
"connection_changed",
"login_required",
"credential_rejected",
"indeterminate",
"storage_unavailable",
"unsupported_store_version",
"attempt_unavailable",
"selection_mismatch",
];
impl AuthOperation {
pub fn error(
message: impl Into<String>,
reason: &str,
stage: &str,
commit_state: &str,
recovery: &str,
) -> Lm15Error {
Lm15Error::AuthOperationError(Box::new(AuthOperation {
meta: ErrorMeta::new(message.into()),
reason: reason.into(),
stage: stage.into(),
commit_state: commit_state.into(),
recovery: recovery.into(),
operation: None,
connection_id: None,
attempt_id: None,
method_id: None,
}))
}
}
impl Lm15Error {
pub fn auth_operation(&self) -> Option<&AuthOperation> {
match self {
Lm15Error::AuthOperationError(op) => Some(op),
_ => None,
}
}
pub fn reason(&self) -> Option<&str> {
self.auth_operation().map(|op| op.reason.as_str())
}
}
impl Lm15Error {
pub fn of_class(class: ErrorClass, meta: ErrorMeta) -> Lm15Error {
match class {
ErrorClass::TransportError => Lm15Error::TransportError(meta),
ErrorClass::LockTimeoutError => Lm15Error::LockTimeoutError(LockTimeout {
meta,
path: String::new(),
lock_path: String::new(),
}),
ErrorClass::StreamAssemblyError => Lm15Error::StreamAssemblyError(StreamAssembly {
meta,
partial: None,
part_index: None,
}),
ErrorClass::CollectionLimitError => Lm15Error::CollectionLimitError(CollectionLimit {
meta,
limit: "events",
maximum: 0,
retained_bytes: 0,
retained_events: 0,
partial_events: std::sync::Arc::new(Vec::new()),
rejected_event: None,
}),
ErrorClass::ConfigurationError => Lm15Error::ConfigurationError(meta),
ErrorClass::NotConfiguredError => Lm15Error::NotConfiguredError(meta),
ErrorClass::UnknownModelError => Lm15Error::UnknownModelError(UnknownModel {
meta,
model: String::new(),
}),
ErrorClass::AmbiguousModelError => Lm15Error::AmbiguousModelError(AmbiguousModel {
meta,
model: String::new(),
providers: Vec::new(),
}),
ErrorClass::CapabilityError => Lm15Error::CapabilityError(meta),
ErrorClass::UnsupportedFeatureError => Lm15Error::UnsupportedFeatureError(meta),
ErrorClass::ProviderError | ErrorClass::LM15Error => Lm15Error::ProviderError(meta),
ErrorClass::AuthError => Lm15Error::AuthError(meta),
ErrorClass::BillingError => Lm15Error::BillingError(meta),
ErrorClass::RateLimitError => Lm15Error::RateLimitError(meta),
ErrorClass::InvalidRequestError => Lm15Error::InvalidRequestError(meta),
ErrorClass::ContextLengthError => Lm15Error::ContextLengthError(meta),
ErrorClass::UnsupportedModelError => Lm15Error::UnsupportedModelError(meta),
ErrorClass::TimeoutError => Lm15Error::TimeoutError(meta),
ErrorClass::ServerError => Lm15Error::ServerError(meta),
ErrorClass::AuthOperationError => {
Lm15Error::AuthOperationError(Box::new(AuthOperation {
meta,
reason: "indeterminate".into(),
stage: "resolution".into(),
commit_state: "unknown".into(),
recovery: "none".into(),
operation: None,
connection_id: None,
attempt_id: None,
method_id: None,
}))
}
}
}
pub fn class(&self) -> ErrorClass {
match self {
Lm15Error::TransportError(_) => ErrorClass::TransportError,
Lm15Error::LockTimeoutError(_) => ErrorClass::LockTimeoutError,
Lm15Error::StreamAssemblyError(_) => ErrorClass::StreamAssemblyError,
Lm15Error::CollectionLimitError(_) => ErrorClass::CollectionLimitError,
Lm15Error::ConfigurationError(_) => ErrorClass::ConfigurationError,
Lm15Error::NotConfiguredError(_) => ErrorClass::NotConfiguredError,
Lm15Error::UnknownModelError(_) => ErrorClass::UnknownModelError,
Lm15Error::AmbiguousModelError(_) => ErrorClass::AmbiguousModelError,
Lm15Error::CapabilityError(_) => ErrorClass::CapabilityError,
Lm15Error::UnsupportedFeatureError(_) => ErrorClass::UnsupportedFeatureError,
Lm15Error::ProviderError(_) => ErrorClass::ProviderError,
Lm15Error::AuthError(_) => ErrorClass::AuthError,
Lm15Error::BillingError(_) => ErrorClass::BillingError,
Lm15Error::RateLimitError(_) => ErrorClass::RateLimitError,
Lm15Error::InvalidRequestError(_) => ErrorClass::InvalidRequestError,
Lm15Error::ContextLengthError(_) => ErrorClass::ContextLengthError,
Lm15Error::UnsupportedModelError(_) => ErrorClass::UnsupportedModelError,
Lm15Error::TimeoutError(_) => ErrorClass::TimeoutError,
Lm15Error::ServerError(_) => ErrorClass::ServerError,
Lm15Error::AuthOperationError(_) => ErrorClass::AuthOperationError,
}
}
pub fn class_name(&self) -> &'static str {
self.class().name()
}
pub fn code(&self) -> ErrorCode {
self.class().code()
}
pub fn is_a(&self, ancestor: ErrorClass) -> bool {
self.class().is_a(ancestor)
}
pub fn is_retryable(&self) -> bool {
matches!(
self,
Lm15Error::RateLimitError(_)
| Lm15Error::TimeoutError(_)
| Lm15Error::ServerError(_)
| Lm15Error::TransportError(_)
| Lm15Error::LockTimeoutError(_)
)
}
pub fn meta(&self) -> &ErrorMeta {
match self {
Lm15Error::StreamAssemblyError(s) => &s.meta,
Lm15Error::CollectionLimitError(limit) => &limit.meta,
Lm15Error::LockTimeoutError(l) => &l.meta,
Lm15Error::UnknownModelError(u) => &u.meta,
Lm15Error::AmbiguousModelError(a) => &a.meta,
Lm15Error::AuthOperationError(op) => &op.meta,
Lm15Error::TransportError(m)
| Lm15Error::ConfigurationError(m)
| Lm15Error::NotConfiguredError(m)
| Lm15Error::CapabilityError(m)
| Lm15Error::UnsupportedFeatureError(m)
| Lm15Error::ProviderError(m)
| Lm15Error::AuthError(m)
| Lm15Error::BillingError(m)
| Lm15Error::RateLimitError(m)
| Lm15Error::InvalidRequestError(m)
| Lm15Error::ContextLengthError(m)
| Lm15Error::UnsupportedModelError(m)
| Lm15Error::TimeoutError(m)
| Lm15Error::ServerError(m) => m,
}
}
pub fn meta_mut(&mut self) -> &mut ErrorMeta {
match self {
Lm15Error::StreamAssemblyError(s) => &mut s.meta,
Lm15Error::CollectionLimitError(limit) => &mut limit.meta,
Lm15Error::LockTimeoutError(l) => &mut l.meta,
Lm15Error::UnknownModelError(u) => &mut u.meta,
Lm15Error::AmbiguousModelError(a) => &mut a.meta,
Lm15Error::AuthOperationError(op) => &mut op.meta,
Lm15Error::TransportError(m)
| Lm15Error::ConfigurationError(m)
| Lm15Error::NotConfiguredError(m)
| Lm15Error::CapabilityError(m)
| Lm15Error::UnsupportedFeatureError(m)
| Lm15Error::ProviderError(m)
| Lm15Error::AuthError(m)
| Lm15Error::BillingError(m)
| Lm15Error::RateLimitError(m)
| Lm15Error::InvalidRequestError(m)
| Lm15Error::ContextLengthError(m)
| Lm15Error::UnsupportedModelError(m)
| Lm15Error::TimeoutError(m)
| Lm15Error::ServerError(m) => m,
}
}
pub fn message(&self) -> &str {
&self.meta().message
}
pub fn provider(&self) -> Option<&str> {
self.meta().provider.as_deref()
}
pub fn provider_code(&self) -> Option<&str> {
self.meta().provider_code.as_deref()
}
pub fn status(&self) -> Option<u16> {
self.meta().status
}
pub fn request_id(&self) -> Option<&str> {
self.meta().request_id.as_deref()
}
pub fn retry_after(&self) -> Option<f64> {
self.meta().retry_after
}
pub fn feature(&self) -> Option<&str> {
self.meta().feature.as_deref()
}
pub fn rate_limit_headers(&self) -> &DiagnosticHeaders {
&self.meta().rate_limit_headers
}
pub fn content_type(&self) -> Option<&str> {
self.meta().content_type.as_deref()
}
pub fn body_excerpt(&self) -> Option<&str> {
self.meta().body_excerpt.as_deref()
}
pub fn credential_source(&self) -> Option<&crate::auth::CredentialSource> {
self.meta().credential_source.as_ref()
}
pub fn partial(&self) -> Option<&Response> {
match self {
Lm15Error::StreamAssemblyError(s) => s.partial.as_deref(),
_ => None,
}
}
pub fn model(&self) -> Option<&str> {
match self {
Lm15Error::UnknownModelError(u) => Some(&u.model),
Lm15Error::AmbiguousModelError(a) => Some(&a.model),
_ => None,
}
}
pub fn lock_paths(&self) -> Option<(&str, &str)> {
match self {
Lm15Error::LockTimeoutError(l) => Some((&l.path, &l.lock_path)),
_ => None,
}
}
pub fn candidate_providers(&self) -> Option<&[String]> {
match self {
Lm15Error::AmbiguousModelError(a) => Some(&a.providers),
_ => None,
}
}
pub fn stream_assembly(
message: impl Into<String>,
partial: Option<Response>,
part_index: Option<u64>,
) -> Lm15Error {
Lm15Error::StreamAssemblyError(StreamAssembly {
meta: ErrorMeta::new(message),
partial: partial.map(Box::new),
part_index,
})
}
pub fn unknown_model(message: impl Into<String>, model: impl Into<String>) -> Lm15Error {
Lm15Error::UnknownModelError(UnknownModel {
meta: ErrorMeta::new(message),
model: model.into(),
})
}
pub fn ambiguous_model(
message: impl Into<String>,
model: impl Into<String>,
providers: Vec<String>,
) -> Lm15Error {
Lm15Error::AmbiguousModelError(AmbiguousModel {
meta: ErrorMeta::new(message),
model: model.into(),
providers,
})
}
pub fn unsupported_feature(message: impl Into<String>) -> Lm15Error {
Lm15Error::UnsupportedFeatureError(ErrorMeta::new(message))
}
pub fn not_configured(message: impl Into<String>) -> Lm15Error {
Lm15Error::NotConfiguredError(ErrorMeta::new(message))
}
}
impl fmt::Display for Lm15Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let meta = self.meta();
let base = if meta.message.is_empty() {
self.code().as_str()
} else {
meta.message.as_str()
};
let (base, guidance) = if self.is_a(ErrorClass::ProviderError) {
base.split_once("\n\n To fix:")
.map(|(text, hint)| (text, Some(hint)))
.unwrap_or((base, None))
} else {
(base, None)
};
write!(f, "{}: {base}", self.class_name())?;
if self.is_a(ErrorClass::ProviderError) {
let mut context: Vec<String> = Vec::new();
if let Some(provider) = &meta.provider {
context.push(provider.clone());
}
if let Some(status) = meta.status {
context.push(format!("HTTP {status}"));
}
if let Some(request_id) = &meta.request_id {
context.push(format!("request {request_id}"));
}
if !context.is_empty() {
write!(f, " ({})", context.join(", "))?;
}
if self.is_a(ErrorClass::AuthError) {
if let Some(source) = &meta.credential_source {
write!(f, "\nCredential came from: {}", source.describe())?;
}
}
if !meta.rate_limit_headers.is_empty() || meta.retry_after.is_some() {
let mut details = Vec::new();
if let Some(wait) = meta.retry_after.filter(|v| v.is_finite() && *v >= 0.0) {
details.push(format!("retry_after={wait}s"));
}
for (name, values) in meta.rate_limit_headers.iter() {
details.push(format!("{name}={values:?}"));
}
let text = details.join("; ");
let preview: String = text.chars().take(2048).collect();
write!(f, "\nHTTP diagnostics (raw/advisory): {preview}")?;
if text.chars().count() > 2048 {
f.write_str(" … [truncated]")?;
}
}
}
if let Some(hint) = guidance {
write!(f, "\n\n To fix:{hint}")?;
}
Ok(())
}
}
impl std::error::Error for Lm15Error {}
pub fn map_http_error(status: u16, meta: ErrorMeta) -> Lm15Error {
let meta = ErrorMeta {
status: Some(status),
..meta
};
let class = match status {
401 | 403 => ErrorClass::AuthError,
402 => ErrorClass::BillingError,
408 | 504 => ErrorClass::TimeoutError,
429 => ErrorClass::RateLimitError,
400 | 404 | 409 | 413 | 422 => ErrorClass::InvalidRequestError,
500..=599 => ErrorClass::ServerError,
_ => ErrorClass::ProviderError,
};
Lm15Error::of_class(class, meta)
}
pub fn normalize_error(
provider: &str,
status: u16,
body_text: &str,
) -> Result<Lm15Error, ValidationError> {
let definition = lookup(provider)
.ok_or_else(|| ValidationError::value(format!("unknown provider: {provider}")))?;
let ctx = Context {
provider: definition.id,
};
Ok(match definition.dialect {
DialectId::OpenaiResponses => {
normalize_openai_shape(&ctx, status, body_text, definition.id == "openai-codex")
}
DialectId::OpenaiChat => {
if definition.id == "xai" {
normalize_openai_shape(&ctx, status, &xai_refold(body_text), false)
} else {
normalize_openai_shape(&ctx, status, body_text, false)
}
}
DialectId::Anthropic => normalize_anthropic(&ctx, status, body_text),
DialectId::Gemini => normalize_gemini(&ctx, status, body_text),
DialectId::Typesafe => normalize_typesafe(&ctx, status, body_text),
})
}
struct Context {
provider: &'static str,
}
impl Context {
fn meta(
&self,
message: String,
provider_code: Option<String>,
request_id: Option<String>,
) -> ErrorMeta {
ErrorMeta {
message,
provider: Some(self.provider.to_string()),
provider_code: provider_code.filter(|c| !c.is_empty()),
status: None,
request_id: request_id.filter(|r| !r.is_empty()),
..Default::default()
}
}
fn error(
&self,
class: ErrorClass,
status: u16,
message: String,
provider_code: Option<String>,
request_id: Option<String>,
) -> Lm15Error {
let meta = ErrorMeta {
status: Some(status),
..self.meta(message, provider_code, request_id)
};
Lm15Error::of_class(class, meta)
}
fn http(
&self,
status: u16,
message: String,
provider_code: Option<String>,
request_id: Option<String>,
) -> Lm15Error {
map_http_error(status, self.meta(message, provider_code, request_id))
}
}
fn scalar_text(value: Option<&Value>) -> String {
match value {
None | Some(Value::Null) => String::new(),
Some(Value::String(s)) => s.clone(),
Some(Value::Bool(true)) => "True".to_string(),
Some(Value::Bool(false)) => String::new(),
Some(Value::Number(n)) => {
if n.as_f64() == Some(0.0) {
String::new()
} else {
n.to_string()
}
}
Some(other) => other.to_string(),
}
}
fn message_text(value: Option<&Value>) -> String {
match value {
None | Some(Value::Null) => String::new(),
Some(Value::String(s)) => s.clone(),
Some(other) => other.to_string(),
}
}
fn fallback_message(status: u16, body: &str) -> String {
let trimmed = body.trim();
if trimmed.is_empty() {
return format!("HTTP {status}");
}
trimmed.chars().take(500).collect()
}
const MODEL_ERROR_MARKERS: &[&str] = &[
"not found",
"does not exist",
"not exist",
"not supported",
"unsupported",
"not available",
"unknown",
];
pub struct ModelNotFoundForm {
pub code: &'static str,
pub prefix: &'static str,
pub contains: &'static str,
pub suffix: &'static str,
}
const fn form(
code: &'static str,
prefix: &'static str,
contains: &'static str,
suffix: &'static str,
) -> ModelNotFoundForm {
ModelNotFoundForm {
code,
prefix,
contains,
suffix,
}
}
pub const MODEL_NOT_FOUND_FORMS: &[ModelNotFoundForm] = &[
form("not_found_error", "model: ", "", ""), form(
"invalid_request_error",
"",
"The supported API model names are ",
"",
), form("1211", "", "", ""), form("1214", "modelCode: ", "", ""), form("400", "", "", " is not a valid model ID"), form("invalid-argument", "Model not found: ", "", ""), form(
"validation_error",
"",
"The provided model identifier is invalid",
"",
), form(
"invalid_request_error",
"Deployment ",
"",
" doesn't exist or isn't accessible.",
), ];
pub fn is_pinned_model_not_found(provider_code: &str, message: &str) -> bool {
!provider_code.is_empty()
&& MODEL_NOT_FOUND_FORMS.iter().any(|f| {
f.code == provider_code
&& message.starts_with(f.prefix)
&& message.contains(f.contains)
&& message.ends_with(f.suffix)
})
}
pub struct AuthFailedForm {
pub code: &'static str,
pub reason: &'static str,
pub prefix: &'static str,
pub contains: &'static str,
pub suffix: &'static str,
}
pub const AUTH_FAILED_FORMS: &[AuthFailedForm] = &[
AuthFailedForm {
code: "INVALID_ARGUMENT",
reason: "API_KEY_INVALID",
prefix: "",
contains: "",
suffix: "",
},
AuthFailedForm {
code: "invalid-argument",
reason: "",
prefix: "Incorrect API key provided",
contains: "",
suffix: "",
},
];
pub fn google_error_reasons(error: Option<&Value>) -> Vec<String> {
let Some(Value::Array(details)) = error.and_then(|e| e.get("details")) else {
return Vec::new();
};
details
.iter()
.filter(|d| {
d.get("@type")
.and_then(Value::as_str)
.is_some_and(|t| t.ends_with("google.rpc.ErrorInfo"))
})
.filter_map(|d| d.get("reason").and_then(Value::as_str).map(str::to_owned))
.collect()
}
pub fn is_pinned_auth_failure(provider_code: &str, message: &str, reasons: &[String]) -> bool {
!provider_code.is_empty()
&& AUTH_FAILED_FORMS.iter().any(|f| {
f.code == provider_code
&& (f.reason.is_empty() || reasons.iter().any(|r| r == f.reason))
&& message.starts_with(f.prefix)
&& message.contains(f.contains)
&& message.ends_with(f.suffix)
})
}
pub(crate) fn is_model_error(text: &str) -> bool {
let lowered = text.to_lowercase();
lowered.contains("model") && MODEL_ERROR_MARKERS.iter().any(|m| lowered.contains(m))
}
const OPENAI_MODEL_ERROR_CODES: &[&str] = &[
"model_not_found",
"model_not_available",
"unsupported_model",
"DeploymentNotFound",
];
const OPENAI_BILLING_CODES: &[&str] = &["insufficient_quota", "1113"];
const OPENAI_BILLING_TYPES: &[&str] = &["insufficient_quota", "exceeded_current_quota_error"];
fn normalize_openai_shape(ctx: &Context, status: u16, body: &str, codex: bool) -> Lm15Error {
if codex {
if let Some(err) = normalize_detail_error(ctx, status, body) {
return err;
}
}
let Ok(data) = serde_json::from_str::<Value>(body) else {
return ctx.http(status, fallback_message(status, body), None, None);
};
let err = data.get("error");
let (mut msg, code, err_type) = match err {
Some(Value::Object(e)) => (
message_text(e.get("message")),
scalar_text(e.get("code")),
scalar_text(e.get("type")),
),
Some(other) if data.is_object() => {
(message_text(Some(other)), String::new(), String::new())
}
_ => (String::new(), String::new(), String::new()),
};
let provider_code = if !code.is_empty() {
Some(code.clone())
} else if !err_type.is_empty() {
Some(err_type.clone())
} else {
None
};
if is_pinned_auth_failure(provider_code.as_deref().unwrap_or(""), &msg, &[]) {
return ctx.error(ErrorClass::AuthError, status, msg, provider_code, None);
}
if code == "context_length_exceeded" {
return ctx.error(
ErrorClass::ContextLengthError,
status,
msg,
provider_code,
None,
);
}
if OPENAI_MODEL_ERROR_CODES.contains(&code.as_str())
|| (status == 404 && is_model_error(&format!("{msg} {code} {err_type}")))
|| is_pinned_model_not_found(provider_code.as_deref().unwrap_or(""), &msg)
{
return ctx.error(
ErrorClass::UnsupportedModelError,
status,
msg,
provider_code,
None,
);
}
if OPENAI_BILLING_CODES.contains(&code.as_str())
|| OPENAI_BILLING_TYPES.contains(&err_type.as_str())
{
return ctx.error(ErrorClass::BillingError, status, msg, provider_code, None);
}
if code == "invalid_api_key" || err_type == "authentication_error" {
return ctx.error(ErrorClass::AuthError, status, msg, provider_code, None);
}
if matches!(
code.as_str(),
"rate_limit_exceeded" | "no_capacity" | "too_many_requests"
) || err_type == "rate_limit_error"
{
return ctx.error(ErrorClass::RateLimitError, status, msg, provider_code, None);
}
if !code.is_empty() && !msg.contains(&code) {
msg = format!("{msg} ({code})");
}
ctx.http(status, msg, provider_code, None)
}
fn normalize_detail_error(ctx: &Context, status: u16, body: &str) -> Option<Lm15Error> {
let data: Value = serde_json::from_str(body).ok()?;
let detail = data.get("detail")?.as_str()?.trim();
if detail.is_empty() {
return None;
}
if is_model_error(detail) {
return Some(ctx.error(
ErrorClass::UnsupportedModelError,
status,
detail.to_string(),
None,
None,
));
}
Some(ctx.http(status, detail.to_string(), None, None))
}
fn xai_refold(body: &str) -> String {
if let Ok(Value::Object(data)) = serde_json::from_str::<Value>(body) {
if let Some(Value::String(message)) = data.get("error") {
let mut inner = serde_json::Map::new();
inner.insert("message".to_string(), Value::String(message.clone()));
inner.insert(
"code".to_string(),
data.get("code").cloned().unwrap_or(Value::Null),
);
let mut outer = serde_json::Map::new();
outer.insert("error".to_string(), Value::Object(inner));
return Value::Object(outer).to_string();
}
}
body.to_string()
}
pub(crate) const ANTHROPIC_ERROR_TYPES: &[(&str, ErrorClass)] = &[
("authentication_error", ErrorClass::AuthError),
("permission_error", ErrorClass::AuthError),
("billing_error", ErrorClass::BillingError),
("rate_limit_error", ErrorClass::RateLimitError),
("request_too_large", ErrorClass::InvalidRequestError),
("not_found_error", ErrorClass::InvalidRequestError),
("resource_not_found_error", ErrorClass::InvalidRequestError),
("DeploymentNotFound", ErrorClass::UnsupportedModelError),
("invalid_authentication_error", ErrorClass::AuthError),
("invalid_request_error", ErrorClass::InvalidRequestError),
("api_error", ErrorClass::ServerError),
("overloaded_error", ErrorClass::ServerError),
("timeout_error", ErrorClass::TimeoutError),
];
pub(crate) fn anthropic_is_context_length(msg: &str) -> bool {
let l = msg.to_lowercase();
l.contains("prompt is too long")
|| l.contains("too many tokens")
|| l.contains("context window")
|| l.contains("context length")
|| (l.contains("token") && (l.contains("limit") || l.contains("exceed")))
}
fn normalize_anthropic(ctx: &Context, status: u16, body: &str) -> Lm15Error {
let data = match serde_json::from_str::<Value>(body) {
Ok(Value::Object(data)) => data,
Ok(_) => return ctx.http(status, String::new(), None, None),
Err(_) => return ctx.http(status, fallback_message(status, body), None, None),
};
let (mut msg, err_type) = match data.get("error") {
Some(Value::Object(e)) => (message_text(e.get("message")), {
let t = scalar_text(e.get("type"));
if t.is_empty() {
scalar_text(e.get("code"))
} else {
t
}
}),
Some(Value::String(s)) => (s.clone(), String::new()),
_ => (message_text(data.get("message")), {
let t = scalar_text(data.get("type"));
if t.is_empty() {
scalar_text(data.get("code"))
} else {
t
}
}),
};
let request_id = Some(scalar_text(data.get("request_id")));
let provider_code = Some(err_type.clone());
if ctx.provider == "claude-code" {
msg = crate::auth::policy::claude_code_version_guidance(&msg);
}
if is_pinned_auth_failure(&err_type, &msg, &[]) {
return ctx.error(
ErrorClass::AuthError,
status,
msg,
provider_code,
request_id,
);
}
if anthropic_is_context_length(&msg) {
return ctx.error(
ErrorClass::ContextLengthError,
status,
msg,
provider_code,
request_id,
);
}
if err_type == "DeploymentNotFound"
|| ((err_type == "not_found_error" || err_type == "resource_not_found_error")
&& is_model_error(&msg))
|| is_pinned_model_not_found(&err_type, &msg)
{
return ctx.error(
ErrorClass::UnsupportedModelError,
status,
msg,
provider_code,
request_id,
);
}
if let Some((_, class)) = ANTHROPIC_ERROR_TYPES.iter().find(|(t, _)| *t == err_type) {
return ctx.error(*class, status, msg, provider_code, request_id);
}
if !err_type.is_empty() && !msg.contains(&err_type) {
msg = format!("{msg} ({err_type})");
}
ctx.http(status, msg, provider_code, request_id)
}
pub(crate) const GEMINI_ERROR_STATUSES: &[(&str, ErrorClass)] = &[
("INVALID_ARGUMENT", ErrorClass::InvalidRequestError),
("FAILED_PRECONDITION", ErrorClass::BillingError),
("PERMISSION_DENIED", ErrorClass::AuthError),
("UNAUTHENTICATED", ErrorClass::AuthError),
("NOT_FOUND", ErrorClass::InvalidRequestError),
("RESOURCE_EXHAUSTED", ErrorClass::RateLimitError),
("INTERNAL", ErrorClass::ServerError),
("UNAVAILABLE", ErrorClass::ServerError),
("DEADLINE_EXCEEDED", ErrorClass::TimeoutError),
];
pub(crate) fn gemini_is_context_length(msg: &str) -> bool {
let l = msg.to_lowercase();
(l.contains("token") && (l.contains("limit") || l.contains("exceed")))
|| l.contains("too long")
|| l.contains("context is too long")
|| l.contains("context length")
}
fn normalize_gemini(ctx: &Context, status: u16, body: &str) -> Lm15Error {
let Ok(data) = serde_json::from_str::<Value>(body) else {
return ctx.http(status, fallback_message(status, body), None, None);
};
let (mut msg, err_status) = match data.get("error") {
Some(Value::Object(e)) => (message_text(e.get("message")), scalar_text(e.get("status"))),
Some(other) if data.is_object() => (message_text(Some(other)), String::new()),
_ => (String::new(), String::new()),
};
let provider_code = Some(err_status.clone());
if is_pinned_auth_failure(&err_status, &msg, &google_error_reasons(data.get("error"))) {
return ctx.error(ErrorClass::AuthError, status, msg, provider_code, None);
}
if gemini_is_context_length(&msg) {
return ctx.error(
ErrorClass::ContextLengthError,
status,
msg,
provider_code,
None,
);
}
if err_status == "NOT_FOUND" && is_model_error(&msg) {
return ctx.error(
ErrorClass::UnsupportedModelError,
status,
msg,
provider_code,
None,
);
}
if let Some((_, class)) = GEMINI_ERROR_STATUSES.iter().find(|(s, _)| *s == err_status) {
return ctx.error(*class, status, msg, provider_code, None);
}
if !err_status.is_empty() && !msg.contains(&err_status) {
msg = format!("{msg} ({err_status})");
}
ctx.http(status, msg, provider_code, None)
}
fn normalize_typesafe(ctx: &Context, status: u16, body: &str) -> Lm15Error {
let mut message = fallback_message(status, body);
let mut code = None;
let data = serde_json::from_str::<Value>(body).ok();
match data.as_ref().and_then(|v| v.get("detail")) {
Some(Value::Object(detail)) => {
code = detail
.get("error_type")
.and_then(Value::as_str)
.map(str::to_string);
if let Some(text) = detail.get("message").and_then(Value::as_str) {
message = text.into();
}
}
Some(Value::Array(details)) => {
if let Some(first) = details.first().and_then(Value::as_object) {
let loc = first
.get("loc")
.and_then(Value::as_array)
.map(|parts| {
parts
.iter()
.filter(|v| v.as_str() != Some("body"))
.map(|v| {
v.as_str()
.map(str::to_string)
.unwrap_or_else(|| v.to_string())
})
.collect::<Vec<_>>()
.join(".")
})
.unwrap_or_default();
let text = first
.get("msg")
.and_then(Value::as_str)
.unwrap_or("validation error");
message = if loc.is_empty() {
text.into()
} else {
format!("{loc}: {text}")
};
}
}
_ => {}
}
let class = if status == 401 || code.as_deref() == Some("authentication_error") {
Some(ErrorClass::AuthError)
} else if status == 400 && message.to_lowercase().contains("unknown model") {
Some(ErrorClass::UnsupportedModelError)
} else {
None
};
match class {
Some(class) => ctx.error(class, status, message, code, None),
None => ctx.http(status, message, code, None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hierarchy_shape_matches_vocabularies() {
use ErrorClass::*;
assert_eq!(ContextLengthError.parent(), Some(InvalidRequestError));
assert_eq!(UnsupportedModelError.parent(), Some(InvalidRequestError));
assert_eq!(InvalidRequestError.parent(), Some(ProviderError));
assert_eq!(NotConfiguredError.parent(), Some(ConfigurationError));
assert_eq!(UnknownModelError.parent(), Some(ConfigurationError));
assert_eq!(AmbiguousModelError.parent(), Some(ConfigurationError));
assert!(!UnknownModelError.is_a(NotConfiguredError));
assert_eq!(UnknownModelError.code(), ErrorCode::UnknownModel);
assert_eq!(AmbiguousModelError.code(), ErrorCode::AmbiguousModel);
assert_eq!(UnsupportedFeatureError.parent(), Some(CapabilityError));
assert_eq!(TransportError.parent(), Some(LM15Error));
assert_eq!(LockTimeoutError.parent(), Some(LM15Error));
assert!(!LockTimeoutError.is_a(ProviderError));
assert!(!LockTimeoutError.is_a(AuthError));
assert_eq!(LockTimeoutError.code(), ErrorCode::LockTimeout);
assert_eq!(StreamAssemblyError.parent(), Some(LM15Error));
assert_eq!(LM15Error.parent(), None);
assert!(ContextLengthError.is_a(ProviderError));
assert!(!TransportError.is_a(ProviderError));
for class in [
TransportError,
LockTimeoutError,
StreamAssemblyError,
ConfigurationError,
NotConfiguredError,
UnknownModelError,
AmbiguousModelError,
CapabilityError,
UnsupportedFeatureError,
ProviderError,
AuthError,
BillingError,
RateLimitError,
InvalidRequestError,
ContextLengthError,
UnsupportedModelError,
TimeoutError,
ServerError,
] {
assert!(class.is_a(LM15Error));
}
}
#[test]
fn codes_are_bidirectional_with_leaf_classes() {
for code in ErrorCode::ALL {
assert_eq!(code.class().code(), *code);
assert_eq!(ErrorCode::parse(code.as_str()).unwrap(), *code);
}
assert!(ErrorCode::parse("boom").is_err());
assert_eq!(ErrorClass::LM15Error.code(), ErrorCode::Provider);
}
#[test]
fn retryable_set() {
let meta = ErrorMeta::new("x");
assert!(Lm15Error::RateLimitError(meta.clone()).is_retryable());
assert!(Lm15Error::TimeoutError(meta.clone()).is_retryable());
assert!(Lm15Error::ServerError(meta.clone()).is_retryable());
assert!(Lm15Error::TransportError(meta.clone()).is_retryable());
assert!(Lm15Error::of_class(ErrorClass::LockTimeoutError, meta.clone()).is_retryable());
assert!(!Lm15Error::AuthError(meta.clone()).is_retryable());
assert!(!Lm15Error::InvalidRequestError(meta).is_retryable());
}
#[test]
fn http_mapping() {
let classes: Vec<ErrorClass> = [401u16, 402, 408, 429, 404, 503, 418]
.iter()
.map(|s| map_http_error(*s, ErrorMeta::new("m")).class())
.collect();
assert_eq!(
classes,
vec![
ErrorClass::AuthError,
ErrorClass::BillingError,
ErrorClass::TimeoutError,
ErrorClass::RateLimitError,
ErrorClass::InvalidRequestError,
ErrorClass::ServerError,
ErrorClass::ProviderError,
]
);
}
#[test]
fn unknown_provider_is_a_value_error() {
assert!(normalize_error("nope", 500, "{}").is_err());
let err = normalize_error("openai_chat", 500, "not json").unwrap();
assert_eq!(err.class(), ErrorClass::ServerError);
assert_eq!(err.message(), "not json");
let empty = normalize_error("gemini", 502, " ").unwrap();
assert_eq!(empty.message(), "HTTP 502");
}
}