use std::time::Duration;
use ferrin_message::Message;
use ferrin_spec::ApprovalId;
use ferrin_spec::FinishReason;
use ferrin_spec::ProviderId;
use ferrin_spec::ResponseMetadata;
use ferrin_spec::ToolCallId;
use ferrin_spec::ToolName;
use ferrin_spec::Usage;
use ferrin_spec::error::ModelKind;
use ferrin_spec::error::ProviderError;
use ferrin_spec::language_model::StreamError;
use http::StatusCode;
use serde::Deserialize;
use serde::Serialize;
use url::Url;
use crate::timeout::TimeoutScope;
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error(transparent)]
Provider(Box<ProviderError>),
#[error("retries exhausted after {attempts} attempts ({reason})")]
Retry {
reason: RetryReason,
attempts: u32,
errors: Vec<ProviderError>,
},
#[error("timeout ({scope}) after {elapsed:?}")]
Timeout {
scope: TimeoutScope,
elapsed: Duration,
},
#[error("operation cancelled")]
Cancelled,
#[error("invalid argument `{argument}`: {message}")]
InvalidArgument {
argument: String,
message: String,
},
#[error("invalid prompt: {message}")]
InvalidPrompt {
message: String,
},
#[error("message conversion failed: {message}")]
MessageConversion {
message: String,
original_message: Box<Message>,
},
#[error("download failed for {}", .0.url)]
Download(#[source] Box<DownloadDetails>),
#[error("invalid data content: {message}")]
InvalidDataContent {
message: String,
#[source]
cause: Option<BoxError>,
},
#[error("no such tool `{tool_name}`")]
NoSuchTool {
tool_name: ToolName,
available_tools: Vec<ToolName>,
},
#[error("invalid input for tool `{}`", .0.tool_name)]
InvalidToolInput(#[source] Box<InvalidToolInputDetails>),
#[error("tool call repair failed")]
ToolCallRepair {
original: Box<Error>,
#[source]
cause: BoxError,
},
#[error("tool choice violated: expected `{expected}`, got `{actual}`")]
ToolChoiceViolation {
expected: ToolName,
actual: ToolName,
},
#[error("tool call `{tool_call_id}` not found for approval `{approval_id}`")]
ToolCallNotFoundForApproval {
tool_call_id: ToolCallId,
approval_id: ApprovalId,
},
#[error("tool choice not satisfied: {}", .expected.as_ref().map_or_else(|| "a tool call was required".to_owned(), |name| format!("expected a call to `{name}`")))]
ToolChoiceNotSatisfied {
expected: Option<ToolName>,
},
#[error("invalid tool approval `{approval_id}`: {message}")]
InvalidToolApproval {
approval_id: ApprovalId,
message: String,
},
#[error("no structured output generated: {}", .0.message)]
NoObjectGenerated(#[source] Box<NoObjectGeneratedDetails>),
#[error("no output generated")]
NoOutputGenerated,
#[error("no image generated")]
NoImageGenerated {
responses: Vec<ResponseMetadata>,
},
#[error("no speech generated")]
NoSpeechGenerated {
responses: Vec<ResponseMetadata>,
},
#[error("no transcript generated")]
NoTranscriptGenerated {
responses: Vec<ResponseMetadata>,
},
#[error("no video generated")]
NoVideoGenerated {
responses: Vec<ResponseMetadata>,
},
#[error("no such provider `{}`", .0.provider_id)]
NoSuchProvider(Box<NoSuchProviderDetails>),
#[error("no default registry configured for model id `{model_id}`")]
NoDefaultRegistry {
model_id: String,
},
#[error("invalid stream part: {message}")]
InvalidStreamPart {
message: String,
},
#[error("stream error: {}", .0.message)]
Stream(Box<StreamError>),
#[error(transparent)]
Mcp(BoxError),
#[error(transparent)]
Other(BoxError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RetryReason {
MaxRetriesExceeded,
ErrorNotRetryable,
Abort,
}
impl std::fmt::Display for RetryReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::MaxRetriesExceeded => "max retries exceeded",
Self::ErrorNotRetryable => "error not retryable",
Self::Abort => "aborted",
})
}
}
#[derive(Debug, thiserror::Error)]
#[error("download of {url} failed")]
pub struct DownloadDetails {
pub url: Url,
pub status_code: Option<StatusCode>,
#[source]
pub cause: Option<BoxError>,
}
#[derive(Debug, thiserror::Error)]
#[error("invalid input for tool `{tool_name}`: {tool_input}")]
pub struct InvalidToolInputDetails {
pub tool_name: ToolName,
pub tool_input: String,
#[source]
pub cause: BoxError,
}
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
pub struct NoObjectGeneratedDetails {
pub message: String,
pub text: Option<String>,
pub response: ResponseMetadata,
pub usage: Usage,
pub finish_reason: FinishReason,
#[source]
pub cause: Option<BoxError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoSuchProviderDetails {
pub provider_id: ProviderId,
pub available_providers: Vec<ProviderId>,
pub model_id: String,
pub model_kind: ModelKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum ErrorKind {
Provider,
Retry,
Timeout,
Cancelled,
InvalidInput,
Tool,
Output,
NotFound,
Mcp,
Other,
}
impl ErrorKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Provider => "provider",
Self::Retry => "retry",
Self::Timeout => "timeout",
Self::Cancelled => "cancelled",
Self::InvalidInput => "invalid-input",
Self::Tool => "tool",
Self::Output => "output",
Self::NotFound => "not-found",
Self::Mcp => "mcp",
Self::Other => "other",
}
}
}
impl std::fmt::Display for ErrorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl Error {
#[must_use]
pub fn invalid_argument(argument: impl Into<String>, message: impl Into<String>) -> Self {
Self::InvalidArgument {
argument: argument.into(),
message: message.into(),
}
}
#[must_use]
pub fn invalid_prompt(message: impl Into<String>) -> Self {
Self::InvalidPrompt {
message: message.into(),
}
}
#[must_use]
pub fn invalid_data_content(message: impl Into<String>, cause: Option<BoxError>) -> Self {
Self::InvalidDataContent {
message: message.into(),
cause,
}
}
#[must_use]
pub fn download(url: Url, status_code: Option<StatusCode>, cause: Option<BoxError>) -> Self {
Self::Download(Box::new(DownloadDetails {
url,
status_code,
cause,
}))
}
#[must_use]
pub fn no_such_tool(tool_name: impl Into<ToolName>, available_tools: Vec<ToolName>) -> Self {
Self::NoSuchTool {
tool_name: tool_name.into(),
available_tools,
}
}
#[must_use]
pub fn invalid_tool_input(
tool_name: impl Into<ToolName>,
tool_input: impl Into<String>,
cause: impl Into<BoxError>,
) -> Self {
Self::InvalidToolInput(Box::new(InvalidToolInputDetails {
tool_name: tool_name.into(),
tool_input: tool_input.into(),
cause: cause.into(),
}))
}
#[must_use]
pub fn no_object_generated(details: NoObjectGeneratedDetails) -> Self {
Self::NoObjectGenerated(Box::new(details))
}
#[must_use]
pub fn no_such_provider(details: NoSuchProviderDetails) -> Self {
Self::NoSuchProvider(Box::new(details))
}
#[must_use]
pub fn invalid_stream_part(message: impl Into<String>) -> Self {
Self::InvalidStreamPart {
message: message.into(),
}
}
#[must_use]
pub fn stream(error: StreamError) -> Self {
Self::Stream(Box::new(error))
}
#[must_use]
pub fn other(error: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::Other(Box::new(error))
}
#[must_use]
pub fn message(message: impl Into<String>) -> Self {
Self::Other(message.into().into())
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::Provider(error) => error.is_retryable(),
Self::Retry {
reason: RetryReason::MaxRetriesExceeded,
errors,
..
} => errors.last().is_some_and(ProviderError::is_retryable),
Self::Stream(error) => error.is_retryable.unwrap_or(false),
_ => false,
}
}
#[must_use]
pub fn is_cancelled(&self) -> bool {
matches!(
self,
Self::Cancelled
| Self::Retry {
reason: RetryReason::Abort,
..
}
)
}
#[must_use]
pub fn status_code(&self) -> Option<StatusCode> {
match self {
Self::Provider(error) => error.status_code(),
Self::Retry { errors, .. } => errors.last().and_then(ProviderError::status_code),
Self::Download(details) => details.status_code,
Self::Stream(error) => error
.status_code
.and_then(|code| StatusCode::from_u16(code).ok()),
_ => None,
}
}
#[must_use]
pub fn as_provider(&self) -> Option<&ProviderError> {
match self {
Self::Provider(error) => Some(error),
_ => None,
}
}
#[must_use]
pub fn kind(&self) -> ErrorKind {
match self {
Self::Provider(_) | Self::Stream(_) => ErrorKind::Provider,
Self::Retry { .. } => ErrorKind::Retry,
Self::Timeout { .. } => ErrorKind::Timeout,
Self::Cancelled => ErrorKind::Cancelled,
Self::InvalidArgument { .. }
| Self::InvalidPrompt { .. }
| Self::MessageConversion { .. }
| Self::Download(_)
| Self::InvalidDataContent { .. }
| Self::InvalidStreamPart { .. } => ErrorKind::InvalidInput,
Self::NoSuchTool { .. }
| Self::InvalidToolInput(_)
| Self::ToolCallRepair { .. }
| Self::ToolChoiceViolation { .. }
| Self::ToolChoiceNotSatisfied { .. }
| Self::ToolCallNotFoundForApproval { .. }
| Self::InvalidToolApproval { .. } => ErrorKind::Tool,
Self::NoObjectGenerated(_)
| Self::NoOutputGenerated
| Self::NoImageGenerated { .. }
| Self::NoSpeechGenerated { .. }
| Self::NoTranscriptGenerated { .. }
| Self::NoVideoGenerated { .. } => ErrorKind::Output,
Self::NoSuchProvider(_) | Self::NoDefaultRegistry { .. } => ErrorKind::NotFound,
Self::Mcp(_) => ErrorKind::Mcp,
Self::Other(_) => ErrorKind::Other,
}
}
}
impl From<ProviderError> for Error {
fn from(error: ProviderError) -> Self {
match error {
ProviderError::Cancelled => Self::Cancelled,
other => Self::Provider(Box::new(other)),
}
}
}
impl From<ferrin_spec::error::NoSuchModelError> for Error {
fn from(error: ferrin_spec::error::NoSuchModelError) -> Self {
Self::Provider(Box::new(ProviderError::NoSuchModel(Box::new(error))))
}
}
impl From<ferrin_spec::error::InvalidArgumentError> for Error {
fn from(error: ferrin_spec::error::InvalidArgumentError) -> Self {
Self::InvalidArgument {
argument: error.argument,
message: error.message,
}
}
}
impl From<ferrin_tool::ToolError> for Error {
fn from(error: ferrin_tool::ToolError) -> Self {
match error {
ferrin_tool::ToolError::Cancelled => Self::Cancelled,
other => Self::Other(Box::new(other)),
}
}
}