diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e03fed7f..6fc3bc48a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- Errors keep their cause, as the AI SDK's `cause`. `JsonParse`, + `NoOutputGenerated`, `InvalidResponseData`, `InvalidArgument`, + `InvalidPrompt` and `Other` are `{ message, cause }` (build them with + `AiMuxError::json_parse(..)`, `invalid_argument(..)`, ...), `ApiCallError` + has `cause` (`.caused_by(..)`), and `InvalidToolInput.cause` is the parse or + validation error itself instead of its text. A cause is a nested + `AiMuxError` or a `SourceError` chain of an OS, TLS or parser error; it is + what `source()` returns and the payload's `cause` in every binding (Node + `error.cause`, Python `__cause__`, Java/Kotlin `getCause()`, Go `Unwrap()`, + Swift/Flutter `cause`, C++ `aimux::NativeError::json()`). `FetchError` + carries a `SourceError` without the request URL. Messages are unchanged. + - aimux's public JSON equals the AI SDK's JSON. Struct fields serialize camelCase (`toolCallId`, `providerOptions`, `inputTokens`); unions carry the AI SDK's tag and strings (`{"type":"text-delta"}`, `"tool-call"`, diff --git a/aimux-core/src/decision_model.rs b/aimux-core/src/decision_model.rs index 3c50c9418..73045a672 100644 --- a/aimux-core/src/decision_model.rs +++ b/aimux-core/src/decision_model.rs @@ -424,7 +424,7 @@ pub struct DecideResult { } fn invalid_input(parameter: &str, message: &str) -> AiMuxError { - AiMuxError::InvalidArgument(format!( + AiMuxError::invalid_argument(format!( "Invalid argument for parameter {parameter}: {message}" )) } @@ -471,7 +471,7 @@ pub fn validate_decision_input(questions: &DecisionQuestions) -> Result<(), AiMu const TOLERANCE: f64 = 1e-6; fn invalid_answer(message: String) -> AiMuxError { - AiMuxError::InvalidResponseData(message) + AiMuxError::invalid_response_data(message) } fn is_probability(value: f64) -> bool { diff --git a/aimux-core/src/decision_replay.rs b/aimux-core/src/decision_replay.rs index 7f1a64dcb..c403f22e6 100644 --- a/aimux-core/src/decision_replay.rs +++ b/aimux-core/src/decision_replay.rs @@ -7,6 +7,7 @@ use crate::decision_model::{ DecideOptions, DecideResult, DecisionCallOptions, DecisionModel, DecisionQuestionType, DecisionResult, DecisionUsage, decide, }; +use crate::error::ErrorCause; use crate::recording::{OutcomeStatus, Recording, RecordingOperation}; /// Appended to a replay error when recordings written by aimux 0.5 or @@ -52,8 +53,8 @@ impl MockDecisionReplayModel { && rec.provider.provider == provider && rec.provider.model_id == model_id }) { - return Err(AiMuxError::InvalidArgument( - "decision replay: no matching provider/model recording".into(), + return Err(AiMuxError::invalid_argument( + "decision replay: no matching provider/model recording", )); } Ok(Self { @@ -67,21 +68,24 @@ impl MockDecisionReplayModel { /// # Errors /// Returns read/parse errors or an error if the file contains no decisions. pub fn from_jsonl(path: impl AsRef) -> Result { - let content = std::fs::read_to_string(path) - .map_err(|e| AiMuxError::InvalidArgument(format!("decision replay: {e}")))?; + let content = std::fs::read_to_string(path).map_err(|e| AiMuxError::InvalidArgument { + message: format!("decision replay: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; let recordings: Vec = content .lines() .filter(|line| !line.trim().is_empty()) .map(|line| { - serde_json::from_str(line).map_err(|e| AiMuxError::JsonParse(e.to_string())) + serde_json::from_str(line).map_err(|e| AiMuxError::JsonParse { + message: e.to_string(), + cause: Some(ErrorCause::source(&e)), + }) }) .collect::>()?; let first = recordings .iter() .find(|rec| rec.input.operation == RecordingOperation::Decision) - .ok_or_else(|| { - AiMuxError::InvalidArgument("decision replay: no decisions in file".into()) - })?; + .ok_or_else(|| AiMuxError::invalid_argument("decision replay: no decisions in file"))?; Self::new( first.provider.provider.clone(), first.provider.model_id.clone(), @@ -114,8 +118,12 @@ impl DecisionModel for MockDecisionReplayModel { DecisionQuestionType::ALL } async fn do_decide(&self, options: &DecisionCallOptions) -> Result { - let requested = - key(&serde_json::to_value(options).map_err(|e| AiMuxError::JsonParse(e.to_string()))?); + let requested = key( + &serde_json::to_value(options).map_err(|e| AiMuxError::JsonParse { + message: e.to_string(), + cause: Some(ErrorCause::source(&e)), + })?, + ); let rec = self .recordings .iter() @@ -143,7 +151,7 @@ impl DecisionModel for MockDecisionReplayModel { "; {older} recording(s) of this model were {OLDER_RECORDING} and never match" )); } - AiMuxError::InvalidArgument(message) + AiMuxError::invalid_argument(message) })?; if rec.outcome.status != OutcomeStatus::Success { return Err(rec @@ -152,7 +160,7 @@ impl DecisionModel for MockDecisionReplayModel { .clone() .and_then(|v| serde_json::from_value(v).ok()) .unwrap_or_else(|| { - AiMuxError::InvalidResponseData( + AiMuxError::invalid_response_data( rec.outcome .error .clone() @@ -162,9 +170,12 @@ impl DecisionModel for MockDecisionReplayModel { } let recorded: DecideResult = serde_json::from_value(rec.outcome.decision_result.clone().ok_or_else(|| { - AiMuxError::InvalidResponseData("decision replay: missing recorded result".into()) + AiMuxError::invalid_response_data("decision replay: missing recorded result") })?) - .map_err(|e| AiMuxError::InvalidResponseData(format!("decision replay result: {e}")))?; + .map_err(|e| AiMuxError::InvalidResponseData { + message: format!("decision replay result: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; Ok(DecisionResult { answers: recorded.answers, rounding: recorded.rounding, @@ -188,8 +199,8 @@ pub async fn replay_decision_with_model( model: &dyn DecisionModel, ) -> Result { if recording.input.operation != RecordingOperation::Decision { - return Err(AiMuxError::InvalidArgument( - "decision replay requires a decision recording".into(), + return Err(AiMuxError::invalid_argument( + "decision replay requires a decision recording", )); } let unread = crate::replay::snake_case_option_keys(&recording.input.options); @@ -199,7 +210,7 @@ pub async fn replay_decision_with_model( } else { String::new() }; - return Err(AiMuxError::JsonParse(format!( + return Err(AiMuxError::json_parse(format!( "decision replay: {older}this version reads the options camelCase and would \ ignore {}", unread.join(", ") @@ -208,11 +219,15 @@ pub async fn replay_decision_with_model( let options = crate::replay::readable_options(&recording.input.options); let options: DecideOptions = serde_json::from_value(options).map_err(|e| { if recording.provider.provider_id.is_empty() { - AiMuxError::JsonParse(format!( - "decision replay: the recording was {OLDER_RECORDING}: {e}" - )) + AiMuxError::JsonParse { + message: format!("decision replay: the recording was {OLDER_RECORDING}: {e}"), + cause: Some(ErrorCause::source(&e)), + } } else { - AiMuxError::JsonParse(e.to_string()) + AiMuxError::JsonParse { + message: e.to_string(), + cause: Some(ErrorCause::source(&e)), + } } })?; decide(model, options).await diff --git a/aimux-core/src/download.rs b/aimux-core/src/download.rs index 17699b879..dbd52d5a4 100644 --- a/aimux-core/src/download.rs +++ b/aimux-core/src/download.rs @@ -30,6 +30,7 @@ use futures::future::BoxFuture; use crate::abort_signal::AbortSignal; use crate::download_guard::{validate_download_target, validate_download_url}; use crate::error::AiMuxError; +use crate::error::ErrorCause; use crate::shared::{FileBytes, GeneratedFileData}; const DEFAULT_MAX_DOWNLOAD_SIZE: usize = 2 * 1024 * 1024 * 1024; @@ -161,7 +162,7 @@ impl DownloadFunction { let requested = downloads.len(); let files = (self.0)(downloads).await?; if files.len() != requested { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "the download function returned {} results for {requested} URLs", files.len() ))); @@ -246,10 +247,10 @@ async fn download_inner( let addresses = validate_download_target(¤t, None).await?; let host = parsed .host_str() - .ok_or_else(|| AiMuxError::InvalidArgument("download URL has no host".into()))?; + .ok_or_else(|| AiMuxError::invalid_argument("download URL has no host"))?; let port = parsed .port_or_known_default() - .ok_or_else(|| AiMuxError::InvalidArgument("download URL has no known port".into()))?; + .ok_or_else(|| AiMuxError::invalid_argument("download URL has no known port"))?; let addresses: Vec<_> = addresses .into_iter() .map(|address| SocketAddr::new(address, port)) @@ -276,7 +277,7 @@ async fn download_inner( && let Some(location) = response.headers().get(reqwest::header::LOCATION) { if redirects == MAX_DOWNLOAD_REDIRECTS { - return Err(AiMuxError::Other("download exceeded 10 redirects".into())); + return Err(AiMuxError::other("download exceeded 10 redirects")); } let location = location.to_str().map_err(download_error)?; current = parsed.join(location).map_err(download_error)?.to_string(); @@ -284,7 +285,7 @@ async fn download_inner( continue; } if !status.is_success() { - return Err(AiMuxError::Other(format!( + return Err(AiMuxError::other(format!( "download failed with HTTP status {status}" ))); } @@ -313,12 +314,15 @@ async fn download_inner( unreachable!("download loop returns a response or redirect error") } -fn download_error(error: impl std::fmt::Display) -> AiMuxError { - AiMuxError::Other(format!("download failed: {error}")) +fn download_error(error: impl std::error::Error + 'static) -> AiMuxError { + AiMuxError::Other { + message: format!("download failed: {error}"), + cause: Some(ErrorCause::source(&error)), + } } fn size_error(max_bytes: usize) -> AiMuxError { - AiMuxError::Other(format!( + AiMuxError::other(format!( "download exceeded maximum size of {max_bytes} bytes" )) } @@ -392,7 +396,7 @@ mod tests { .await .unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidArgument(message) + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message == "the download function returned 1 results for 2 URLs"), "{error:?}" ); @@ -407,7 +411,7 @@ mod tests { .await .unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidArgument(message) + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message == "download URL targets a non-public address: 127.0.0.1"), "{error:?}" ); diff --git a/aimux-core/src/download_guard.rs b/aimux-core/src/download_guard.rs index 4c3f02915..f41c3c696 100644 --- a/aimux-core/src/download_guard.rs +++ b/aimux-core/src/download_guard.rs @@ -11,6 +11,7 @@ use std::net::IpAddr; use crate::error::AiMuxError; +use crate::error::ErrorCause; /// Compare scheme, host, and effective port. Unparseable inputs are never /// same-origin. Public so providers can build credential allowlists on top @@ -61,31 +62,33 @@ fn host_ip_literal(host: &str) -> Option { /// Callers that resolve DNS afterwards rely on this having rejected every /// non-public literal, so keep this the single literal-check site. pub fn validate_download_url(url: &str) -> Result { - let parsed = url::Url::parse(url) - .map_err(|error| AiMuxError::InvalidArgument(format!("invalid download URL: {error}")))?; + let parsed = url::Url::parse(url).map_err(|error| AiMuxError::InvalidArgument { + message: format!("invalid download URL: {error}"), + cause: Some(ErrorCause::source(&error)), + })?; if !matches!(parsed.scheme(), "http" | "https") { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "download URL must use HTTP or HTTPS: {}", redacted(&parsed) ))); } let host = parsed .host_str() - .ok_or_else(|| AiMuxError::InvalidArgument("download URL has no host".into()))?; + .ok_or_else(|| AiMuxError::invalid_argument("download URL has no host"))?; let normalized_host = host.to_ascii_lowercase(); let normalized_host = normalized_host.trim_end_matches('.'); if normalized_host == "localhost" || normalized_host.ends_with(".local") || normalized_host.ends_with(".localhost") { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "download URL targets a disallowed hostname: {normalized_host}" ))); } if let Some(address) = host_ip_literal(normalized_host) && !is_public_download_address(address) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "download URL targets a non-public address: {address}" ))); } @@ -108,18 +111,19 @@ pub async fn validate_download_target( let parsed = validate_download_url(url)?; let host = parsed .host_str() - .ok_or_else(|| AiMuxError::InvalidArgument("download URL has no host".into()))?; + .ok_or_else(|| AiMuxError::invalid_argument("download URL has no host"))?; if let Some(address) = host_ip_literal(host) { // validate_download_url above already rejected non-public literals. return Ok(vec![address]); } let port = parsed .port_or_known_default() - .ok_or_else(|| AiMuxError::InvalidArgument("download URL has no known port".into()))?; + .ok_or_else(|| AiMuxError::invalid_argument("download URL has no known port"))?; let addresses: Vec<_> = tokio::net::lookup_host((host, port)) .await - .map_err(|error| { - AiMuxError::InvalidArgument(format!("download URL host could not be resolved: {error}")) + .map_err(|error| AiMuxError::InvalidArgument { + message: format!("download URL host could not be resolved: {error}"), + cause: Some(ErrorCause::source(&error)), })? .map(|address| address.ip()) .collect(); @@ -131,14 +135,14 @@ fn validate_resolved_download_addresses( addresses: Vec, ) -> Result, AiMuxError> { if addresses.is_empty() { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "download URL host did not resolve to an address: {host}" ))); } let mut validated = Vec::with_capacity(addresses.len()); for address in addresses { if !is_public_download_address(address) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "download URL resolves to a non-public address: {address}" ))); } @@ -369,7 +373,7 @@ mod tests { let empty = validate_resolved_download_addresses("empty.example", Vec::new()) .expect_err("an empty DNS answer must fail closed"); assert!( - matches!(empty, AiMuxError::InvalidArgument(ref message) if message.contains("did not resolve")) + matches!(empty, AiMuxError::InvalidArgument { ref message, .. } if message.contains("did not resolve")) ); let mixed = validate_resolved_download_addresses( @@ -378,7 +382,7 @@ mod tests { ) .expect_err("one private answer must reject the entire DNS result"); assert!( - matches!(mixed, AiMuxError::InvalidArgument(ref message) if message.contains("non-public")) + matches!(mixed, AiMuxError::InvalidArgument { ref message, .. } if message.contains("non-public")) ); let all = validate_resolved_download_addresses( diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs index be73d03ed..b5dcb40d9 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -128,6 +128,12 @@ pub struct ApiCallError { /// no status to compute from) sets it to `true` explicitly. #[serde(default)] pub is_retryable: bool, + /// The error this failure was built from (`APICallError.cause`): the + /// transport error with its source chain, the body parse error, or the + /// aimux error a response handler returned. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cause: Option, } impl ApiCallError { @@ -147,6 +153,16 @@ impl ApiCallError { response_headers: None, data: None, is_retryable: false, + cause: None, + } + } + + /// Set the error this failure was built from. + #[must_use] + pub fn caused_by(self, cause: impl Into) -> Self { + Self { + cause: Some(cause.into()), + ..self } } } @@ -172,7 +188,7 @@ pub struct RetryError { } static EMPTY_RETRY_HISTORY_ERROR: LazyLock = - LazyLock::new(|| AiMuxError::Other("Retry failed without recorded errors".into())); + LazyLock::new(|| AiMuxError::other("Retry failed without recorded errors")); impl RetryError { /// The final attempt error, or a stable invalid-history error when a @@ -226,6 +242,19 @@ impl std::fmt::Display for RetryError { } } +/// The last attempt, like the AI SDK's `RetryError.lastError`. +impl std::error::Error for RetryError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.errors.last().map(|error| error as _) + } +} + +impl std::error::Error for ApiCallError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.cause.as_ref().map(|cause| cause as _) + } +} + impl std::fmt::Display for ApiCallError { /// `HTTP {status}: {message}` when a status is known (just `HTTP {status}` /// if the response had no body), else `{message}`. `provider_code` and @@ -239,30 +268,172 @@ impl std::fmt::Display for ApiCallError { } } +/// An error from outside aimux (a transport, parser or I/O error), kept as +/// its message and the messages of its own sources, so it can be cloned, +/// serialized and sent across the bindings with the error built from it. +/// +/// The JSON form is a JavaScript `Error`'s own `{ message, cause }`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-export", ts(export))] +pub struct SourceError { + /// The error's `Display` text. + pub message: String, + /// The error's own `source()`, recursively. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cause: Option>, +} + +impl SourceError { + /// Record `error` and its whole `source()` chain. + #[must_use] + pub fn new(error: &(dyn std::error::Error + 'static)) -> Self { + Self { + message: error.to_string(), + cause: error.source().map(|source| Box::new(Self::new(source))), + } + } + + /// A failure described by `message`, caused by `source` and its chain. + #[must_use] + pub fn with_cause( + message: impl Into, + source: &(dyn std::error::Error + 'static), + ) -> Self { + Self { + message: message.into(), + cause: Some(Box::new(Self::new(source))), + } + } +} + +/// A failure detected in place, with nothing underneath it. +impl From for SourceError { + fn from(message: String) -> Self { + Self { + message, + cause: None, + } + } +} + +impl From<&str> for SourceError { + fn from(message: &str) -> Self { + message.to_owned().into() + } +} + +impl std::fmt::Display for SourceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for SourceError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.cause.as_deref().map(|cause| cause as _) + } +} + +/// The error another error was built from: the AI SDK's `cause`. +/// +/// An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an +/// `APICallError`; any other error is a [`SourceError`]. The JSON form is the +/// nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(untagged)] +#[cfg_attr(feature = "ts-export", ts(export))] +pub enum ErrorCause { + AiMux(Box), + Source(SourceError), +} + +impl ErrorCause { + /// The cause for `error`: an aimux error is kept whole, any other error + /// is recorded with its whole `source()` chain. + #[must_use] + pub fn source(error: &(dyn std::error::Error + 'static)) -> Self { + match error.downcast_ref::() { + Some(error) => error.clone().into(), + None => Self::Source(SourceError::new(error)), + } + } + + fn as_error(&self) -> &(dyn std::error::Error + 'static) { + match self { + Self::AiMux(error) => error.as_ref(), + Self::Source(error) => error, + } + } +} + +impl From for ErrorCause { + fn from(error: AiMuxError) -> Self { + Self::AiMux(Box::new(error)) + } +} + +impl From for ErrorCause { + fn from(error: SourceError) -> Self { + Self::Source(error) + } +} + +impl std::fmt::Display for ErrorCause { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(self.as_error(), f) + } +} + +impl std::error::Error for ErrorCause { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.as_error().source() + } +} + /// Unified error type for all aimux operations. /// /// Variants are cut by *what the caller does about it*, not by where the /// failure came from. `ApiCallError` is boxed only to keep the Rust enum /// compact; serde and every binding still observe the same object shape. +/// +/// A variant has a `cause` exactly where its AI SDK class takes one; it is +/// what `source()` returns. `Retry` reports its +/// last attempt and `ToolCallRepair` its `cause` the same way. #[derive(Debug, Clone, Serialize, Deserialize, TS, Error)] #[serde(from = "AiMuxErrorWire", into = "AiMuxErrorWire")] #[ts(as = "AiMuxErrorWire")] #[cfg_attr(feature = "ts-export", ts(export))] pub enum AiMuxError { #[error("API call error: {0}")] - ApiCall(Box), + ApiCall(#[source] Box), - #[error("{0}")] + #[error(transparent)] Retry(RetryError), - #[error("JSON parse error: {0}")] - JsonParse(String), + #[error("JSON parse error: {message}")] + JsonParse { + message: String, + #[source] + cause: Option, + }, - #[error("{0}")] - NoOutputGenerated(String), + #[error("{message}")] + NoOutputGenerated { + message: String, + #[source] + cause: Option, + }, - #[error("invalid response data: {0}")] - InvalidResponseData(String), + /// Malformed response data: the AI SDK's `InvalidResponseDataError`, and + /// its `TypeValidationError` for a well-formed body of the wrong shape. + #[error("invalid response data: {message}")] + InvalidResponseData { + message: String, + #[source] + cause: Option, + }, #[error("Tool call \"{tool_call_id}\" not found for approval request \"{approval_id}\".")] ToolCallNotFoundForApproval { @@ -278,36 +449,44 @@ pub enum AiMuxError { #[error("Model tried to call unavailable tool '{tool_name}'. {}", no_such_tool_availability(.available_tools))] NoSuchTool { tool_name: String, - #[ts(optional)] - #[serde(default, skip_serializing_if = "Option::is_none")] available_tools: Option>, /// Original argument text, when supplied by the provider. Absent in /// older serialized errors and errors constructed without a call. - #[ts(optional)] - #[serde(default, skip_serializing_if = "Option::is_none")] tool_input: Option, }, /// A tool call could not be parsed or did not satisfy its input schema. - #[error("Invalid input for tool {tool_name}: {cause}")] + /// `cause` is the parse error (`AI_JSONParseError`) or the schema + /// validation failure. + #[error("Invalid input for tool {tool_name}: {}", cause_message(.cause))] InvalidToolInput { tool_name: String, tool_input: String, - cause: String, + #[source] + cause: ErrorCause, }, /// The repair callback failed or returned a call that was still invalid. #[error("Error repairing tool call: {cause}")] ToolCallRepair { original_error: Box, + #[source] cause: Box, }, - #[error("invalid argument: {0}")] - InvalidArgument(String), + #[error("invalid argument: {message}")] + InvalidArgument { + message: String, + #[source] + cause: Option, + }, - #[error("invalid prompt: {0}")] - InvalidPrompt(String), + #[error("invalid prompt: {message}")] + InvalidPrompt { + message: String, + #[source] + cause: Option, + }, /// No API key was passed and the fallback environment variable is unset /// (the AI SDK's `LoadAPIKeyError`). Pre-HTTP: no request was made. @@ -370,7 +549,6 @@ pub enum AiMuxError { model_id: String, /// What kind of model was requested (`"languageModel"`, /// `"imageModel"`, …), the AI SDK's `modelType`. - #[serde(default)] model_type: String, }, @@ -394,8 +572,84 @@ pub enum AiMuxError { #[error("{0}")] Aborted(String), - #[error("{0}")] - Other(String), + /// Any other failure: the AI SDK's base `AISDKError`. + #[error("{message}")] + Other { + message: String, + #[source] + cause: Option, + }, +} + +/// The AI SDK's `getErrorMessage(cause)`: an error's own message, without the +/// variant prefix [`AiMuxError`]'s `Display` adds. +fn cause_message(cause: &ErrorCause) -> String { + match cause { + ErrorCause::AiMux(error) => match error.as_ref() { + AiMuxError::JsonParse { message, .. } + | AiMuxError::InvalidResponseData { message, .. } + | AiMuxError::InvalidArgument { message, .. } + | AiMuxError::InvalidPrompt { message, .. } => message.clone(), + other => other.to_string(), + }, + ErrorCause::Source(error) => error.message.clone(), + } +} + +impl AiMuxError { + /// A [`JsonParse`](Self::JsonParse) error with no cause. + #[must_use] + pub fn json_parse(message: impl Into) -> Self { + Self::JsonParse { + message: message.into(), + cause: None, + } + } + + /// A [`NoOutputGenerated`](Self::NoOutputGenerated) error with no cause. + #[must_use] + pub fn no_output_generated(message: impl Into) -> Self { + Self::NoOutputGenerated { + message: message.into(), + cause: None, + } + } + + /// An [`InvalidResponseData`](Self::InvalidResponseData) error with no cause. + #[must_use] + pub fn invalid_response_data(message: impl Into) -> Self { + Self::InvalidResponseData { + message: message.into(), + cause: None, + } + } + + /// An [`InvalidArgument`](Self::InvalidArgument) error with no cause. + #[must_use] + pub fn invalid_argument(message: impl Into) -> Self { + Self::InvalidArgument { + message: message.into(), + cause: None, + } + } + + /// An [`InvalidPrompt`](Self::InvalidPrompt) error with no cause. + #[must_use] + pub fn invalid_prompt(message: impl Into) -> Self { + Self::InvalidPrompt { + message: message.into(), + cause: None, + } + } + + /// An [`Other`](Self::Other) error with no cause. + #[must_use] + pub fn other(message: impl Into) -> Self { + Self::Other { + message: message.into(), + cause: None, + } + } } /// The JSON form of [`AiMuxError`]: internally tagged by `name`, with the AI @@ -409,11 +663,26 @@ pub enum AiMuxErrorWire { #[serde(rename = "AI_RetryError")] Retry(RetryError), #[serde(rename = "AI_JSONParseError")] - JsonParse { message: String }, + JsonParse { + message: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, #[serde(rename = "AI_NoOutputGeneratedError")] - NoOutputGenerated { message: String }, + NoOutputGenerated { + message: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, #[serde(rename = "AI_InvalidResponseDataError")] - InvalidResponseData { message: String }, + InvalidResponseData { + message: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, #[serde(rename = "AI_ToolCallNotFoundForApprovalError")] ToolCallNotFoundForApproval { tool_call_id: String, @@ -433,7 +702,7 @@ pub enum AiMuxErrorWire { InvalidToolInput { tool_name: String, tool_input: String, - cause: String, + cause: ErrorCause, }, #[serde(rename = "AI_ToolCallRepairError")] ToolCallRepair { @@ -441,9 +710,19 @@ pub enum AiMuxErrorWire { cause: Box, }, #[serde(rename = "AI_InvalidArgumentError")] - InvalidArgument { message: String }, + InvalidArgument { + message: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, #[serde(rename = "AI_InvalidPromptError")] - InvalidPrompt { message: String }, + InvalidPrompt { + message: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, #[serde(rename = "AI_LoadAPIKeyError")] LoadApiKey { env_var: String, @@ -482,7 +761,12 @@ pub enum AiMuxErrorWire { #[serde(rename = "AbortError")] Aborted { message: String }, #[serde(rename = "AI_Error")] - Other { message: String }, + Other { + message: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, } /// Maps each `AiMuxError` variant to its `AiMuxErrorWire` twin and back: @@ -517,17 +801,22 @@ macro_rules! wire_variants { wire_variants! { newtype: ApiCall, Retry, ToolChoiceViolation; - message: JsonParse, NoOutputGenerated, InvalidResponseData, InvalidArgument, - InvalidPrompt, TokenExpired, UnsupportedFunctionality, Timeout, Aborted, Other; - fields: ToolCallNotFoundForApproval { tool_call_id, approval_id }, + message: TokenExpired, UnsupportedFunctionality, Timeout, Aborted; + fields: JsonParse { message, cause }, + NoOutputGenerated { message, cause }, + InvalidResponseData { message, cause }, + ToolCallNotFoundForApproval { tool_call_id, approval_id }, NoSuchTool { tool_name, available_tools, tool_input }, InvalidToolInput { tool_name, tool_input, cause }, ToolCallRepair { original_error, cause }, + InvalidArgument { message, cause }, + InvalidPrompt { message, cause }, LoadApiKey { env_var, description }, LoadSetting { env_var, name }, NoSuchModel { model_id, model_type }, NoSuchProvider { provider_id, model_id, model_type, available_providers }, - MissingToolResults { tool_call_ids } + MissingToolResults { tool_call_ids }, + Other { message, cause } } /// `". Did you mean 'a', 'b'?"` for the names in `candidates` closest to @@ -650,9 +939,13 @@ fn no_such_tool_availability(available_tools: &Option>) -> String { impl From for AiMuxError { fn from(e: serde_json::Error) -> Self { use serde_json::error::Category; + let message = e.to_string(); + let cause = Some(ErrorCause::source(&e)); match e.classify() { - Category::Data => AiMuxError::InvalidResponseData(e.to_string()), - Category::Syntax | Category::Eof | Category::Io => AiMuxError::JsonParse(e.to_string()), + Category::Data => AiMuxError::InvalidResponseData { message, cause }, + Category::Syntax | Category::Eof | Category::Io => { + AiMuxError::JsonParse { message, cause } + } } } } @@ -684,7 +977,10 @@ impl AiMuxError { /// these errors and continue polling for later, independently framed data. #[must_use] pub fn is_recoverable_stream_error(&self) -> bool { - matches!(self, Self::JsonParse(_) | Self::InvalidResponseData(_)) + matches!( + self, + Self::JsonParse { .. } | Self::InvalidResponseData { .. } + ) } /// Convert caller cancellation into the public error model. @@ -866,8 +1162,8 @@ mod tests { AiMuxError::Timeout("HTTP 408: total timeout after 1000ms".into()).status_code(), None ); - assert_eq!(AiMuxError::JsonParse("HTTP 400".into()).status_code(), None); - assert_eq!(AiMuxError::Other("HTTP 400".into()).status_code(), None); + assert_eq!(AiMuxError::json_parse("HTTP 400").status_code(), None); + assert_eq!(AiMuxError::other("HTTP 400").status_code(), None); } /// A timeout is a spent caller-side time budget, not a transient fault — @@ -883,7 +1179,7 @@ mod tests { })) .is_retryable() ); - assert!(!AiMuxError::JsonParse("parse".into()).is_retryable()); + assert!(!AiMuxError::json_parse("parse").is_retryable()); } #[test] @@ -951,4 +1247,98 @@ mod tests { assert_eq!(back.retry_after_hint(), Some(7)); assert_eq!(back.status_code(), Some(429)); } + + fn chain(error: &(dyn std::error::Error + 'static)) -> Vec { + std::iter::successors(Some(error), |error| error.source()) + .map(ToString::to_string) + .collect() + } + + /// A transport chain survives as `cause`: `source()` walks it in Rust and + /// the wire form carries the same chain to every binding. + #[test] + fn api_call_cause_is_its_source_chain_and_round_trips() { + let os = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "Connection refused"); + let transport = SourceError::with_cause("tcp connect error", &os); + let err = AiMuxError::ApiCall(Box::new( + api_error("Cannot connect to API: tcp connect error").caused_by(transport), + )); + assert_eq!( + chain(&err), + [ + "API call error: Cannot connect to API: tcp connect error", + "Cannot connect to API: tcp connect error", + "tcp connect error", + "Connection refused", + ] + ); + let json = serde_json::to_value(&err).unwrap(); + assert_eq!( + json["cause"], + serde_json::json!({ + "message": "tcp connect error", + "cause": { "message": "Connection refused" }, + }) + ); + let back: AiMuxError = serde_json::from_value(json).unwrap(); + assert_eq!(chain(&back), chain(&err)); + } + + #[test] + fn no_cause_stays_off_the_wire() { + let json = serde_json::to_value(AiMuxError::invalid_argument("bad")).unwrap(); + assert_eq!( + json, + serde_json::json!({ "name": "AI_InvalidArgumentError", "message": "bad" }) + ); + let back: AiMuxError = serde_json::from_value(json).unwrap(); + assert!(std::error::Error::source(&back).is_none()); + } + + /// An aimux cause keeps its variant; anything else becomes a `SourceError`. + #[test] + fn nested_aimux_cause_keeps_its_name() { + let inner = AiMuxError::json_parse("Unexpected end of JSON input"); + let err = AiMuxError::InvalidResponseData { + message: "bad body".into(), + cause: Some(ErrorCause::source(&inner)), + }; + assert!(matches!( + &err, + AiMuxError::InvalidResponseData { cause: Some(ErrorCause::AiMux(cause)), .. } + if matches!(cause.as_ref(), AiMuxError::JsonParse { .. }) + )); + let json = serde_json::to_value(&err).unwrap(); + assert_eq!(json["cause"]["name"], "AI_JSONParseError"); + let back: AiMuxError = serde_json::from_value(json).unwrap(); + assert_eq!(chain(&back), chain(&err)); + } + + #[test] + fn serde_error_is_its_own_cause() { + let parse = serde_json::from_str::("{").unwrap_err(); + let err = AiMuxError::from(parse); + let AiMuxError::JsonParse { message, cause } = &err else { + panic!("{err:?}") + }; + assert_eq!( + cause.as_ref().map(ToString::to_string).as_ref(), + Some(message) + ); + } + + /// Like the AI SDK's `RetryError.lastError`. + #[test] + fn retry_source_is_the_last_attempt() { + let err = AiMuxError::Retry(RetryError { + reason: RetryErrorReason::MaxRetriesExceeded, + errors: vec![AiMuxError::other("first"), AiMuxError::other("second")], + }); + let source = std::error::Error::source(&err).unwrap(); + assert_eq!(source.to_string(), "second"); + assert_eq!( + err.to_string(), + "Failed after 2 attempts. Last error: second" + ); + } } diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 1534fc649..f7b0ab760 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -517,9 +517,7 @@ impl GenerateTextResult { pub fn output(&self) -> Result<&Value, AiMuxError> { match &self.output { OutputState::Parsed(value) => Ok(value), - OutputState::NotParsed => { - Err(AiMuxError::NoOutputGenerated("No output generated.".into())) - } + OutputState::NotParsed => Err(AiMuxError::no_output_generated("No output generated.")), OutputState::Failed(error) => Err(error.clone()), } } @@ -923,9 +921,7 @@ impl StreamTextResult { } if steps.is_empty() { return Err(aborted.unwrap_or_else(|| { - AiMuxError::NoOutputGenerated( - "No output generated. Check the stream for errors.".into(), - ) + AiMuxError::no_output_generated("No output generated. Check the stream for errors.") })); } let output = stream_output(steps.last().expect("checked above"), output.as_ref()); @@ -1372,7 +1368,7 @@ async fn convert_generated_content( && tool.supports_deferred_results == Some(true)) }); if !has_call && !deferred_allowed { - return Err(AiMuxError::Other(format!( + return Err(AiMuxError::other(format!( "Tool call {} not found.", result.tool_call_id ))); @@ -1934,8 +1930,8 @@ async fn stream_in_operation( // empty step; one with partial output keeps it // (stream-text.ts:3015-3030). if !step.terminal && !step.saw_output { - yield Ok(TextStreamPart::Error { error: AiMuxError::NoOutputGenerated( - "No output generated. The model stream ended without a finish chunk.".into(), + yield Ok(TextStreamPart::Error { error: AiMuxError::no_output_generated( + "No output generated. The model stream ended without a finish chunk.", ) }); return; } @@ -2334,9 +2330,7 @@ pub(crate) fn standardize_prompt( ) -> Result<(Vec, Option<&str>), AiMuxError> { let (messages, instructions) = split_prompt(prompt, instructions); if messages.is_empty() { - return Err(AiMuxError::InvalidPrompt( - "messages must not be empty".into(), - )); + return Err(AiMuxError::invalid_prompt("messages must not be empty")); } Ok((messages, instructions)) } diff --git a/aimux-core/src/generate/pipeline_tests.rs b/aimux-core/src/generate/pipeline_tests.rs index 7141591f6..c1a0b397e 100644 --- a/aimux-core/src/generate/pipeline_tests.rs +++ b/aimux-core/src/generate/pipeline_tests.rs @@ -137,14 +137,14 @@ impl LanguageModel for PipelineModel { Behavior::ProviderErrorThenFinish => Ok(stream_result( futures::stream::iter([ Ok(StreamPart::Error { - error: AiMuxError::Other("provider error".into()), + error: AiMuxError::other("provider error"), }), Ok(finish()), ]) .chain(futures::stream::pending()), )), Behavior::ParseErrorThenDelta => Ok(stream_result(futures::stream::iter([ - Err(AiMuxError::JsonParse("malformed SSE data".into())), + Err(AiMuxError::json_parse("malformed SSE data")), Ok(delta("after-error")), ]))), Behavior::TransportErrorThenPending => Ok(stream_result( @@ -383,7 +383,7 @@ async fn the_stream_continues_after_a_malformed_frame() { let parts: Vec<_> = result.stream.collect().await; let error = parts .iter() - .position(|part| matches!(part, Err(AiMuxError::JsonParse(_)))); + .position(|part| matches!(part, Err(AiMuxError::JsonParse { .. }))); let text = parts.iter().position( |part| matches!(part, Ok(TextStreamPart::TextDelta { text, .. }) if text == "after-error"), ); diff --git a/aimux-core/src/generate/tests.rs b/aimux-core/src/generate/tests.rs index 0f545f248..d91b233e1 100644 --- a/aimux-core/src/generate/tests.rs +++ b/aimux-core/src/generate/tests.rs @@ -400,7 +400,7 @@ async fn generate_text_derives_its_result_from_the_step() { // reading the output is a NoOutputGeneratedError. assert!(matches!( result.output(), - Err(AiMuxError::NoOutputGenerated(message)) if message == "No output generated." + Err(AiMuxError::NoOutputGenerated { message, .. }) if message == "No output generated." )); } @@ -480,7 +480,7 @@ async fn generate_text_parses_the_output() { )); let error = generate_text(&model, "hi", options()).await.unwrap_err(); assert!( - matches!(&error, AiMuxError::JsonParse(message) + matches!(&error, AiMuxError::JsonParse { message, .. } if message.starts_with("No object generated: could not parse the response.")), "{error:?}" ); @@ -517,7 +517,7 @@ async fn a_stream_output_failure_only_fails_the_output() { .await .unwrap(); assert_eq!(result.text(), "not json"); - assert!(matches!(result.output(), Err(AiMuxError::JsonParse(_)))); + assert!(matches!(result.output(), Err(AiMuxError::JsonParse { .. }))); assert!( serde_json::to_value(&result) .unwrap() @@ -607,7 +607,7 @@ async fn an_incomplete_stream_without_output_is_an_error() { assert_eq!(kinds, ["start", "error"]); assert!(matches!( &parts[1], - TextStreamPart::Error { error: AiMuxError::NoOutputGenerated(message) } + TextStreamPart::Error { error: AiMuxError::NoOutputGenerated { message, .. } } if message == "No output generated. The model stream ended without a finish chunk." )); @@ -673,7 +673,7 @@ async fn generate_object_wraps_the_output() { )); assert!(matches!( generate_object(&model, "hi", GenerateTextOptions::default()).await, - Err(AiMuxError::JsonParse(_)) + Err(AiMuxError::JsonParse { .. }) )); } @@ -1481,7 +1481,7 @@ async fn the_default_prompt_download_is_guarded() { .await .unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidArgument(message) + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message == "download URL targets a non-public address: 127.0.0.1"), "{error:?}" ); diff --git a/aimux-core/src/language_model_message.rs b/aimux-core/src/language_model_message.rs index b632b21e1..e02542d5b 100644 --- a/aimux-core/src/language_model_message.rs +++ b/aimux-core/src/language_model_message.rs @@ -12,6 +12,7 @@ use crate::content::{ }; use crate::download::{DownloadFunction, DownloadedFile, PlannedDownload}; use crate::error::AiMuxError; +use crate::error::ErrorCause; use crate::language_model::SupportedUrls; use crate::message::{AssistantContent, ModelMessage, UserContent}; use crate::shared::{ @@ -350,9 +351,8 @@ pub fn convert_file_part_data( match data { FilePartData::Tagged(FileData::Data { data: FileBytes::Base64(text), - }) if text.starts_with("data:") => Err(AiMuxError::InvalidPrompt( - "Data URLs are not valid inline data. Pass them as { type: \"url\", url } instead." - .into(), + }) if text.starts_with("data:") => Err(AiMuxError::invalid_prompt( + "Data URLs are not valid inline data. Pass them as { type: \"url\", url } instead.", )), FilePartData::Tagged(FileData::Url { url, original_url }) => { convert_url_to_file_part_data(url, original_url.clone()) @@ -417,7 +417,7 @@ fn convert_url_to_file_part_data( }, Some(media_type.to_owned()), )), - _ => Err(AiMuxError::InvalidPrompt(format!( + _ => Err(AiMuxError::invalid_prompt(format!( "Invalid data URL format in content {url}" ))), } @@ -667,8 +667,8 @@ fn assistant_part( FileData::Data { data } => GeneratedFileData::Data { data }, FileData::Url { url, original_url } => GeneratedFileData::Url { url, original_url }, FileData::Reference { .. } | FileData::Text { .. } => { - return Err(AiMuxError::InvalidPrompt( - "Unsupported reasoning-file data type".into(), + return Err(AiMuxError::invalid_prompt( + "Unsupported reasoning-file data type", )); } }; @@ -726,7 +726,7 @@ fn tool_result_part( .or_else(|| context.tool_names.get(tool_call_id)) .cloned() .ok_or_else(|| { - AiMuxError::InvalidPrompt(format!( + AiMuxError::invalid_prompt(format!( "tool result {tool_call_id} has no tool name or preceding tool call" )) })?, @@ -816,8 +816,10 @@ fn deprecated_reference(warnings: &mut Vec, kind: &str) { /// with the given spelling kept as `original_url` when normalizing changed /// it (`:666-676`, `:741-747`). fn normalized_url(given: &str) -> Result { - let parsed = url::Url::parse(given) - .map_err(|e| AiMuxError::InvalidPrompt(format!("Invalid URL: {given}: {e}")))?; + let parsed = url::Url::parse(given).map_err(|e| AiMuxError::InvalidPrompt { + message: format!("Invalid URL: {given}: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; let normalized = parsed.to_string(); Ok(FileData::Url { original_url: (normalized != given).then(|| given.to_owned()), @@ -989,10 +991,9 @@ fn file_id_reference( user::ToolResultFileId::ByProvider(reference) => Ok(reference), user::ToolResultFileId::Id(id) => { let provider = provider.ok_or_else(|| { - AiMuxError::InvalidPrompt( + AiMuxError::invalid_prompt( "Cannot convert string fileId to provider reference without a provider ID. \ - Use a Record fileId or switch to the file-reference type." - .into(), + Use a Record fileId or switch to the file-reference type.", ) })?; Ok(SharedProviderReference::from([(provider.to_owned(), id)])) @@ -1563,8 +1564,8 @@ mod tests { convert_output(json!([{ "type": "file-id", "fileId": "file-1" }]), None).unwrap_err(); assert_eq!( error.to_string(), - AiMuxError::InvalidPrompt( - "Cannot convert string fileId to provider reference without a provider ID. Use a Record fileId or switch to the file-reference type.".into() + AiMuxError::invalid_prompt( + "Cannot convert string fileId to provider reference without a provider ID. Use a Record fileId or switch to the file-reference type." ) .to_string() ); diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index 794766538..38c9930cb 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -183,7 +183,10 @@ pub use abort_signal::AbortSignal; pub use custom_provider::{CustomProvider, CustomProviderOptions, custom_provider}; pub use decision_model::DecisionModel; pub use embedding_model::EmbeddingModel; -pub use error::{AiMuxError, ApiCallError, RetryError, RetryErrorReason, ToolChoiceViolationError}; +pub use error::{ + AiMuxError, ApiCallError, ErrorCause, RetryError, RetryErrorReason, SourceError, + ToolChoiceViolationError, +}; pub use files_model::Files; pub use image_model::ImageModel; pub use language_model::LanguageModel; diff --git a/aimux-core/src/moa.rs b/aimux-core/src/moa.rs index d1f5c795e..2f8ef8ade 100644 --- a/aimux-core/src/moa.rs +++ b/aimux-core/src/moa.rs @@ -182,7 +182,7 @@ impl MoaModel { } } if texts.is_empty() { - return Err(AiMuxError::Other("moa: all reference models failed".into())); + return Err(AiMuxError::other("moa: all reference models failed")); } Ok((texts, usage, warnings)) } @@ -339,7 +339,7 @@ mod tests { async fn do_generate(&self, _options: &CallOptions) -> Result { let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if self.fail { - return Err(AiMuxError::Other(format!("{} failed", self.name))); + return Err(AiMuxError::other(format!("{} failed", self.name))); } if attempt <= self.retry_failures { return Err(retryable_error(self.name)); @@ -366,7 +366,7 @@ mod tests { async fn do_stream(&self, _options: &CallOptions) -> Result { let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if self.fail { - return Err(AiMuxError::Other(format!("{} failed", self.name))); + return Err(AiMuxError::other(format!("{} failed", self.name))); } if attempt <= self.retry_failures { return Err(retryable_error(self.name)); @@ -675,7 +675,7 @@ mod tests { provider_metadata: None, }), // A transport error mid-stream. - Err(AiMuxError::Other("aggregator transport blew up".into())), + Err(AiMuxError::other("aggregator transport blew up")), // A misbehaving aggregator that keeps going after the Err. Ok(StreamPart::TextDelta { id: "t2".into(), @@ -743,7 +743,7 @@ mod tests { Ok(StreamResult { stream: Box::pin(stream::iter([ Ok(StreamPart::StreamStart { warnings: vec![] }), - Err(AiMuxError::JsonParse("malformed SSE data".into())), + Err(AiMuxError::json_parse("malformed SSE data")), Ok(StreamPart::TextDelta { id: "t1".into(), delta: "after-error".into(), @@ -776,7 +776,7 @@ mod tests { let outcomes: Vec<_> = result.stream.collect().await; let error_index = outcomes .iter() - .position(|outcome| matches!(outcome, Err(AiMuxError::JsonParse(_)))) + .position(|outcome| matches!(outcome, Err(AiMuxError::JsonParse { .. }))) .expect("parse error should be relayed"); assert!(outcomes[error_index + 1..].iter().any(|outcome| matches!( diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index f8c53b66d..fee889233 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -1262,6 +1262,7 @@ fn random_id() -> String { #[cfg(test)] mod tests { use super::*; + use crate::error::SourceError; use crate::result::GenerateResult; use crate::result::ReasoningOutput; use crate::shared::ResponseInfo; @@ -1382,7 +1383,7 @@ mod tests { let error = AiMuxError::InvalidToolInput { tool_name: "get_weather".to_string(), tool_input: String::new(), - cause: "Type validation failed: missing 'city'".to_string(), + cause: SourceError::from("Type validation failed: missing 'city'").into(), }; let arguments = parsed_tool_call_arguments(&json!({}), Some(true), Some(&error)); assert_eq!(arguments, "{}"); @@ -1424,7 +1425,7 @@ mod tests { #[tokio::test] async fn invalid_tool_calls_stream_their_raw_arguments_verbatim() { let failing_repair = crate::parse_tool_call::ToolCallRepair::new(|_context| async { - Err(AiMuxError::Other("repair model failed".to_string())) + Err(AiMuxError::other("repair model failed")) }); let cases: [(&str, &str, Option<&crate::parse_tool_call::ToolCallRepair>); 3] = [ // Valid JSON string rejected by the schema: the quotes must @@ -2061,7 +2062,7 @@ mod tests { async fn recoverable_frame_error_does_not_truncate_chat_completion_stream() { let parts: Vec> = vec![ Ok(TextStreamPart::Start {}), - Err(AiMuxError::JsonParse("malformed SSE data".into())), + Err(AiMuxError::json_parse("malformed SSE data")), Ok(TextStreamPart::TextDelta { id: "0".to_string(), text: "after-error".to_string(), @@ -2082,7 +2083,7 @@ mod tests { let outcomes: Vec<_> = result.stream.collect().await; let error_index = outcomes .iter() - .position(|outcome| matches!(outcome, Err(AiMuxError::JsonParse(_)))) + .position(|outcome| matches!(outcome, Err(AiMuxError::JsonParse { .. }))) .expect("parse error should be preserved as an error item"); assert!(outcomes[error_index + 1..].iter().any(|outcome| matches!( diff --git a/aimux-core/src/output.rs b/aimux-core/src/output.rs index ec7f2dea3..520389786 100644 --- a/aimux-core/src/output.rs +++ b/aimux-core/src/output.rs @@ -9,14 +9,14 @@ //! Parse failures follow the AI SDK's `NoObjectGeneratedError` messages //! ("No object generated: could not parse the response." and "No object //! generated: response did not match schema."), carried by the Aimux variants -//! for the same two cases: [`AiMuxError::JsonParse`] for text that is not -//! JSON and [`AiMuxError::InvalidResponseData`] for JSON of the wrong shape. +//! for the same two cases: [`AiMuxError::json_parse`] for text that is not +//! JSON and [`AiMuxError::invalid_response_data`] for JSON of the wrong shape. use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use ts_rs::TS; -use crate::error::AiMuxError; +use crate::error::{AiMuxError, ErrorCause, SourceError}; use crate::options::ResponseFormat; /// What the final text of a generation is parsed into. @@ -105,8 +105,8 @@ impl Output { } = self && min_items > max_items { - return Err(AiMuxError::InvalidArgument( - "minItems must be less than or equal to maxItems".into(), + return Err(AiMuxError::invalid_argument( + "minItems must be less than or equal to maxItems", )); } Ok(()) @@ -197,8 +197,10 @@ impl Output { /// JSON does not have the requested shape; both messages start with the /// AI SDK's `NoObjectGeneratedError` message. pub fn parse_complete_output(&self, text: &str) -> Result { - let schema_mismatch = - |cause: String| AiMuxError::InvalidResponseData(format!("{DID_NOT_MATCH} {cause}")); + let schema_mismatch = |cause: SourceError| AiMuxError::InvalidResponseData { + message: format!("{DID_NOT_MATCH} {cause}"), + cause: Some(cause.into()), + }; match self { Self::Text {} => Ok(Value::String(text.to_owned())), Self::Json { .. } => parse_json(text), @@ -215,25 +217,28 @@ impl Output { } => { let value = parse_json(text)?; let Some(elements) = value.get("elements").and_then(Value::as_array) else { - return Err(schema_mismatch(type_validation_failed( + return Err(schema_mismatch(type_validation_error( &value, "response must be an object with an elements array", + None, ))); }; if let Some(min_items) = min_items && elements.len() < *min_items as usize { - return Err(schema_mismatch(type_validation_failed( + return Err(schema_mismatch(type_validation_error( &Value::Array(elements.clone()), &format!("elements array must contain at least {min_items} items"), + None, ))); } if let Some(max_items) = max_items && elements.len() > *max_items as usize { - return Err(schema_mismatch(type_validation_failed( + return Err(schema_mismatch(type_validation_error( &Value::Array(elements.clone()), &format!("elements array must contain at most {max_items} items"), + None, ))); } for item in elements { @@ -247,9 +252,10 @@ impl Output { Some(result) if options.iter().any(|option| option == result) => { Ok(Value::String(result.to_owned())) } - _ => Err(schema_mismatch(type_validation_failed( + _ => Err(schema_mismatch(type_validation_error( &value, "response must be an object that contains a choice value.", + None, ))), } } @@ -258,37 +264,79 @@ impl Output { } /// The AI SDK's `safeParseJSON` (`@ai-sdk/provider-utils` `parse-json.ts`): -/// JSON with the prototype-poisoning guard of `secure-json-parse`. +/// JSON with the prototype-poisoning guard of `secure-json-parse`. A failure +/// is the `NoObjectGeneratedError` message with the `JSONParseError` as its +/// cause. fn parse_json(text: &str) -> Result { - let parse_failed = |message: String| { - AiMuxError::JsonParse(format!( - "{COULD_NOT_PARSE} JSON parsing failed: Text: {text}.\nError message: {message}" - )) + let no_object = |parse_error: AiMuxError| AiMuxError::JsonParse { + message: format!("{COULD_NOT_PARSE} {}", parse_error_message(&parse_error)), + cause: Some(parse_error.into()), }; - let value: Value = - serde_json::from_str(text).map_err(|error| parse_failed(error.to_string()))?; + let value: Value = serde_json::from_str(text) + .map_err(|error| no_object(json_parse_error(text, &error.to_string(), Some(&error))))?; if crate::parse_tool_call::contains_forbidden_prototype(&value) { - return Err(parse_failed( - "Object contains forbidden prototype property".into(), - )); + return Err(no_object(json_parse_error( + text, + "Object contains forbidden prototype property", + None, + ))); } Ok(value) } -/// The AI SDK's `TypeValidationError` message -/// (`@ai-sdk/provider` `errors/type-validation-error.ts`). -fn type_validation_failed(value: &Value, cause: &str) -> String { - format!("Type validation failed: Value: {value}.\nError message: {cause}") +fn parse_error_message(error: &AiMuxError) -> &str { + match error { + AiMuxError::JsonParse { message, .. } => message, + _ => "", + } +} + +/// The AI SDK's `JSONParseError` (`@ai-sdk/provider` `errors/json-parse-error.ts`): +/// its message, with the parser's error as its cause. +pub(crate) fn json_parse_error( + text: &str, + message: &str, + source: Option<&(dyn std::error::Error + 'static)>, +) -> AiMuxError { + AiMuxError::JsonParse { + message: format!("JSON parsing failed: Text: {text}.\nError message: {message}"), + cause: source.map(ErrorCause::source), + } +} + +/// The AI SDK's `TypeValidationError` +/// (`@ai-sdk/provider` `errors/type-validation-error.ts`), with the +/// validator's error as its cause when one exists. +pub(crate) fn type_validation_error( + value: &Value, + message: &str, + source: Option, +) -> SourceError { + SourceError { + message: format!("Type validation failed: Value: {value}.\nError message: {message}"), + cause: source.map(Box::new), + } +} + +/// A `jsonschema` error as a cause. It borrows the validated value, so it +/// cannot be kept as a `'static` source; it has no source chain of its own. +pub(crate) fn validator_error(error: &jsonschema::ValidationError<'_>) -> SourceError { + error.to_string().into() } /// Validate a value against a JSON schema, as tool inputs are validated /// (`parse_tool_call`). -fn validate(value: &Value, schema: &Value) -> Result<(), String> { - let validator = jsonschema::validator_for(schema) - .map_err(|error| type_validation_failed(value, &format!("schema is invalid: {error}")))?; - validator - .validate(value) - .map_err(|error| type_validation_failed(value, &error.to_string())) +fn validate(value: &Value, schema: &Value) -> Result<(), SourceError> { + let validator = jsonschema::validator_for(schema).map_err(|error| { + type_validation_error( + value, + &format!("schema is invalid: {error}"), + Some(validator_error(&error)), + ) + })?; + validator.validate(value).map_err(|error| { + type_validation_error(value, &error.to_string(), Some(validator_error(&error))) + }) } #[cfg(test)] @@ -332,13 +380,13 @@ mod tests { ); let error = output.parse_complete_output("{not json").unwrap_err(); assert!( - matches!(&error, AiMuxError::JsonParse(message) + matches!(&error, AiMuxError::JsonParse { message, .. } if message.starts_with("No object generated: could not parse the response.")), "{error:?}" ); let error = output.parse_complete_output(r#"{"age":3}"#).unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidResponseData(message) + matches!(&error, AiMuxError::InvalidResponseData { message, .. } if message.starts_with("No object generated: response did not match schema.")), "{error:?}" ); @@ -396,7 +444,7 @@ mod tests { assert!( matches!( output.parse_complete_output(text), - Err(AiMuxError::InvalidResponseData(_)) + Err(AiMuxError::InvalidResponseData { .. }) ), "{text}" ); @@ -415,7 +463,7 @@ mod tests { }; assert!(matches!( output.validate(), - Err(AiMuxError::InvalidArgument(message)) + Err(AiMuxError::InvalidArgument { message, .. }) if message == "minItems must be less than or equal to maxItems" )); } @@ -442,7 +490,7 @@ mod tests { ); assert!(matches!( output.parse_complete_output(r#"{"result":"maybe"}"#), - Err(AiMuxError::InvalidResponseData(_)) + Err(AiMuxError::InvalidResponseData { .. }) )); } @@ -463,7 +511,7 @@ mod tests { ); assert!(matches!( output.parse_complete_output(r#"{"__proto__":{}}"#), - Err(AiMuxError::JsonParse(_)) + Err(AiMuxError::JsonParse { .. }) )); } } diff --git a/aimux-core/src/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs index 30347fe02..a28cfaa1b 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -14,7 +14,8 @@ use serde_json::Value; use ts_rs::TS; use crate::content::{AssistantContentPart, ToolContentPart}; -use crate::error::AiMuxError; +use crate::error::{AiMuxError, ErrorCause, SourceError}; +use crate::output::{json_parse_error, type_validation_error, validator_error}; use crate::result::ContentPart; use crate::tool::{RawToolCall, Tool, ToolCall}; @@ -277,22 +278,27 @@ fn parse_and_validate_tool_call( let Tool::Function(function_tool) = tool else { return Ok((input, None)); }; + let invalid = |cause: ErrorCause| AiMuxError::InvalidToolInput { + tool_name: tool_call.tool_name.clone(), + tool_input: tool_call.input.clone(), + cause, + }; let validator = jsonschema::validator_for(&function_tool.input_schema).map_err(|error| { - AiMuxError::InvalidToolInput { - tool_name: tool_call.tool_name.clone(), - tool_input: tool_call.input.clone(), - cause: format!("input schema is invalid: {error}"), - } + invalid( + SourceError { + message: format!("input schema is invalid: {error}"), + cause: Some(Box::new(validator_error(&error))), + } + .into(), + ) })?; // Cause wording matches the AI SDK's `TypeValidationError` template; // `Value` Display is compact JSON, the `JSON.stringify` equivalent. - validator - .validate(&input) - .map_err(|error| AiMuxError::InvalidToolInput { - tool_name: tool_call.tool_name.clone(), - tool_input: tool_call.input.clone(), - cause: format!("Type validation failed: Value: {input}.\nError message: {error}"), - })?; + validator.validate(&input).map_err(|error| { + invalid( + type_validation_error(&input, &error.to_string(), Some(validator_error(&error))).into(), + ) + })?; Ok((input, None)) } @@ -300,25 +306,25 @@ fn parse_json_input(tool_call: &RawToolCall) -> Result { if tool_call.input.trim().is_empty() { return Ok(Value::Object(serde_json::Map::new())); } - // Cause wording matches the AI SDK's `JSONParseError` template. - let value: Value = - parse_input_text(&tool_call.input).map_err(|error| AiMuxError::InvalidToolInput { - tool_name: tool_call.tool_name.clone(), - tool_input: tool_call.input.clone(), - cause: format!( - "JSON parsing failed: Text: {}.\nError message: {error}", - tool_call.input - ), - })?; + // The cause is the AI SDK's `JSONParseError`. + let invalid = |parse_error: AiMuxError| AiMuxError::InvalidToolInput { + tool_name: tool_call.tool_name.clone(), + tool_input: tool_call.input.clone(), + cause: parse_error.into(), + }; + let value: Value = parse_input_text(&tool_call.input).map_err(|error| { + invalid(json_parse_error( + &tool_call.input, + &error.to_string(), + Some(&error), + )) + })?; if contains_forbidden_prototype(&value) { - return Err(AiMuxError::InvalidToolInput { - tool_name: tool_call.tool_name.clone(), - tool_input: tool_call.input.clone(), - cause: format!( - "JSON parsing failed: Text: {}.\nError message: Object contains forbidden prototype property", - tool_call.input - ), - }); + return Err(invalid(json_parse_error( + &tool_call.input, + "Object contains forbidden prototype property", + None, + ))); } Ok(value) } @@ -481,7 +487,7 @@ impl From for RepairOutcome { ToolCallRepairReply::Repaired { tool_call } => RepairOutcome::Repaired(tool_call), ToolCallRepairReply::Unchanged => RepairOutcome::Unchanged, ToolCallRepairReply::Failed { message } => { - RepairOutcome::Failed(AiMuxError::Other(message)) + RepairOutcome::Failed(AiMuxError::other(message)) } } } @@ -524,13 +530,13 @@ pub(crate) fn original_tool_call_text(input: &Value, error: &AiMuxError) -> Opti /// call instead. fn raw_from_invalid(tool_call: &ToolCall) -> Result<(RawToolCall, AiMuxError), AiMuxError> { if tool_call.invalid != Some(true) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "tool call '{}' is not invalid; nothing to repair", tool_call.tool_call_id ))); } let error = tool_call.error.clone().ok_or_else(|| { - AiMuxError::InvalidArgument(format!( + AiMuxError::invalid_argument(format!( "invalid tool call '{}' carries no error", tool_call.tool_call_id )) @@ -560,7 +566,7 @@ fn raw_from_invalid(tool_call: &ToolCall) -> Result<(RawToolCall, AiMuxError), A /// repair, so reaching this point with `None` is a caller mistake. fn required_tools(tools: Option<&[Tool]>) -> Result<&[Tool], AiMuxError> { tools.ok_or_else(|| { - AiMuxError::InvalidArgument( + AiMuxError::invalid_argument( "tool calls made without a tool set are not repairable".to_string(), ) }) @@ -601,7 +607,7 @@ pub fn tool_call_repair_inputs( /// /// # Errors /// -/// [`AiMuxError::InvalidArgument`] when `tool_call` is not an invalid call, or +/// [`AiMuxError::invalid_argument`] when `tool_call` is not an invalid call, or /// carries no error. pub fn tool_call_repair_context( tool_call: &ToolCall, @@ -639,7 +645,7 @@ pub fn tool_call_repair_context( /// /// # Errors /// -/// [`AiMuxError::InvalidArgument`] when `tools` is `None` (the call was never +/// [`AiMuxError::invalid_argument`] when `tools` is `None` (the call was never /// repairable — [`tool_call_repair_context`] already answered `None` for it), /// or when `tool_call` is not an invalid call, or carries no error. pub fn apply_tool_call_repair( @@ -673,7 +679,7 @@ pub fn apply_tool_call_repair( /// /// # Errors /// -/// [`AiMuxError::InvalidArgument`] when `tools` is `None`, when the document +/// [`AiMuxError::invalid_argument`] when `tools` is `None`, when the document /// is not a text result, when `tool_call_id` is missing or ambiguous, when /// the matched call is not invalid, or when a replacement id conflicts with /// another call. @@ -684,7 +690,10 @@ pub fn apply_tool_call_repair_to_result( reply: ToolCallRepairReply, ) -> Result { let mut result: crate::generate::GenerateTextResult = serde_json::from_value(result.clone()) - .map_err(|error| AiMuxError::InvalidArgument(format!("result: {error}")))?; + .map_err(|error| AiMuxError::InvalidArgument { + message: format!("result: {error}"), + cause: Some(ErrorCause::source(&error)), + })?; // Calls and the transcript are keyed by id here, so a duplicated id // cannot be patched unambiguously; refuse rather than rewrite the first @@ -694,11 +703,11 @@ pub fn apply_tool_call_repair_to_result( .iter() .filter(|call| call.tool_call_id == tool_call_id); let original = (*matching.next().ok_or_else(|| { - AiMuxError::InvalidArgument(format!("result: no tool call '{tool_call_id}'")) + AiMuxError::invalid_argument(format!("result: no tool call '{tool_call_id}'")) })?) .clone(); if matching.next().is_some() { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "result: tool call id '{tool_call_id}' is not unique" ))); } @@ -709,7 +718,7 @@ pub fn apply_tool_call_repair_to_result( .iter() .any(|call| call.tool_call_id == repaired.tool_call_id) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "result: repaired tool call id '{}' already exists", repaired.tool_call_id ))); @@ -805,8 +814,10 @@ pub fn apply_tool_call_repair_to_result( }); } - serde_json::to_value(&result) - .map_err(|error| AiMuxError::InvalidArgument(format!("result: {error}"))) + serde_json::to_value(&result).map_err(|error| AiMuxError::InvalidArgument { + message: format!("result: {error}"), + cause: Some(ErrorCause::source(&error)), + }) } #[cfg(test)] diff --git a/aimux-core/src/recording.rs b/aimux-core/src/recording.rs index 98b8f75f1..a44346e4f 100644 --- a/aimux-core/src/recording.rs +++ b/aimux-core/src/recording.rs @@ -296,7 +296,7 @@ impl ProviderRecord { /// a recording is replayed by passing the model to `replay_with_model`. pub fn registry_id(&self) -> Result<&str, crate::error::AiMuxError> { if self.provider_id.is_empty() { - return Err(crate::error::AiMuxError::InvalidArgument(format!( + return Err(crate::error::AiMuxError::invalid_argument(format!( "replay: the recording of `{}` has no provider_id (recordings written by \ aimux 0.5 or earlier carry none), so the model cannot be rebuilt from a \ registry; pass the model to replay_with_model", @@ -2112,7 +2112,7 @@ mod tests { fn provider_error_part() -> Result { Ok(crate::stream_part::StreamPart::Error { - error: crate::AiMuxError::Other("provider error".into()), + error: crate::AiMuxError::other("provider error"), }) } @@ -2200,16 +2200,13 @@ mod tests { provider_metadata: None, }); let mut stream = capture_stream( - vec![ - Err(crate::AiMuxError::JsonParse("bad frame".into())), - finish, - ], + vec![Err(crate::AiMuxError::json_parse("bad frame")), finish], &recorder, ); assert!(matches!( stream.next().await, - Some(Err(crate::AiMuxError::JsonParse(_))) + Some(Err(crate::AiMuxError::JsonParse { .. })) )); assert!(recorder.0.lock().unwrap().is_empty()); assert!(matches!( diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index 799b8047f..21ba4e1f0 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -16,6 +16,7 @@ use async_trait::async_trait; use std::sync::Arc; use crate::error::AiMuxError; +use crate::error::ErrorCause; use crate::generate::{ GenerateTextOptions, GenerateTextResult, generate_text_from_language_model_prompt, }; @@ -88,9 +89,7 @@ impl ReplayMatcher for ExactMatcher { && r.provider.model_id == self.model_id && canonical_keys_match(&canonical_recording_key(r), &needle) }) - .ok_or_else(|| { - AiMuxError::InvalidArgument("mock replay: no exact matching recording".into()) - }) + .ok_or_else(|| AiMuxError::invalid_argument("mock replay: no exact matching recording")) } } @@ -245,7 +244,7 @@ impl ReplayMatcher for ScoreMatcher { } } best.map(|(r, _)| r).ok_or_else(|| { - AiMuxError::InvalidArgument("mock replay: no scoring match for this input".into()) + AiMuxError::invalid_argument("mock replay: no scoring match for this input") }) } } @@ -387,7 +386,7 @@ impl ReplayMatcher for PrefixMatcher { } } best.map(|(r, _)| r).ok_or_else(|| { - AiMuxError::InvalidArgument("mock replay: no prefix match for this input".into()) + AiMuxError::invalid_argument("mock replay: no prefix match for this input") }) } } @@ -440,32 +439,35 @@ impl MockReplayModel { /// /// # Errors /// - /// Returns `AiMuxError::InvalidArgument` when the file cannot be read, + /// Returns `AiMuxError::invalid_argument` when the file cannot be read, /// contains no recordings, or holds a recording of an unsupported schema, /// and `JsonParse` when a line is not valid JSON. pub fn from_jsonl(path: &str) -> Result { - let content = std::fs::read_to_string(path) - .map_err(|e| AiMuxError::InvalidArgument(format!("mock replay: {e}")))?; + let content = std::fs::read_to_string(path).map_err(|e| AiMuxError::InvalidArgument { + message: format!("mock replay: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; let mut recordings = Vec::new(); for (idx, line) in content.lines().enumerate() { if line.trim().is_empty() { continue; } let rec: Recording = serde_json::from_str(line).map_err(|e| { - let msg = format!("mock replay line {}: {e}", idx + 1); + let message = format!("mock replay line {}: {e}", idx + 1); + let cause = Some(ErrorCause::source(&e)); // Well-formed JSON that is not a current-schema `Recording` // (including an old `schema`) is a bad argument, not a parse error. if e.classify() == serde_json::error::Category::Data { - AiMuxError::InvalidArgument(msg) + AiMuxError::InvalidArgument { message, cause } } else { - AiMuxError::JsonParse(msg) + AiMuxError::JsonParse { message, cause } } })?; recordings.push(rec); } if recordings.is_empty() { - return Err(AiMuxError::InvalidArgument( - "mock replay: empty recording file".into(), + return Err(AiMuxError::invalid_argument( + "mock replay: empty recording file", )); } let provider = recordings[0].provider.provider.clone(); @@ -498,10 +500,13 @@ impl MockReplayModel { if unreadable == 0 { return error; } - AiMuxError::InvalidArgument(format!( - "{error}; {unreadable} recording(s) of this model hold a prompt this \ + AiMuxError::InvalidArgument { + message: format!( + "{error}; {unreadable} recording(s) of this model hold a prompt this \ version cannot read (written by aimux 0.5 or earlier) and never match" - )) + ), + cause: Some(error.into()), + } }) } } @@ -544,8 +549,8 @@ fn last_response(rec: &Recording) -> Result<&crate::recording::ResponseRecord, A return Ok(resp); } } - Err(AiMuxError::InvalidArgument( - "mock replay: recording has no successful (2xx, finalized, error-free) response".into(), + Err(AiMuxError::invalid_argument( + "mock replay: recording has no successful (2xx, finalized, error-free) response", )) } @@ -606,8 +611,9 @@ fn parse_finish(raw: Option<&str>) -> (FinishReasonUnified, Option) { fn u32_from_json(v: &serde_json::Value, field: &str) -> Result, AiMuxError> { v.as_u64() .map(|n| { - u32::try_from(n).map_err(|_| { - AiMuxError::JsonParse(format!("mock replay: usage '{field}' overflows u32: {n}")) + u32::try_from(n).map_err(|error| AiMuxError::JsonParse { + message: format!("mock replay: usage '{field}' overflows u32: {n}"), + cause: Some(ErrorCause::source(&error)), }) }) .transpose() @@ -654,9 +660,11 @@ fn rebuild_generate_result(rec: &Recording) -> Result Result { let body = resp .body .as_deref() - .ok_or_else(|| AiMuxError::InvalidArgument("mock replay: response has no body".into()))?; + .ok_or_else(|| AiMuxError::invalid_argument("mock replay: response has no body"))?; let mut parts: Vec> = Vec::new(); parts.push(Ok(StreamPart::StreamStart { warnings: vec![] })); @@ -1020,9 +1028,9 @@ pub struct ReplayOverrides { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` for a recording whose prompt this +/// Returns `AiMuxError::invalid_argument` for a recording whose prompt this /// version cannot read ([`InputRecord::unreadable_prompt`](crate::recording::InputRecord::unreadable_prompt)), -/// `AiMuxError::JsonParse` when the recorded call options cannot be +/// `AiMuxError::json_parse` when the recorded call options cannot be /// deserialized or hold snake_case keys this version would ignore (options /// written by aimux 0.5 or earlier), and propagates errors from the /// generation pipeline. @@ -1032,12 +1040,12 @@ pub async fn replay_with_model( overrides: Option<&ReplayOverrides>, ) -> Result { if recording.input.operation != crate::recording::RecordingOperation::LanguageModel { - return Err(AiMuxError::InvalidArgument( - "language replay requires a language-model recording".into(), + return Err(AiMuxError::invalid_argument( + "language replay requires a language-model recording", )); } if let Some(error) = recording.input.unreadable_prompt() { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "language replay: this version cannot read the recorded prompt (recordings \ written by aimux 0.5 or earlier use another prompt format): {error}" ))); @@ -1051,7 +1059,7 @@ pub async fn replay_with_model( }; let unread = snake_case_option_keys(&recording.input.options); if !unread.is_empty() { - return Err(AiMuxError::JsonParse(format!( + return Err(AiMuxError::json_parse(format!( "mock replay: input options invalid{older}: this version reads the options \ camelCase and would ignore {}", unread.join(", ") @@ -1060,12 +1068,16 @@ pub async fn replay_with_model( // 1. 从录制输入重建 CallOptions(round-trip:录制时由 CallOptions 序列化) // and the operation settings recorded next to them. let options = readable_options(&recording.input.options); - let mut call_options: CallOptions = serde_json::from_value(options.clone()).map_err(|e| { - AiMuxError::JsonParse(format!("mock replay: input options invalid{older}: {e}")) - })?; - let settings: OperationSettings = serde_json::from_value(options).map_err(|e| { - AiMuxError::JsonParse(format!("mock replay: input options invalid{older}: {e}")) - })?; + let mut call_options: CallOptions = + serde_json::from_value(options.clone()).map_err(|e| AiMuxError::JsonParse { + message: format!("mock replay: input options invalid{older}: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; + let settings: OperationSettings = + serde_json::from_value(options).map_err(|e| AiMuxError::JsonParse { + message: format!("mock replay: input options invalid{older}: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; // 2. 应用 overrides。 if let Some(o) = overrides { @@ -1606,7 +1618,7 @@ mod tests { .await .unwrap_err() }); - assert!(matches!(err, AiMuxError::JsonParse(_)), "{err}"); + assert!(matches!(err, AiMuxError::JsonParse { .. }), "{err}"); assert!(err.to_string().contains("overflows u32"), "{err}"); } @@ -2121,6 +2133,6 @@ mod tests { let rt = tokio::runtime::Runtime::new().unwrap(); let err = rt.block_on(async { replay_with_model(&rec, &EchoModel, None).await.unwrap_err() }); - assert!(matches!(err, AiMuxError::JsonParse(_)), "{err}"); + assert!(matches!(err, AiMuxError::JsonParse { .. }), "{err}"); } } diff --git a/aimux-core/src/retry.rs b/aimux-core/src/retry.rs index 0cd91d35e..4d3328418 100644 --- a/aimux-core/src/retry.rs +++ b/aimux-core/src/retry.rs @@ -311,7 +311,7 @@ mod tests { #[tokio::test] async fn first_non_retryable_error_is_not_wrapped() { let error = retry_with_exponential_backoff( - || async { Err::<(), _>(AiMuxError::InvalidArgument("bad".into())) }, + || async { Err::<(), _>(AiMuxError::invalid_argument("bad")) }, 2, 0, 2, @@ -321,7 +321,7 @@ mod tests { ) .await .unwrap_err(); - assert!(matches!(error, AiMuxError::InvalidArgument(_))); + assert!(matches!(error, AiMuxError::InvalidArgument { .. })); } #[tokio::test] diff --git a/aimux-core/src/router.rs b/aimux-core/src/router.rs index 910cecf3d..72e9778ac 100644 --- a/aimux-core/src/router.rs +++ b/aimux-core/src/router.rs @@ -53,7 +53,7 @@ impl Router for RuleRouter { models: &[ChildModel], ) -> Result { if models.is_empty() { - return Err(AiMuxError::Other("router: no models configured".into())); + return Err(AiMuxError::other("router: no models configured")); } Ok(0) } @@ -85,7 +85,7 @@ impl Router for WeightedRouter { models: &[ChildModel], ) -> Result { if models.is_empty() { - return Err(AiMuxError::Other("router: no models configured".into())); + return Err(AiMuxError::other("router: no models configured")); } // Pick the child with the highest weight. On ties, prefer the earliest // index (matches `RuleRouter`'s "child 0 first" expectation when all @@ -210,7 +210,7 @@ impl RouterModel { if idx < self.models.len() { Ok(idx) } else { - Err(AiMuxError::InvalidArgument(format!( + Err(AiMuxError::invalid_argument(format!( "{from}: router returned out-of-bounds index {idx} (models: {})", self.models.len() ))) @@ -313,7 +313,7 @@ mod tests { async fn do_generate(&self, _options: &CallOptions) -> Result { let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if self.fail { - return Err(AiMuxError::Other(format!("{} always fails", self.name))); + return Err(AiMuxError::other(format!("{} always fails", self.name))); } if attempt <= self.retry_failures { return Err(retryable_error(self.name)); @@ -337,7 +337,7 @@ mod tests { async fn do_stream(&self, _options: &CallOptions) -> Result { let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if self.fail { - return Err(AiMuxError::Other(format!("{} always fails", self.name))); + return Err(AiMuxError::other(format!("{} always fails", self.name))); } if attempt <= self.retry_failures { return Err(retryable_error(self.name)); @@ -569,7 +569,7 @@ mod tests { ); let err = router.do_generate(&opts()).await.unwrap_err(); match err { - AiMuxError::InvalidArgument(msg) => { + AiMuxError::InvalidArgument { message: msg, .. } => { assert!(msg.contains("out-of-bounds"), "got: {msg}"); } other => panic!("expected InvalidArgument, got {other:?}"), @@ -692,7 +692,7 @@ mod tests { .lock() .unwrap() .push(RecordingContext::current().and_then(|context| context.step())); - Err(AiMuxError::Other("probe fails".into())) + Err(AiMuxError::other("probe fails")) } async fn do_stream(&self, _: &CallOptions) -> Result { unreachable!() diff --git a/aimux-core/src/shared.rs b/aimux-core/src/shared.rs index 923581d83..170d884ab 100644 --- a/aimux-core/src/shared.rs +++ b/aimux-core/src/shared.rs @@ -72,7 +72,7 @@ pub fn provider_namespace( object: Value, ) -> Result { let Value::Object(object) = object else { - return Err(crate::AiMuxError::InvalidArgument(format!( + return Err(crate::AiMuxError::invalid_argument(format!( "invalid {namespace} provider options: expected a JSON object" ))); }; diff --git a/aimux-core/src/timeout.rs b/aimux-core/src/timeout.rs index b3f9d5ded..b26b0a9d4 100644 --- a/aimux-core/src/timeout.rs +++ b/aimux-core/src/timeout.rs @@ -35,7 +35,7 @@ impl TimeoutDeadline { let at = start .checked_add(std::time::Duration::from_millis(duration_ms)) .ok_or_else(|| { - AiMuxError::InvalidArgument(format!( + AiMuxError::invalid_argument(format!( "{label} timeout of {duration_ms}ms exceeds the supported range" )) })?; @@ -207,7 +207,7 @@ mod tests { let retry = retry::retry_with_exponential_backoff( || { attempts.fetch_add(1, Ordering::SeqCst); - async { Err::<(), _>(AiMuxError::Other("retry".into())) } + async { Err::<(), _>(AiMuxError::other("retry")) } }, 2, 60_000, @@ -258,7 +258,7 @@ mod tests { .expect("timeout validation must not panic"); assert!(matches!( result, - Ok(_) | Err(AiMuxError::InvalidArgument(_)) + Ok(_) | Err(AiMuxError::InvalidArgument { .. }) )); } } diff --git a/aimux-core/src/transcription_model.rs b/aimux-core/src/transcription_model.rs index 4d594b033..ee573e38a 100644 --- a/aimux-core/src/transcription_model.rs +++ b/aimux-core/src/transcription_model.rs @@ -48,8 +48,8 @@ impl AudioInput { /// only [`transcribe`] downloads a URL. #[must_use] pub fn url_error() -> AiMuxError { - AiMuxError::InvalidArgument( - "a transcription model takes audio data; `transcribe` downloads an audio URL".into(), + AiMuxError::invalid_argument( + "a transcription model takes audio data; `transcribe` downloads an audio URL", ) } } @@ -687,7 +687,7 @@ mod tests { ); let error = transcribe(&model, options).await.unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidArgument(message) + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message == "download URL targets a non-public address: 127.0.0.1"), "{error:?}" ); diff --git a/aimux-core/src/types.rs b/aimux-core/src/types.rs index 5ffb98023..0b35b8cd1 100644 --- a/aimux-core/src/types.rs +++ b/aimux-core/src/types.rs @@ -359,14 +359,14 @@ impl NamespacedKind { /// /// # Errors /// - /// [`AiMuxError::InvalidArgument`](crate::error::AiMuxError::InvalidArgument) + /// [`AiMuxError::invalid_argument`](crate::error::AiMuxError::invalid_argument) /// when `value` has no `.`. pub fn new(value: impl Into) -> Result { let value = value.into(); if value.contains('.') { Ok(Self(value)) } else { - Err(crate::error::AiMuxError::InvalidArgument(format!( + Err(crate::error::AiMuxError::invalid_argument(format!( "expected a `.` identifier, got {value:?}" ))) } diff --git a/aimux-core/src/video_model.rs b/aimux-core/src/video_model.rs index 2ae609f46..49c5168d4 100644 --- a/aimux-core/src/video_model.rs +++ b/aimux-core/src/video_model.rs @@ -479,7 +479,7 @@ async fn start_and_poll( options: VideoCallOptions, ) -> Result { if options.n == 0 { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "video generation `n` must be at least 1".to_string(), )); } @@ -1136,7 +1136,7 @@ mod tests { options.download = None; let error = generate_video(&model, options).await.unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidArgument(message) + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message == "download URL targets a non-public address: 127.0.0.1"), "{error:?}" ); @@ -1186,7 +1186,7 @@ mod tests { let error = generate_video(&model, options).await.unwrap_err(); assert!( - matches!(error, AiMuxError::InvalidArgument(_)), + matches!(error, AiMuxError::InvalidArgument { .. }), "expected InvalidArgument, got {error:?}" ); assert_eq!( diff --git a/aimux-core/tests/cache_probe_test.rs b/aimux-core/tests/cache_probe_test.rs index 6a35947e2..b2c92f735 100644 --- a/aimux-core/tests/cache_probe_test.rs +++ b/aimux-core/tests/cache_probe_test.rs @@ -557,7 +557,7 @@ fn mid_stream_error() -> Vec { vec![ Ok(start()), Ok(StreamPart::Error { - error: AiMuxError::InvalidResponseData("mid-stream failure".into()), + error: AiMuxError::invalid_response_data("mid-stream failure"), }), ] } @@ -566,7 +566,7 @@ fn mid_stream_error() -> Vec { fn transport_error() -> Vec { vec![ Ok(start()), - Err(AiMuxError::InvalidResponseData("transport failure".into())), + Err(AiMuxError::invalid_response_data("transport failure")), ] } @@ -618,11 +618,11 @@ impl LanguageModel for Failing { } async fn do_generate(&self, _options: &CallOptions) -> Result { - Err(AiMuxError::Other("boom".into())) + Err(AiMuxError::other("boom")) } async fn do_stream(&self, _options: &CallOptions) -> Result { - Err(AiMuxError::Other("boom".into())) + Err(AiMuxError::other("boom")) } } @@ -787,7 +787,7 @@ fn trace_layer_records_stream_part_error() { stats[0].errors, 1, "provider-reported stream error recorded" ); - let expected = AiMuxError::InvalidResponseData("mid-stream failure".into()).to_string(); + let expected = AiMuxError::invalid_response_data("mid-stream failure").to_string(); assert_eq!(records(&store)[0].error, Some(expected)); } @@ -804,7 +804,7 @@ fn trace_layer_records_stream_transport_error() { let stats = store.aggregate(&TraceFilter::default()); assert_eq!(stats[0].errors, 1, "transport error recorded"); - let expected = AiMuxError::InvalidResponseData("transport failure".into()).to_string(); + let expected = AiMuxError::invalid_response_data("transport failure").to_string(); assert_eq!(records(&store)[0].error, Some(expected)); } diff --git a/aimux-core/tests/decision_test.rs b/aimux-core/tests/decision_test.rs index 5b238de79..5c940bac9 100644 --- a/aimux-core/tests/decision_test.rs +++ b/aimux-core/tests/decision_test.rs @@ -115,8 +115,8 @@ fn retryable() -> AiMuxError { fn message(error: AiMuxError) -> String { match error { - AiMuxError::InvalidArgument(message) - | AiMuxError::InvalidResponseData(message) + AiMuxError::InvalidArgument { message, .. } + | AiMuxError::InvalidResponseData { message, .. } | AiMuxError::UnsupportedFunctionality(message) => message, other => panic!("unexpected error {other:?}"), } @@ -198,7 +198,7 @@ async fn invalid_input_is_rejected_before_the_model_is_called() { for (questions, expected) in cases { let options = DecideOptions::new("text", serde_json::from_value(questions).unwrap()); let error = decide(&model, options).await.unwrap_err(); - assert!(matches!(error, AiMuxError::InvalidArgument(_))); + assert!(matches!(error, AiMuxError::InvalidArgument { .. })); assert_eq!(message(error), expected); } assert!( @@ -287,7 +287,7 @@ async fn answers_are_validated_once_after_the_retries() { ))) }); let error = decide(&model, options()).await.unwrap_err(); - assert!(matches!(error, AiMuxError::InvalidResponseData(_))); + assert!(matches!(error, AiMuxError::InvalidResponseData { .. })); assert_eq!( message(error), "Decision must return exactly one answer for every question." diff --git a/aimux-core/tests/error_value_golden_test.rs b/aimux-core/tests/error_value_golden_test.rs index 0cc63dacb..be42b28c9 100644 --- a/aimux-core/tests/error_value_golden_test.rs +++ b/aimux-core/tests/error_value_golden_test.rs @@ -103,11 +103,11 @@ fn error_value_snapshot_retry_history() { fn error_value_snapshots_plain_variants() { for (err, expected) in [ ( - AiMuxError::JsonParse("bad json".into()), + AiMuxError::json_parse("bad json"), r#"{"name":"AI_JSONParseError","message":"bad json"}"#, ), ( - AiMuxError::InvalidResponseData("eof".into()), + AiMuxError::invalid_response_data("eof"), r#"{"name":"AI_InvalidResponseDataError","message":"eof"}"#, ), ( @@ -139,9 +139,9 @@ fn error_value_snapshots_plain_variants() { AiMuxError::InvalidToolInput { tool_name: "weather".into(), tool_input: "{".into(), - cause: "JSON parsing failed".into(), + cause: AiMuxError::json_parse("JSON parsing failed").into(), }, - r#"{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{","cause":"JSON parsing failed"}"#, + r#"{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{","cause":{"name":"AI_JSONParseError","message":"JSON parsing failed"}}"#, ), ( AiMuxError::ToolCallRepair { @@ -150,16 +150,16 @@ fn error_value_snapshots_plain_variants() { available_tools: None, tool_input: None, }), - cause: Box::new(AiMuxError::Other("repair model failed".into())), + cause: Box::new(AiMuxError::other("repair model failed")), }, r#"{"name":"AI_ToolCallRepairError","originalError":{"name":"AI_NoSuchToolError","toolName":"weathr"},"cause":{"name":"AI_Error","message":"repair model failed"}}"#, ), ( - AiMuxError::InvalidArgument("bad arg".into()), + AiMuxError::invalid_argument("bad arg"), r#"{"name":"AI_InvalidArgumentError","message":"bad arg"}"#, ), ( - AiMuxError::InvalidPrompt("bad prompt".into()), + AiMuxError::invalid_prompt("bad prompt"), r#"{"name":"AI_InvalidPromptError","message":"bad prompt"}"#, ), ( @@ -230,7 +230,7 @@ fn error_value_snapshots_plain_variants() { r#"{"name":"AbortError","message":"request aborted"}"#, ), ( - AiMuxError::Other("misc".into()), + AiMuxError::other("misc"), r#"{"name":"AI_Error","message":"misc"}"#, ), ] { @@ -306,26 +306,26 @@ fn variant_addition_breaks_this_match(error: &AiMuxError) { match error { AiMuxError::ApiCall(_) | AiMuxError::Retry(_) - | AiMuxError::JsonParse(_) - | AiMuxError::InvalidResponseData(_) + | AiMuxError::JsonParse { .. } + | AiMuxError::InvalidResponseData { .. } | AiMuxError::ToolCallNotFoundForApproval { .. } | AiMuxError::NoSuchTool { .. } | AiMuxError::InvalidToolInput { .. } | AiMuxError::ToolCallRepair { .. } - | AiMuxError::InvalidArgument(_) - | AiMuxError::InvalidPrompt(_) + | AiMuxError::InvalidArgument { .. } + | AiMuxError::InvalidPrompt { .. } | AiMuxError::LoadApiKey { .. } | AiMuxError::LoadSetting { .. } | AiMuxError::MissingToolResults { .. } | AiMuxError::ToolChoiceViolation(_) - | AiMuxError::NoOutputGenerated(_) + | AiMuxError::NoOutputGenerated { .. } | AiMuxError::TokenExpired(_) | AiMuxError::UnsupportedFunctionality(_) | AiMuxError::NoSuchModel { .. } | AiMuxError::NoSuchProvider { .. } | AiMuxError::Timeout(_) | AiMuxError::Aborted(_) - | AiMuxError::Other(_) => {} + | AiMuxError::Other { .. } => {} } } @@ -337,8 +337,8 @@ fn variant_set_is_exactly_the_wire_tag_list() { reason: RetryErrorReason::MaxRetriesExceeded, errors: vec![AiMuxError::ApiCall(Box::new(api_error("x")))], }), - AiMuxError::JsonParse("x".into()), - AiMuxError::InvalidResponseData("x".into()), + AiMuxError::json_parse("x"), + AiMuxError::invalid_response_data("x"), AiMuxError::ToolCallNotFoundForApproval { tool_call_id: "x".into(), approval_id: "x".into(), @@ -351,7 +351,7 @@ fn variant_set_is_exactly_the_wire_tag_list() { AiMuxError::InvalidToolInput { tool_name: "x".into(), tool_input: "{}".into(), - cause: "x".into(), + cause: AiMuxError::json_parse("x").into(), }, AiMuxError::ToolCallRepair { original_error: Box::new(AiMuxError::NoSuchTool { @@ -359,10 +359,10 @@ fn variant_set_is_exactly_the_wire_tag_list() { available_tools: None, tool_input: None, }), - cause: Box::new(AiMuxError::Other("x".into())), + cause: Box::new(AiMuxError::other("x")), }, - AiMuxError::InvalidArgument("x".into()), - AiMuxError::InvalidPrompt("x".into()), + AiMuxError::invalid_argument("x"), + AiMuxError::invalid_prompt("x"), AiMuxError::LoadApiKey { env_var: "X".into(), description: "x".into(), @@ -381,7 +381,7 @@ fn variant_set_is_exactly_the_wire_tag_list() { model_id: "x".into(), content: vec![], })), - AiMuxError::NoOutputGenerated("x".into()), + AiMuxError::no_output_generated("x"), AiMuxError::TokenExpired("x".into()), AiMuxError::UnsupportedFunctionality("x".into()), AiMuxError::NoSuchModel { @@ -396,7 +396,7 @@ fn variant_set_is_exactly_the_wire_tag_list() { }, AiMuxError::Timeout("x".into()), AiMuxError::Aborted("request aborted".into()), - AiMuxError::Other("x".into()), + AiMuxError::other("x"), ]; // The wire tag is the AI SDK's error name, under `name`. let mut names: Vec = all diff --git a/aimux-core/tests/generate_checks_test.rs b/aimux-core/tests/generate_checks_test.rs index abad497f4..ce669362c 100644 --- a/aimux-core/tests/generate_checks_test.rs +++ b/aimux-core/tests/generate_checks_test.rs @@ -157,7 +157,7 @@ async fn empty_messages_are_an_invalid_prompt() { .await .unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidPrompt(message) if message == "messages must not be empty"), + matches!(&error, AiMuxError::InvalidPrompt { message, .. } if message == "messages must not be empty"), "{error:?}" ); let error = stream_text( @@ -167,7 +167,10 @@ async fn empty_messages_are_an_invalid_prompt() { ) .await .unwrap_err(); - assert!(matches!(error, AiMuxError::InvalidPrompt(_)), "{error:?}"); + assert!( + matches!(error, AiMuxError::InvalidPrompt { .. }), + "{error:?}" + ); } // `generate-text.ts:1155-1176`: a `required` choice needs some tool call, a diff --git a/aimux-core/tests/master_recording_test.rs b/aimux-core/tests/master_recording_test.rs index 6f7e1dec5..d8c382fb9 100644 --- a/aimux-core/tests/master_recording_test.rs +++ b/aimux-core/tests/master_recording_test.rs @@ -122,14 +122,14 @@ async fn what_cannot_be_replayed_fails_at_replay_time() { let err = replay_with_model(&recordings[1], &mock, None) .await .unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err}"); + assert!(matches!(err, AiMuxError::InvalidArgument { .. }), "{err}"); assert!(err.to_string().contains("recorded prompt"), "{err}"); // ... and master's call options (`tool_choice: "auto"`), naming the writer. let err = replay_with_model(&recordings[0], &mock, None) .await .unwrap_err(); - assert!(matches!(err, AiMuxError::JsonParse(_)), "{err}"); + assert!(matches!(err, AiMuxError::JsonParse { .. }), "{err}"); assert!(err.to_string().contains("aimux 0.5 or earlier"), "{err}"); } @@ -177,7 +177,7 @@ async fn master_decision_recordings_load_and_explain_at_replay() { let err = replay_decision_with_model(&recordings[0], &mock) .await .unwrap_err(); - assert!(matches!(err, AiMuxError::JsonParse(_)), "{err}"); + assert!(matches!(err, AiMuxError::JsonParse { .. }), "{err}"); assert!(err.to_string().contains("aimux 0.5 or earlier"), "{err}"); // The same call in this version's shape (the V4 Jev fixture) finds no diff --git a/aimux-core/tests/tool_input_test.rs b/aimux-core/tests/tool_input_test.rs index 3aa7c9e5f..3c679e10c 100644 --- a/aimux-core/tests/tool_input_test.rs +++ b/aimux-core/tests/tool_input_test.rs @@ -353,7 +353,7 @@ async fn repair_returning_none_keeps_the_original_failure() { async fn repair_failure_keeps_both_typed_errors() { let repair = ToolCallRepair::new(|context| async move { assert_eq!(context.input_schema("weather"), weather_tool_schema()); - Err(AiMuxError::Other("repair model failed".into())) + Err(AiMuxError::other("repair model failed")) }); let call = parse_tool_call( @@ -369,7 +369,7 @@ async fn repair_failure_keeps_both_typed_errors() { call.error, Some(AiMuxError::ToolCallRepair { original_error, cause }) if matches!(*original_error, AiMuxError::InvalidToolInput { .. }) - && matches!(*cause, AiMuxError::Other(_)) + && matches!(*cause, AiMuxError::Other { .. }) )); } diff --git a/aimux-ffi/src/abi.rs b/aimux-ffi/src/abi.rs index 2444fc558..656c9e898 100644 --- a/aimux-ffi/src/abi.rs +++ b/aimux-ffi/src/abi.rs @@ -612,9 +612,7 @@ mod tests { #[test] fn message_getter_sanitizes_nul_and_keeps_the_json_envelope_exact() { - let error = finish(Err(OpsError::AiMux(aimux_core::AiMuxError::Other( - "a\0b".into(), - )))); + let error = finish(Err(OpsError::AiMux(aimux_core::AiMuxError::other("a\0b")))); let message = aimux_error_message(error); assert!(!message.is_null()); assert!( diff --git a/aimux-ffi/tests/c/raii.cpp b/aimux-ffi/tests/c/raii.cpp index 7d24b8226..3670ce7cb 100644 --- a/aimux-ffi/tests/c/raii.cpp +++ b/aimux-ffi/tests/c/raii.cpp @@ -17,7 +17,9 @@ int main() { check_error(error.release(), 300); bool caught = false; try { aimux::check(aimux_call(0, "not.an.op", "{}", 0, &raw)); } - catch (const std::runtime_error &) { caught = true; } + catch (const aimux::NativeError &e) { + caught = e.code() == 300 && e.json().find("\"InvalidOp\"") != std::string::npos; + } assert(caught); aimux::Handle first(create_handle("abort.create", 0, "{}")); diff --git a/aimux-ops/src/envelope.rs b/aimux-ops/src/envelope.rs index eb2230a94..0c428093b 100644 --- a/aimux-ops/src/envelope.rs +++ b/aimux-ops/src/envelope.rs @@ -146,14 +146,14 @@ impl OpsError { match self { Self::Projected(error) => error.envelope.code, Self::AiMux(error) => match error { - AiMuxError::Other(_) => 1, - AiMuxError::JsonParse(_) => 2, - AiMuxError::InvalidResponseData(_) + AiMuxError::Other { .. } => 1, + AiMuxError::JsonParse { .. } => 2, + AiMuxError::InvalidResponseData { .. } | AiMuxError::ToolCallNotFoundForApproval { .. } | AiMuxError::ToolChoiceViolation(_) => 3, // Code 4 is retired. Preserve the existing FFI groupings. - AiMuxError::InvalidArgument(_) => 5, - AiMuxError::InvalidPrompt(_) | AiMuxError::MissingToolResults { .. } => 6, + AiMuxError::InvalidArgument { .. } => 5, + AiMuxError::InvalidPrompt { .. } | AiMuxError::MissingToolResults { .. } => 6, AiMuxError::TokenExpired(_) => 7, AiMuxError::UnsupportedFunctionality(_) => 8, AiMuxError::NoSuchModel { .. } => 9, @@ -165,7 +165,7 @@ impl OpsError { AiMuxError::NoSuchTool { .. } => 15, AiMuxError::InvalidToolInput { .. } => 16, AiMuxError::ToolCallRepair { .. } => 17, - AiMuxError::NoOutputGenerated(_) => 18, + AiMuxError::NoOutputGenerated { .. } => 18, AiMuxError::LoadApiKey { .. } => 19, AiMuxError::LoadSetting { .. } => 20, }, diff --git a/aimux-ops/src/ops/misc.rs b/aimux-ops/src/ops/misc.rs index 4df353e34..9d6f7dab5 100644 --- a/aimux-ops/src/ops/misc.rs +++ b/aimux-ops/src/ops/misc.rs @@ -1,5 +1,6 @@ //! Catalogue, host-side repair, subscription refresh and OpenAI output conversion. +use aimux_core::ErrorCause; use std::time::Duration; use aimux_core::{ @@ -238,9 +239,10 @@ fn catalogue_fetch(ctx: Ctx, _target: Target, value: Value) -> CallFuture { .await .map_err(OpsError::AiMux)?; let raw = serde_json::from_str(&String::from_utf8_lossy(&response.value)).map_err(|e| { - OpsError::AiMux(AiMuxError::JsonParse(format!( - "get_model_specs: invalid source JSON: {e}" - ))) + OpsError::AiMux(AiMuxError::JsonParse { + message: format!("get_model_specs: invalid source JSON: {e}"), + cause: Some(ErrorCause::source(&e)), + }) })?; let catalogue = aimux_providers::catalogue::parse_anya2a_all(&raw).map_err(OpsError::AiMux)?; @@ -490,7 +492,7 @@ mod tests { } assert!(matches!( repair_apply(ctx(), entry(), json!({"toolCall":call,"reply":reply()})).await, - Err(OpsError::AiMux(AiMuxError::InvalidArgument(_))) + Err(OpsError::AiMux(AiMuxError::InvalidArgument { .. })) )); } @@ -558,7 +560,7 @@ mod tests { ] { assert!(matches!( catalogue_fetch(ctx(), entry(), json!({"sourceUrl":url})).await, - Err(OpsError::AiMux(AiMuxError::InvalidArgument(_))) + Err(OpsError::AiMux(AiMuxError::InvalidArgument { .. })) )); } } diff --git a/aimux-ops/src/ops/modalities.rs b/aimux-ops/src/ops/modalities.rs index f62e429e6..4faed5bc5 100644 --- a/aimux-ops/src/ops/modalities.rs +++ b/aimux-ops/src/ops/modalities.rs @@ -729,9 +729,7 @@ mod tests { let model = Arc::new(MockModel { output: Value::Null, calls: Mutex::new(vec![]), - failure: Some(AiMuxError::InvalidArgument( - "provider rejected query".into(), - )), + failure: Some(AiMuxError::invalid_argument("provider rejected query")), }); let error = invoke( "search.search", @@ -740,7 +738,7 @@ mod tests { ) .await; assert!( - matches!(error.unwrap_err().unprojected(), OpsError::AiMux(AiMuxError::InvalidArgument(message)) if message == "provider rejected query") + matches!(error.unwrap_err().unprojected(), OpsError::AiMux(AiMuxError::InvalidArgument { message, .. }) if message == "provider rejected query") ); assert_eq!(model.calls.lock().unwrap().len(), 1); } diff --git a/aimux-ops/src/ops/provider.rs b/aimux-ops/src/ops/provider.rs index 57d50dfef..ab57454e4 100644 --- a/aimux-ops/src/ops/provider.rs +++ b/aimux-ops/src/ops/provider.rs @@ -420,8 +420,8 @@ fn create_package( || settings.project.is_some() || !settings.params.is_empty() { - return Err(OpsError::AiMux(AiMuxError::InvalidArgument( - "Jev accepts apiKey, endpoint, headers and probabilitySource settings".into(), + return Err(OpsError::AiMux(AiMuxError::invalid_argument( + "Jev accepts apiKey, endpoint, headers and probabilitySource settings", ))); } let mut config = match settings.api_key { @@ -446,8 +446,8 @@ fn create_package( ); } if settings.endpoint.is_some() || settings.probability_source.is_some() { - return Err(OpsError::AiMux(AiMuxError::InvalidArgument( - "endpoint and probabilitySource settings require the Jev provider".into(), + return Err(OpsError::AiMux(AiMuxError::invalid_argument( + "endpoint and probabilitySource settings require the Jev provider", ))); } let mut headers = settings.headers; @@ -775,7 +775,7 @@ mod tests { owned_by: None, created: None, }]), - Discovery::Error => Err(AiMuxError::InvalidArgument("discovery failed".into())), + Discovery::Error => Err(AiMuxError::invalid_argument("discovery failed")), Discovery::Pending => futures::future::pending().await, } }) @@ -1024,7 +1024,7 @@ mod tests { let (provider, _) = mock(owner, Discovery::Error); assert!( matches!(call(owner, provider, "provider.invoke", json!({"method":"listModels"})).await, - Err(OpsError::AiMux(AiMuxError::InvalidArgument(message))) if message == "discovery failed") + Err(OpsError::AiMux(AiMuxError::InvalidArgument { message, .. })) if message == "discovery failed") ); let provider = handle( owner, diff --git a/aimux-ops/src/ops/text.rs b/aimux-ops/src/ops/text.rs index fa2f79beb..b882688c8 100644 --- a/aimux-ops/src/ops/text.rs +++ b/aimux-ops/src/ops/text.rs @@ -575,7 +575,7 @@ mod tests { return futures::future::pending().await; } if self.setup_error { - return Err(AiMuxError::Other("setup failed".into())); + return Err(AiMuxError::other("setup failed")); } let stream: Pin> + Send>> = if self.pending { @@ -682,12 +682,12 @@ mod tests { model.parts.insert( 1, Ok(StreamPart::Error { - error: AiMuxError::Other("provider warning".into()), + error: AiMuxError::other("provider warning"), }), ); model .parts - .insert(2, Err(AiMuxError::JsonParse("malformed frame".into()))); + .insert(2, Err(AiMuxError::json_parse("malformed frame"))); let events = stream(ctx(), model.entry(), request()) .unwrap() .collect::>() @@ -709,7 +709,7 @@ mod tests { let mut model = MockModel::new("before"); model .parts - .insert(3, Err(AiMuxError::Other("connection lost".into()))); + .insert(3, Err(AiMuxError::other("connection lost"))); let mut events = if openai { stream_openai(ctx(), model.entry(), request()) } else { @@ -718,7 +718,7 @@ mod tests { .unwrap(); let mut errors = 0; while let Some(event) = events.next().await { - if let Err(OpsError::AiMux(AiMuxError::Other(message))) = event { + if let Err(OpsError::AiMux(AiMuxError::Other { message, .. })) = event { assert_eq!(message, "connection lost"); errors += 1; assert!(events.next().await.is_none()); @@ -744,7 +744,7 @@ mod tests { .await; assert_eq!(events.len(), 1); assert!( - matches!(&events[0], Err(OpsError::AiMux(AiMuxError::Other(message))) if message == "setup failed") + matches!(&events[0], Err(OpsError::AiMux(AiMuxError::Other { message, .. })) if message == "setup failed") ); } } @@ -754,7 +754,7 @@ mod tests { let mut model = MockModel::new("after"); model .parts - .insert(1, Err(AiMuxError::JsonParse("malformed frame".into()))); + .insert(1, Err(AiMuxError::json_parse("malformed frame"))); let events = stream_openai( ctx(), model.entry(), @@ -1045,11 +1045,11 @@ mod tests { match case { "recoverable" => model .parts - .insert(1, Err(AiMuxError::JsonParse("malformed frame".into()))), + .insert(1, Err(AiMuxError::json_parse("malformed frame"))), "setup" => model.setup_error = true, "runtime" => model .parts - .insert(3, Err(AiMuxError::Other("connection lost".into()))), + .insert(3, Err(AiMuxError::other("connection lost"))), "large" => model.parts.insert( 3, Ok(StreamPart::TextDelta { diff --git a/aimux-ops/src/ops/trace.rs b/aimux-ops/src/ops/trace.rs index b6bdddf1b..951c96a98 100644 --- a/aimux-ops/src/ops/trace.rs +++ b/aimux-ops/src/ops/trace.rs @@ -128,9 +128,9 @@ fn aggregate(_ctx: Ctx, target: Target, value: Value) -> CallFuture { fn session_chain(_ctx: Ctx, target: Target, value: Value) -> CallFuture { Box::pin(async move { let p: TraceSessionParams = decode(value)?; - let view = store(target)?.session_chain(&p.session_id).ok_or_else(|| { - OpsError::AiMux(AiMuxError::InvalidArgument("unknown session".into())) - })?; + let view = store(target)? + .session_chain(&p.session_id) + .ok_or_else(|| OpsError::AiMux(AiMuxError::invalid_argument("unknown session")))?; result(view) }) } diff --git a/aimux-ops/tests/composite.rs b/aimux-ops/tests/composite.rs index 657062f6f..116068a70 100644 --- a/aimux-ops/tests/composite.rs +++ b/aimux-ops/tests/composite.rs @@ -66,7 +66,7 @@ impl LanguageModel for FailingChild { 'b: 'f, Self: 'f, { - Box::pin(async { Err(AiMuxError::Other("mock stream".into())) }) + Box::pin(async { Err(AiMuxError::other("mock stream")) }) } } diff --git a/aimux-ops/tests/dispatch_contract.rs b/aimux-ops/tests/dispatch_contract.rs index 1b1d5e266..163ed2566 100644 --- a/aimux-ops/tests/dispatch_contract.rs +++ b/aimux-ops/tests/dispatch_contract.rs @@ -221,7 +221,7 @@ fn events(_: Ctx, _: Target, params: Value) -> Result { _ => Ok(Box::pin(futures::stream::iter([ Ok(json!({"type":"error"})), Ok(json!({"type":"finish"})), - Err(AiMuxError::Other("terminal".into()).into()), + Err(AiMuxError::other("terminal").into()), Ok(Value::Null), ]))), } diff --git a/aimux-ops/tests/error_envelope_golden.rs b/aimux-ops/tests/error_envelope_golden.rs index 0bc9397e7..33579aa50 100644 --- a/aimux-ops/tests/error_envelope_golden.rs +++ b/aimux-ops/tests/error_envelope_golden.rs @@ -354,7 +354,7 @@ fn only_direct_api_calls_get_top_level_retry_hints() { }), AiMuxError::ToolCallRepair { original_error: Box::new(attempt), - cause: Box::new(AiMuxError::Other("repair failed".into())), + cause: Box::new(AiMuxError::other("repair failed")), }, AiMuxError::Timeout("timeout".into()), AiMuxError::Aborted("aborted".into()), diff --git a/aimux-ops/tests/handles.rs b/aimux-ops/tests/handles.rs index c9ed33af6..f126a529c 100644 --- a/aimux-ops/tests/handles.rs +++ b/aimux-ops/tests/handles.rs @@ -211,7 +211,7 @@ async fn builtin_download_keeps_the_guard_and_model_supported_url_policy() { is_url_supported_by_model: false }]) .await, - Err(AiMuxError::InvalidArgument(_)) + Err(AiMuxError::InvalidArgument { .. }) )); assert_eq!( download diff --git a/aimux-ops/tests/operation.rs b/aimux-ops/tests/operation.rs index a73c6f9f5..23029dca7 100644 --- a/aimux-ops/tests/operation.rs +++ b/aimux-ops/tests/operation.rs @@ -64,7 +64,7 @@ impl TranscriptionModel for MockModel { 'b: 'c, Self: 'c, { - Box::pin(async { Err(AiMuxError::Other("batch generation is unused".into())) }) + Box::pin(async { Err(AiMuxError::other("batch generation is unused")) }) } fn do_stream( @@ -79,8 +79,8 @@ impl TranscriptionModel for MockModel { let stream: Pin< Box> + Send>, > = match &self.mode { - Mode::Unsupported => return Err(AiMuxError::Other("unused".into())), - Mode::ConnectFailure => return Err(AiMuxError::Other("connect failed".into())), + Mode::Unsupported => return Err(AiMuxError::other("unused")), + Mode::ConnectFailure => return Err(AiMuxError::other("connect failed")), Mode::Pending => return std::future::pending().await, Mode::Echo(gate) => { gate.cancelled().await; @@ -282,7 +282,7 @@ async fn connection_failure_is_saved_and_delivered_once() { ) .await; assert!( - matches!(next(&operation).await, Err(OpsError::AiMux(AiMuxError::Other(message))) if message == "connect failed") + matches!(next(&operation).await, Err(OpsError::AiMux(AiMuxError::Other { message, .. })) if message == "connect failed") ); assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); diff --git a/aimux-ops/tests/provider.rs b/aimux-ops/tests/provider.rs index 486a8536a..5b1eef6a3 100644 --- a/aimux-ops/tests/provider.rs +++ b/aimux-ops/tests/provider.rs @@ -113,7 +113,7 @@ async fn jev_preserves_constructor_errors_and_rejects_unavailable_settings() { assert_eq!(error.code(), 5); assert!(matches!( error.unprojected(), - OpsError::AiMux(AiMuxError::InvalidArgument(_)) + OpsError::AiMux(AiMuxError::InvalidArgument { .. }) )); } let provider = jev(owner, "native").await; diff --git a/aimux-ops/tests/registry.rs b/aimux-ops/tests/registry.rs index 1ccf5beb0..cf1a45559 100644 --- a/aimux-ops/tests/registry.rs +++ b/aimux-ops/tests/registry.rs @@ -38,7 +38,7 @@ impl LanguageModel for Model { 'b: 'f, Self: 'f, { - Box::pin(async { Err(AiMuxError::Other("generation reached".into())) }) + Box::pin(async { Err(AiMuxError::other("generation reached")) }) } fn do_stream<'a, 'b, 'f>( &'a self, @@ -49,7 +49,7 @@ impl LanguageModel for Model { 'b: 'f, Self: 'f, { - Box::pin(async { Err(AiMuxError::Other("stream reached".into())) }) + Box::pin(async { Err(AiMuxError::other("stream reached")) }) } } impl ImageModel for Model { @@ -71,7 +71,7 @@ impl ImageModel for Model { 'b: 'f, Self: 'f, { - Box::pin(async { Err(AiMuxError::Other("image reached".into())) }) + Box::pin(async { Err(AiMuxError::other("image reached")) }) } } impl DecisionModel for Model { @@ -93,7 +93,7 @@ impl DecisionModel for Model { 'b: 'f, Self: 'f, { - Box::pin(async { Err(AiMuxError::Other("decision reached".into())) }) + Box::pin(async { Err(AiMuxError::other("decision reached")) }) } } diff --git a/aimux-ops/tests/trace.rs b/aimux-ops/tests/trace.rs index f5abebb29..e4bcfefbf 100644 --- a/aimux-ops/tests/trace.rs +++ b/aimux-ops/tests/trace.rs @@ -155,7 +155,7 @@ async fn sessions_export_and_idempotent_clear_preserve_ffi_query_semantics() { .await .unwrap_err(); assert!( - matches!(error.unprojected(), OpsError::AiMux(aimux_core::AiMuxError::InvalidArgument(message)) if message == "unknown session") + matches!(error.unprojected(), OpsError::AiMux(aimux_core::AiMuxError::InvalidArgument { message, .. }) if message == "unknown session") ); let envelope = serde_json::to_value(error.envelope(SystemTime::now())).unwrap(); assert_eq!(envelope["kind"], "aimux"); diff --git a/aimux-provider-utils/src/decision_language_model.rs b/aimux-provider-utils/src/decision_language_model.rs index 32cdfef45..a89cfb8ea 100644 --- a/aimux-provider-utils/src/decision_language_model.rs +++ b/aimux-provider-utils/src/decision_language_model.rs @@ -39,11 +39,11 @@ impl DecisionLanguageModel { } fn invalid(message: impl Into) -> AiMuxError { - AiMuxError::InvalidResponseData(message.into()) + AiMuxError::invalid_response_data(message.into()) } fn criteria_error(id: &str) -> AiMuxError { - AiMuxError::InvalidArgument(format!( + AiMuxError::invalid_argument(format!( "Invalid argument for parameter questions.{id}.criteria: Choice requires at least one option; Score requires at least two levels." )) } @@ -90,7 +90,7 @@ impl DecisionModel for DecisionLanguageModel { } } if entries.is_empty() { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "Invalid argument for parameter questions: Decision requires at least one question." .to_string(), )); diff --git a/aimux-provider-utils/src/download_blob.rs b/aimux-provider-utils/src/download_blob.rs index f47e256f0..97d90b186 100644 --- a/aimux-provider-utils/src/download_blob.rs +++ b/aimux-provider-utils/src/download_blob.rs @@ -115,7 +115,7 @@ mod tests { .await .unwrap_err(); assert!( - matches!(error, AiMuxError::InvalidArgument(ref message) if message.contains("non-public")), + matches!(error, AiMuxError::InvalidArgument { ref message, .. } if message.contains("non-public")), "{error:?}" ); } diff --git a/aimux-provider-utils/src/fetch.rs b/aimux-provider-utils/src/fetch.rs index 245291313..7d2930aa6 100644 --- a/aimux-provider-utils/src/fetch.rs +++ b/aimux-provider-utils/src/fetch.rs @@ -31,7 +31,7 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; use url::Url; -use aimux_core::AbortSignal; +use aimux_core::{AbortSignal, SourceError}; /// Proxy routing of one transport: a [`ReqwestFetch`], a [`PinnedFetch`] /// hop, or the WebSocket connector. With every field empty, HTTP follows the @@ -161,11 +161,15 @@ impl fmt::Debug for FetchResponse { /// A transport-level failure: no HTTP response was produced, or the body /// stream broke. HTTP error statuses are *not* errors here — they come back /// as a [`FetchResponse`] and are interpreted by the response handlers. +/// +/// Each failure keeps the transport's error with its whole `source()` chain +/// (for a refused connection, the OS error at the bottom of reqwest's chain), +/// which becomes the `cause` of the resulting `ApiCallError`. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum FetchError { /// The connection could not be established. - #[error("{0}")] - Connect(String), + #[error(transparent)] + Connect(SourceError), /// The transport's own timeout fired. #[error("request timed out")] Timeout, @@ -173,11 +177,11 @@ pub enum FetchError { #[error("request aborted")] Aborted, /// The connection failed while sending the request or reading the body. - #[error("{0}")] - Io(String), + #[error(transparent)] + Io(SourceError), /// Anything else, including requests the transport refuses to build. - #[error("{0}")] - Other(String), + #[error(transparent)] + Other(SourceError), } impl FetchError { @@ -333,7 +337,10 @@ impl ReqwestFetch { return Ok(client.clone()); } let client = build_client(&self.proxy).map_err(|error| { - FetchError::Other(format!("HTTP client initialization failed: {error}")) + FetchError::Other(SourceError::with_cause( + format!("HTTP client initialization failed: {error}"), + &error, + )) })?; if clients.len() >= CLIENTS_PER_TRANSPORT { // Which entries are dead is unknowable, so evict them all; @@ -386,12 +393,15 @@ impl PinnedFetch { let builder = apply_proxy(builder, &self.proxy); if self.addresses.is_empty() { return builder.build().map_err(|error| { - FetchError::Other(format!("download client initialization failed: {error}")) + FetchError::Other(SourceError::with_cause( + format!("download client initialization failed: {error}"), + &error, + )) }); } let host = url .host_str() - .ok_or_else(|| FetchError::Other("download url has no host".to_string()))?; + .ok_or_else(|| FetchError::Other("download url has no host".into()))?; let port = url.port_or_known_default().unwrap_or(80); let socket_addresses: Vec<_> = self .addresses @@ -402,8 +412,9 @@ impl PinnedFetch { .resolve_to_addrs(host, &socket_addresses) .build() .map_err(|error| { - FetchError::Other(format!( - "pinned download client initialization failed: {error}" + FetchError::Other(SourceError::with_cause( + format!("pinned download client initialization failed: {error}"), + &error, )) }) } @@ -442,15 +453,18 @@ async fn send(client: &Client, request: FetchRequest) -> Result FetchError { + let error = error.without_url(); if error.is_builder() { - FetchError::Other(error.to_string()) + FetchError::Other(SourceError::new(&error)) } else if error.is_connect() { - FetchError::Connect(error.to_string()) + FetchError::Connect(SourceError::new(&error)) } else if error.is_timeout() { FetchError::Timeout } else { - FetchError::Io(error.to_string()) + FetchError::Io(SourceError::new(&error)) } } @@ -461,16 +475,14 @@ fn map_reqwest_error(error: reqwest::Error) -> FetchError { // client on its next request. const CLIENTS_PER_TRANSPORT: usize = 8; -fn build_client(proxy: &ProxyConfig) -> Result { +fn build_client(proxy: &ProxyConfig) -> Result { let builder = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .connect_timeout(Duration::from_millis(10_000)) .pool_max_idle_per_host(10) .pool_idle_timeout(Some(Duration::from_secs(30))) .tcp_keepalive(Some(Duration::from_secs(20))); - apply_proxy(builder, proxy) - .build() - .map_err(|error| error.to_string()) + apply_proxy(builder, proxy).build() } fn apply_proxy(mut builder: reqwest::ClientBuilder, proxy: &ProxyConfig) -> reqwest::ClientBuilder { @@ -560,6 +572,49 @@ mod tests { ); } + /// A refused connection keeps reqwest's whole chain down to the OS error, + /// and drops the URL, whose query may carry an API key. + #[tokio::test] + async fn a_refused_connection_keeps_its_chain_without_the_url() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + let error = ReqwestFetch::default() + .fetch(get(&format!("http://{address}/v1?key=SECRET"))) + .await + .unwrap_err(); + let FetchError::Connect(source) = &error else { + panic!("{error:?}") + }; + let chain: Vec<&str> = std::iter::successors(Some(source), |error| error.cause.as_deref()) + .map(|error| error.message.as_str()) + .collect(); + assert!(chain.len() > 1, "{chain:?}"); + assert!( + chain.iter().all(|message| !message.contains("SECRET")), + "{chain:?}" + ); + assert!( + chain.last().unwrap().to_lowercase().contains("refused"), + "{chain:?}" + ); + + let api = crate::handle_fetch_error::fetch_error_to_ai_mux_error( + error.clone(), + "https://api.example/v1", + &serde_json::Value::Null, + ); + let aimux_core::AiMuxError::ApiCall(api) = api else { + panic!("{api:?}") + }; + assert!(api.is_retryable); + assert!( + matches!(&api.cause, Some(aimux_core::ErrorCause::Source(cause)) if cause == source), + "{:?}", + api.cause + ); + } + /// `default_fetch` is read per request: replacing the default transport /// reroutes a transport handle taken later, and the WebSocket connector /// and download hops read the same settings. diff --git a/aimux-provider-utils/src/handle_fetch_error.rs b/aimux-provider-utils/src/handle_fetch_error.rs index 76b7a1160..88ee26196 100644 --- a/aimux-provider-utils/src/handle_fetch_error.rs +++ b/aimux-provider-utils/src/handle_fetch_error.rs @@ -1,6 +1,6 @@ //! Fetch error normalization. -use aimux_core::{AiMuxError, ApiCallError}; +use aimux_core::{AiMuxError, ApiCallError, ErrorCause}; use crate::fetch::FetchError; @@ -24,7 +24,9 @@ pub fn handle_fetch_error( /// Convert a transport failure into the public error model: an abort stays an /// abort; everything else is an `ApiCall` error with no status (no response -/// arrived), retryable exactly when [`FetchError::is_retryable`] says so. +/// arrived), retryable exactly when [`FetchError::is_retryable`] says so, with +/// the transport error and its source chain as its `cause` (the AI SDK's +/// `handleFetchError`, `@ai-sdk/provider-utils` `handle-fetch-error.ts`). #[must_use] pub(crate) fn fetch_error_to_ai_mux_error( error: FetchError, @@ -35,6 +37,7 @@ pub(crate) fn fetch_error_to_ai_mux_error( FetchError::Aborted => AiMuxError::Aborted("request aborted".into()), other => AiMuxError::ApiCall(Box::new(ApiCallError { is_retryable: other.is_retryable(), + cause: Some(ErrorCause::source(&other)), ..ApiCallError::new(other.to_string(), url, request_body_values.clone()) })), } diff --git a/aimux-provider-utils/src/http.rs b/aimux-provider-utils/src/http.rs index 7af4117c2..eaa269a38 100644 --- a/aimux-provider-utils/src/http.rs +++ b/aimux-provider-utils/src/http.rs @@ -12,6 +12,7 @@ use std::time::{Duration, Instant}; use bytes::Bytes; use futures::{Stream, StreamExt, stream::BoxStream}; +use aimux_core::ErrorCause; use aimux_core::recording::{ HttpExchange, HttpRecord, RecordingContext, ResponseRecord, TimingRecord, }; @@ -401,9 +402,11 @@ fn redirect_error( request: &PreparedRequest, message: impl Into, status: http::StatusCode, + cause: Option, ) -> AiMuxError { AiMuxError::ApiCall(Box::new(ApiCallError { status_code: Some(status.as_u16()), + cause, ..api_call_error(request, message) })) } @@ -471,15 +474,26 @@ async fn send_following_redirects(request: &PreparedRequest) -> Result Result Result Result { - let url = url::Url::parse(&request.url) - .map_err(|error| AiMuxError::InvalidArgument(format!("invalid request URL: {error}")))?; + let url = url::Url::parse(&request.url).map_err(|error| AiMuxError::InvalidArgument { + message: format!("invalid request URL: {error}"), + cause: Some(ErrorCause::source(&error)), + })?; let mut headers = http::HeaderMap::new(); for (name, value) in &request.headers { - let header_name = http::HeaderName::try_from(name) - .map_err(|_| AiMuxError::InvalidArgument(format!("invalid header name: {name}")))?; + let header_name = + http::HeaderName::try_from(name).map_err(|error| AiMuxError::InvalidArgument { + message: format!("invalid header name: {name}"), + cause: Some(ErrorCause::source(&error)), + })?; // The value is never echoed: it is commonly a credential. - let header_value = http::HeaderValue::try_from(value) - .map_err(|_| AiMuxError::InvalidArgument(format!("invalid header value for {name}")))?; + let header_value = + http::HeaderValue::try_from(value).map_err(|error| AiMuxError::InvalidArgument { + message: format!("invalid header value for {name}"), + cause: Some(ErrorCause::source(&error)), + })?; headers.insert(header_name, header_value); } if request.provider_utils_user_agent { @@ -562,8 +585,11 @@ fn build_fetch_request(request: &PreparedRequest) -> Result Result { - let content_type = http::HeaderValue::try_from(content_type).map_err(|_| { - AiMuxError::InvalidArgument(format!("invalid content type: {content_type}")) + let content_type = http::HeaderValue::try_from(content_type).map_err(|error| { + AiMuxError::InvalidArgument { + message: format!("invalid content type: {content_type}"), + cause: Some(ErrorCause::source(&error)), + } })?; headers.insert(http::header::CONTENT_TYPE, content_type); Bytes::copy_from_slice(bytes) diff --git a/aimux-provider-utils/src/media_type.rs b/aimux-provider-utils/src/media_type.rs index 1127ac960..e87fda96f 100644 --- a/aimux-provider-utils/src/media_type.rs +++ b/aimux-provider-utils/src/media_type.rs @@ -81,7 +81,7 @@ fn decode_prefix(data: MediaTypeData<'_>, max_bytes: usize) -> Result Result<&mut Self, AiMuxError> { validate_multipart_param(name, "name")?; @@ -65,7 +65,7 @@ impl MultipartForm { /// /// # Errors /// - /// Returns `AiMuxError::InvalidArgument` when `name`, `filename`, or + /// Returns `AiMuxError::invalid_argument` when `name`, `filename`, or /// `media_type` fails MIME-header validation. pub fn file( &mut self, @@ -161,7 +161,7 @@ fn validate_multipart_param(value: &str, label: &str) -> Result<(), AiMuxError> .iter() .any(|&b| b == b'"' || b == b'\r' || b == b'\n' || b == 0) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "invalid multipart {label}: must not contain '\"', CR, LF or NUL" ))); } @@ -210,13 +210,16 @@ mod tests { // A double-quote would break out of the quoted-string. let mut form = MultipartForm::new(); let err = form.text("evil\"", "value").unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "got {err:?}"); + assert!( + matches!(err, AiMuxError::InvalidArgument { .. }), + "got {err:?}" + ); assert!(err.to_string().contains("multipart name")); // CRLF would inject a new header line. let mut form = MultipartForm::new(); let err = form.text("evil\r\nX-Injected: yes", "value").unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_))); + assert!(matches!(err, AiMuxError::InvalidArgument { .. })); // Nothing should have been appended after a rejected field. let mut form = MultipartForm::new(); @@ -234,7 +237,7 @@ mod tests { let err = form .file("file", "evil\".wav", "audio/wav", b"data") .unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_))); + assert!(matches!(err, AiMuxError::InvalidArgument { .. })); assert!(err.to_string().contains("multipart filename")); // Malicious filename (CRLF injection). @@ -242,14 +245,14 @@ mod tests { let err = form .file("file", "evil\r\nX-Injected: yes", "audio/wav", b"data") .unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_))); + assert!(matches!(err, AiMuxError::InvalidArgument { .. })); // Malicious media type (CRLF injection / header splitting). let mut form = MultipartForm::new(); let err = form .file("file", "audio.wav", "audio/wav\r\nX-Injected: yes", b"data") .unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_))); + assert!(matches!(err, AiMuxError::InvalidArgument { .. })); assert!(err.to_string().contains("multipart media_type")); // Malicious name is rejected before any bytes are appended. diff --git a/aimux-provider-utils/src/post_to_api.rs b/aimux-provider-utils/src/post_to_api.rs index 075d33610..d40ced71c 100644 --- a/aimux-provider-utils/src/post_to_api.rs +++ b/aimux-provider-utils/src/post_to_api.rs @@ -181,6 +181,7 @@ async fn execute_exchange( url, request_body_values, ) + .caused_by(error) }))), }; } @@ -198,6 +199,7 @@ async fn execute_exchange( url, request_body_values, ) + .caused_by(error) }))), } } diff --git a/aimux-provider-utils/src/read_response_with_size_limit.rs b/aimux-provider-utils/src/read_response_with_size_limit.rs index 794accf6f..0dc8d9f59 100644 --- a/aimux-provider-utils/src/read_response_with_size_limit.rs +++ b/aimux-provider-utils/src/read_response_with_size_limit.rs @@ -3,7 +3,7 @@ use bytes::Bytes; use futures::StreamExt; -use aimux_core::{AiMuxError, ApiCallError}; +use aimux_core::{AiMuxError, ApiCallError, ErrorCause}; use crate::fetch::FetchResponse; @@ -74,6 +74,7 @@ pub async fn read_response_with_size_limit( status_code: None, response_headers: Some(response_headers.clone()), is_retryable: true, + cause: Some(ErrorCause::source(&error)), ..ApiCallError::new( format!("Failed to read response body: {error}"), url, diff --git a/aimux-provider-utils/src/resolvable.rs b/aimux-provider-utils/src/resolvable.rs index 8d2cee627..d182bf862 100644 --- a/aimux-provider-utils/src/resolvable.rs +++ b/aimux-provider-utils/src/resolvable.rs @@ -144,9 +144,15 @@ mod tests { #[tokio::test] async fn errors_propagate_and_a_future_caches_its_failure() { let value: Resolvable = - Resolvable::from_future(async { Err(AiMuxError::Other("boom".into())) }); - assert!(matches!(value.resolve().await, Err(AiMuxError::Other(_)))); - assert!(matches!(value.resolve().await, Err(AiMuxError::Other(_)))); + Resolvable::from_future(async { Err(AiMuxError::other("boom")) }); + assert!(matches!( + value.resolve().await, + Err(AiMuxError::Other { .. }) + )); + assert!(matches!( + value.resolve().await, + Err(AiMuxError::Other { .. }) + )); } #[test] diff --git a/aimux-provider-utils/src/response_handler.rs b/aimux-provider-utils/src/response_handler.rs index bcafb46b3..8e22f2904 100644 --- a/aimux-provider-utils/src/response_handler.rs +++ b/aimux-provider-utils/src/response_handler.rs @@ -7,7 +7,7 @@ use bytes::Bytes; use futures::{StreamExt, stream::BoxStream}; use serde::de::DeserializeOwned; -use aimux_core::{AiMuxError, ApiCallError}; +use aimux_core::{AiMuxError, ApiCallError, ErrorCause}; use crate::extract_response_headers::extract_response_headers; use crate::fetch::FetchResponse; @@ -132,6 +132,7 @@ where status_code: Some(status), response_body: Some(String::from_utf8_lossy(&body).into_owned()), response_headers: Some(headers.clone()), + cause: Some(ErrorCause::source(&error)), ..ApiCallError::new( format!("Invalid JSON response: {error}"), input.url.clone(), @@ -190,9 +191,11 @@ fn error_body_string(body: &[u8], read_truncated: bool) -> String { /// Callers may hold the original request after the transport has returned. /// Apply the same URL sanitization and structured-context bounds as transport /// errors; keep bounded raw response evidence and the observed HTTP status. +/// `error` is the validation failure: its text is the message and the error +/// itself the `cause`. #[must_use] pub fn invalid_response_api_call( - message: impl Into, + error: &(dyn std::error::Error + 'static), status_code: u16, url: &str, request_body_values: serde_json::Value, @@ -207,8 +210,9 @@ pub fn invalid_response_api_call( )), response_headers: Some(response_headers), data: Some(crate::logging::redact_error_context(response_body)), + cause: Some(ErrorCause::source(error)), ..ApiCallError::new( - message, + error.to_string(), crate::http::sanitized_request_url(url), crate::logging::redact_error_context(request_body_values), ) @@ -373,6 +377,7 @@ where AiMuxError::ApiCall(Box::new(ApiCallError { response_headers: Some(headers.clone()), is_retryable: true, + cause: Some(ErrorCause::source(&error)), ..ApiCallError::new( error.to_string(), body_url.clone(), @@ -412,6 +417,7 @@ where yield Err(AiMuxError::ApiCall(Box::new(ApiCallError { response_headers: Some(stream_headers.clone()), is_retryable: true, + cause: Some(ErrorCause::source(error.as_ref())), ..ApiCallError::new( error.to_string(), stream_url.clone(), @@ -427,6 +433,7 @@ where yield Err(AiMuxError::ApiCall(Box::new(ApiCallError { response_headers: Some(stream_headers.clone()), is_retryable: false, + cause: Some(ErrorCause::source(&error)), ..ApiCallError::new( error.to_string(), stream_url.clone(), diff --git a/aimux-provider-utils/src/sigv4_fetch.rs b/aimux-provider-utils/src/sigv4_fetch.rs index 35fcdea0d..653b517f7 100644 --- a/aimux-provider-utils/src/sigv4_fetch.rs +++ b/aimux-provider-utils/src/sigv4_fetch.rs @@ -19,6 +19,8 @@ use chrono::{DateTime, Utc}; use hmac::{Hmac, Mac}; use sha2::{Digest, Sha256}; +use aimux_core::SourceError; + use crate::fetch::{ Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, RequestCredentials, }; @@ -427,7 +429,10 @@ impl Fetch for SigV4Fetch { return self.inner.fetch(request).await; } let credentials = self.credentials.resolve().await.map_err(|error| { - FetchError::Other(format!("failed to resolve AWS credentials: {error}")) + FetchError::Other(SourceError::with_cause( + format!("failed to resolve AWS credentials: {error}"), + &error, + )) })?; let mut extra_headers = Vec::with_capacity(request.headers.len()); for (name, value) in &request.headers { @@ -438,8 +443,11 @@ impl Fetch for SigV4Fetch { if unsigned { continue; } - let value = value.to_str().map_err(|_| { - FetchError::Other(format!("cannot sign non-ASCII header value for {name}")) + let value = value.to_str().map_err(|error| { + FetchError::Other(SourceError::with_cause( + format!("cannot sign non-ASCII header value for {name}"), + &error, + )) })?; extra_headers.push((name.as_str().to_string(), value.to_string())); } @@ -455,10 +463,16 @@ impl Fetch for SigV4Fetch { ); for (name, value) in signed.headers { let name = http::HeaderName::try_from(name.as_str()).map_err(|error| { - FetchError::Other(format!("invalid signed header name {name}: {error}")) + FetchError::Other(SourceError::with_cause( + format!("invalid signed header name {name}: {error}"), + &error, + )) })?; let value = http::HeaderValue::try_from(value.as_str()).map_err(|error| { - FetchError::Other(format!("invalid signed header value for {name}: {error}")) + FetchError::Other(SourceError::with_cause( + format!("invalid signed header value for {name}: {error}"), + &error, + )) })?; request.headers.insert(name, value); } @@ -918,7 +932,7 @@ mod tests { .fetch(FetchRequest::new(Method::GET, URL.parse().unwrap())) .await .unwrap_err(); - assert!(matches!(&error, FetchError::Other(m) if m.contains("AWS_ACCESS_KEY_ID"))); + assert!(matches!(&error, FetchError::Other(m) if m.message.contains("AWS_ACCESS_KEY_ID"))); assert!(capture.0.lock().unwrap().is_none()); } } diff --git a/aimux-provider-utils/src/transform_stream.rs b/aimux-provider-utils/src/transform_stream.rs index d8cf90bf1..6f84d2b4b 100644 --- a/aimux-provider-utils/src/transform_stream.rs +++ b/aimux-provider-utils/src/transform_stream.rs @@ -193,11 +193,11 @@ mod tests { fn transform(&mut self, chunk: i32, controller: &mut TransformStreamController) { if chunk == 0 { - controller.enqueue_error(AiMuxError::InvalidResponseData("zero".into())); + controller.enqueue_error(AiMuxError::invalid_response_data("zero")); return; } if chunk < 0 { - controller.error(AiMuxError::InvalidResponseData("negative".into())); + controller.error(AiMuxError::invalid_response_data("negative")); return; } self.total += chunk; @@ -257,8 +257,8 @@ mod tests { controller.enqueue(1); controller.terminate(); controller.enqueue(2); - controller.enqueue_error(AiMuxError::InvalidResponseData("late".into())); - controller.error(AiMuxError::InvalidResponseData("late".into())); + controller.enqueue_error(AiMuxError::invalid_response_data("late")); + controller.error(AiMuxError::invalid_response_data("late")); let emitted: Vec = controller.drain().map(Result::unwrap).collect(); assert_eq!(emitted, [1]); assert!(controller.is_closed()); diff --git a/aimux-provider-utils/src/url.rs b/aimux-provider-utils/src/url.rs index b0342f716..ee097b953 100644 --- a/aimux-provider-utils/src/url.rs +++ b/aimux-provider-utils/src/url.rs @@ -28,24 +28,26 @@ pub fn without_trailing_slash_opt(url: Option<&str>) -> Option { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when the URL cannot be parsed, the +/// Returns `AiMuxError::invalid_argument` when the URL cannot be parsed, the /// scheme is not `http`/`https`, or the host is missing. pub fn validate_base_url(url: &str) -> Result { if url.is_empty() { - return Err(aimux_core::AiMuxError::InvalidArgument( + return Err(aimux_core::AiMuxError::invalid_argument( "base URL cannot be empty".to_string(), )); } - let parsed = url::Url::parse(url) - .map_err(|e| aimux_core::AiMuxError::InvalidArgument(format!("invalid base URL: {e}")))?; + let parsed = url::Url::parse(url).map_err(|e| aimux_core::AiMuxError::InvalidArgument { + message: format!("invalid base URL: {e}"), + cause: Some(aimux_core::ErrorCause::source(&e)), + })?; if !matches!(parsed.scheme(), "http" | "https") { - return Err(aimux_core::AiMuxError::InvalidArgument(format!( + return Err(aimux_core::AiMuxError::invalid_argument(format!( "base URL must be http or https, got: {}", parsed.scheme() ))); } if parsed.host_str().map(str::is_empty).unwrap_or(true) { - return Err(aimux_core::AiMuxError::InvalidArgument( + return Err(aimux_core::AiMuxError::invalid_argument( "base URL must have a host".to_string(), )); } diff --git a/aimux-provider-utils/src/ws.rs b/aimux-provider-utils/src/ws.rs index 3603a7a1e..c48202137 100644 --- a/aimux-provider-utils/src/ws.rs +++ b/aimux-provider-utils/src/ws.rs @@ -35,6 +35,7 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use aimux_core::AbortSignal; use aimux_core::error::{AiMuxError, ApiCallError}; use aimux_core::options::TimeoutConfiguration; +use aimux_core::{ErrorCause, SourceError}; /// A request to open a WebSocket connection. #[derive(Debug)] @@ -154,9 +155,14 @@ fn abort_error(abort: &Option) -> AiMuxError { .unwrap_or_else(|| AiMuxError::Aborted("request aborted".into())) } -fn ws_error(url: &str, msg: impl std::fmt::Display) -> AiMuxError { +fn ws_error( + url: &str, + msg: impl std::fmt::Display, + source: Option<&(dyn std::error::Error + 'static)>, +) -> AiMuxError { AiMuxError::ApiCall(Box::new(ApiCallError { is_retryable: true, + cause: source.map(ErrorCause::source), ..ApiCallError::new( msg.to_string(), crate::http::sanitized_request_url(url), @@ -272,11 +278,9 @@ fn resolve_proxy( let Some(raw) = raw else { return Ok(None); }; - let proxy = url::Url::parse(&raw).map_err(|e| { - AiMuxError::InvalidArgument(format!( - "invalid proxy URL {}: {e}", - sanitized_proxy_url(&raw) - )) + let proxy = url::Url::parse(&raw).map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid proxy URL {}: {e}", sanitized_proxy_url(&raw)), + cause: Some(ErrorCause::source(&e)), })?; let scheme = proxy.scheme().to_ascii_lowercase(); if scheme != "http" { @@ -292,7 +296,7 @@ fn resolve_proxy( let host = proxy .host_str() .ok_or_else(|| { - AiMuxError::InvalidArgument(format!( + AiMuxError::invalid_argument(format!( "proxy URL has no host: {}", sanitized_proxy_url(&raw) )) @@ -448,7 +452,10 @@ async fn connect_through_proxy( .with_safe_default_protocol_versions() .map_err(|e| { ConnectError::Tungstenite(tokio_tungstenite::tungstenite::Error::Io( - std::io::Error::other(format!("building rustls client config: {e}")), + std::io::Error::other(SourceError::with_cause( + format!("building rustls client config: {e}"), + &e, + )), )) })? .with_root_certificates(roots) @@ -511,18 +518,25 @@ async fn connect_tungstenite( req: &WebSocketRequest, config: &crate::fetch::ProxyConfig, ) -> Result { - let mut http_req = req - .url - .as_str() - .into_client_request() - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid WebSocket URL: {e}")))?; + let mut http_req = + req.url + .as_str() + .into_client_request() + .map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid WebSocket URL: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; for (k, v) in &req.headers { http_req.headers_mut().insert( tokio_tungstenite::tungstenite::http::HeaderName::try_from(k.as_str()).map_err( - |e| AiMuxError::InvalidArgument(format!("invalid WS header name {k}: {e}")), + |e| AiMuxError::InvalidArgument { + message: format!("invalid WS header name {k}: {e}"), + cause: Some(ErrorCause::source(&e)), + }, )?, - HeaderValue::from_str(v).map_err(|e| { - AiMuxError::InvalidArgument(format!("invalid WS header value for {k}: {e}")) + HeaderValue::from_str(v).map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid WS header value for {k}: {e}"), + cause: Some(ErrorCause::source(&e)), })?, ); } @@ -530,7 +544,10 @@ async fn connect_tungstenite( http_req.headers_mut().insert( "Sec-WebSocket-Protocol", HeaderValue::from_str(&req.subprotocols.join(", ")).map_err(|e| { - AiMuxError::InvalidArgument(format!("invalid WS subprotocols: {e}")) + AiMuxError::InvalidArgument { + message: format!("invalid WS subprotocols: {e}"), + cause: Some(ErrorCause::source(&e)), + } })?, ); } @@ -547,8 +564,10 @@ async fn connect_tungstenite( // Proxy resolution (RFC-0034 §2): the connector's ProxyConfig. // no_proxy hits and a proxy-less config keep the direct path unchanged. - let target = url::Url::parse(&req.url) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid WebSocket URL: {e}")))?; + let target = url::Url::parse(&req.url).map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid WebSocket URL: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; let proxy = { let target_host = target.host_str().unwrap_or_default(); let target_port = target.port_or_known_default().unwrap_or(80); @@ -615,7 +634,11 @@ async fn connect_tungstenite( }))); } Err(ConnectError::Tungstenite(e)) => { - return Err(ws_error(&req.url, format!("websocket connect failed: {e}"))); + return Err(ws_error( + &req.url, + format!("websocket connect failed: {e}"), + Some(&e), + )); } }, }; @@ -682,7 +705,7 @@ impl WsConnection { Err(AiMuxError::Timeout("websocket send exceeded total timeout".into())) } res = self.stream.send(Message::Text(text.to_string())) => { - res.map_err(|e| ws_error(&self.url, format!("websocket send failed: {e}"))) + res.map_err(|e| ws_error(&self.url, format!("websocket send failed: {e}"), Some(&e))) } } } @@ -702,7 +725,7 @@ impl WsConnection { Err(AiMuxError::Timeout("websocket send exceeded total timeout".into())) } res = self.stream.send(Message::Binary(bytes.to_vec())) => { - res.map_err(|e| ws_error(&self.url, format!("websocket send failed: {e}"))) + res.map_err(|e| ws_error(&self.url, format!("websocket send failed: {e}"), Some(&e))) } } } @@ -752,12 +775,18 @@ impl WsConnection { let detail = frame .map(|f| format!(" (code {}: {})", u16::from(f.code), f.reason)) .unwrap_or_default(); - return Some(Err(ws_error(&self.url, format!( - "websocket closed by peer{detail}" - )))); + return Some(Err(ws_error( + &self.url, + format!("websocket closed by peer{detail}"), + None, + ))); } Some(Err(e)) => { - return Some(Err(ws_error(&self.url, format!("websocket error: {e}")))); + return Some(Err(ws_error( + &self.url, + format!("websocket error: {e}"), + Some(&e), + ))); } }, } diff --git a/aimux-provider-utils/tests/decision_language_model_test.rs b/aimux-provider-utils/tests/decision_language_model_test.rs index 09b644cf0..36216c37b 100644 --- a/aimux-provider-utils/tests/decision_language_model_test.rs +++ b/aimux-provider-utils/tests/decision_language_model_test.rs @@ -387,7 +387,7 @@ async fn supports_boolean_without_criteria() { fn assert_invalid_response(error: &AiMuxError) { assert!( - matches!(error, AiMuxError::InvalidResponseData(_)), + matches!(error, AiMuxError::InvalidResponseData { .. }), "got {error:?}" ); } @@ -572,7 +572,7 @@ async fn rejects_invalid_rubric_shapes_before_model_io() { }; let error = model.do_decide(&opts).await.unwrap_err(); assert!( - matches!(error, AiMuxError::InvalidArgument(_)), + matches!(error, AiMuxError::InvalidArgument { .. }), "got {error:?}" ); assert!(mock.calls.lock().unwrap().is_empty()); @@ -600,7 +600,8 @@ fn provider_defaults_to_the_language_provider_with_a_decision_suffix() { fn error_message(error: AiMuxError) -> String { match error { - AiMuxError::InvalidArgument(message) | AiMuxError::InvalidResponseData(message) => message, + AiMuxError::InvalidArgument { message, .. } + | AiMuxError::InvalidResponseData { message, .. } => message, other => panic!("unexpected error {other:?}"), } } diff --git a/aimux-provider-utils/tests/download_guard_test.rs b/aimux-provider-utils/tests/download_guard_test.rs index 9d3832af2..19a848243 100644 --- a/aimux-provider-utils/tests/download_guard_test.rs +++ b/aimux-provider-utils/tests/download_guard_test.rs @@ -65,7 +65,7 @@ async fn rejects_non_public_literal_urls_before_connecting() { .await .expect_err("non-public literal must be rejected"); assert!( - matches!(error, AiMuxError::InvalidArgument(ref m) if m.contains("non-public")), + matches!(error, AiMuxError::InvalidArgument { message: ref m, .. } if m.contains("non-public")), "unexpected error for {url}: {error:?}" ); } @@ -142,7 +142,7 @@ async fn rejects_a_redirect_to_a_non_public_target() { .await .expect_err("redirect to metadata IP must be rejected"); assert!( - matches!(error, AiMuxError::InvalidArgument(ref m) if m.contains("non-public")), + matches!(error, AiMuxError::InvalidArgument { message: ref m, .. } if m.contains("non-public")), "unexpected error: {error:?}" ); } @@ -171,7 +171,7 @@ async fn rejects_a_redirect_onto_a_foreign_loopback_origin() { .await .expect_err("foreign loopback origin must be rejected"); assert!( - matches!(error, AiMuxError::InvalidArgument(ref m) if m.contains("non-public")), + matches!(error, AiMuxError::InvalidArgument { message: ref m, .. } if m.contains("non-public")), "unexpected error: {error:?}" ); } diff --git a/aimux-provider-utils/tests/validate_base_url.rs b/aimux-provider-utils/tests/validate_base_url.rs index e59ea692d..dbeec0569 100644 --- a/aimux-provider-utils/tests/validate_base_url.rs +++ b/aimux-provider-utils/tests/validate_base_url.rs @@ -9,25 +9,25 @@ use aimux_provider_utils::validate_base_url; #[test] fn rejects_file_scheme() { let err = validate_base_url("file:///etc/passwd").unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err:?}"); + assert!(matches!(err, AiMuxError::InvalidArgument { .. }), "{err:?}"); } #[test] fn rejects_ftp_scheme() { let err = validate_base_url("ftp://example.com").unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err:?}"); + assert!(matches!(err, AiMuxError::InvalidArgument { .. }), "{err:?}"); } #[test] fn rejects_unparseable_input() { let err = validate_base_url("not a url").unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err:?}"); + assert!(matches!(err, AiMuxError::InvalidArgument { .. }), "{err:?}"); } #[test] fn rejects_empty_string() { let err = validate_base_url("").unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err:?}"); + assert!(matches!(err, AiMuxError::InvalidArgument { .. }), "{err:?}"); } #[test] @@ -58,5 +58,5 @@ fn strips_trailing_slash_on_valid_url() { fn rejects_url_without_host() { // "https://" has no host and fails to parse into a usable URL. let err = validate_base_url("https:///").unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err:?}"); + assert!(matches!(err, AiMuxError::InvalidArgument { .. }), "{err:?}"); } diff --git a/aimux-providers/src/anthropic/convert.rs b/aimux-providers/src/anthropic/convert.rs index 7a61c5980..3b2511d49 100644 --- a/aimux-providers/src/anthropic/convert.rs +++ b/aimux-providers/src/anthropic/convert.rs @@ -73,7 +73,7 @@ pub struct AnthropicPromptConversion { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` / `UnsupportedFunctionality` when a +/// Returns `AiMuxError::invalid_argument` / `UnsupportedFunctionality` when a /// message part cannot be represented in the Anthropic wire format (e.g. an /// unresolvable file reference or an unsupported media type). pub fn convert_prompt_to_anthropic_full_fallible( @@ -95,7 +95,7 @@ pub fn convert_prompt_to_anthropic_full_fallible( /// /// # Errors /// -/// Same as [`convert_prompt_to_anthropic_full_fallible`]: `AiMuxError::InvalidArgument` +/// Same as [`convert_prompt_to_anthropic_full_fallible`]: `AiMuxError::invalid_argument` /// / `UnsupportedFunctionality` when a message part cannot be represented in the /// Anthropic wire format. pub fn convert_prompt_to_anthropic_full_with_tools( @@ -219,7 +219,7 @@ fn convert_prompt_with_validator( let options = anthropic_options(provider_options.as_ref(), options_name) .unwrap_or_default(); let invalid = - || AiMuxError::InvalidArgument("invalid anthropic provider options".into()); + || AiMuxError::invalid_argument("invalid anthropic provider options"); let clear_at = options.get("clearAt"); if clear_at.is_some_and(|value| value.as_str() != Some("next_user_message")) { return Err(invalid()); @@ -1524,7 +1524,7 @@ fn resolve_anthropic_reference( return Ok(id.to_string()); } let providers: Vec<&str> = reference.keys().map(String::as_str).collect(); - Err(AiMuxError::InvalidArgument(format!( + Err(AiMuxError::invalid_argument(format!( "No provider reference found for provider '{CANONICAL}'. Available providers: {}", providers.join(", ") ))) @@ -2162,7 +2162,7 @@ fn strip_null_fields(value: &mut Value) { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when provider-option resolution +/// Returns `AiMuxError::invalid_argument` when provider-option resolution /// fails (e.g. a custom skill reference missing the `anthropic` key). pub fn build_request_body( model_id: &str, @@ -2796,7 +2796,7 @@ fn append_anthropic_container( /// # Errors /// /// Propagates prompt/provider-option conversion errors, e.g. -/// `AiMuxError::InvalidArgument` for an unresolvable file reference or +/// `AiMuxError::invalid_argument` for an unresolvable file reference or /// container option. pub fn build_request_body_with_warnings( model_id: &str, @@ -2822,10 +2822,8 @@ pub(crate) fn build_request_body_for( if let Some(raw) = anthropic_options_in(options.provider_options.as_ref(), &profile.options_name) { - let parsed = - parse_anthropic_option_object("options", &Value::Object(raw)).map_err(|()| { - AiMuxError::InvalidArgument("invalid anthropic provider options".to_string()) - })?; + let parsed = parse_anthropic_option_object("options", &Value::Object(raw)) + .map_err(|()| AiMuxError::invalid_argument("invalid anthropic provider options"))?; let namespaces = parsed_options .provider_options .get_or_insert_with(Default::default); @@ -3046,7 +3044,9 @@ pub(crate) fn build_request_body_for( .get("contextManagement") .is_some_and(|v| !v.is_null()) { - return Err(AiMuxError::InvalidArgument("Anthropic provider options `compaction` and `contextManagement` cannot be used together.".to_string())); + return Err(AiMuxError::invalid_argument( + "Anthropic provider options `compaction` and `contextManagement` cannot be used together.", + )); } for (source, target) in [ ("speed", "speed"), diff --git a/aimux-providers/src/anthropic/files.rs b/aimux-providers/src/anthropic/files.rs index 49ac782d0..1981ef087 100644 --- a/aimux-providers/src/anthropic/files.rs +++ b/aimux-providers/src/anthropic/files.rs @@ -8,6 +8,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::json; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData, UploadFileResult}; use aimux_core::shared::FileBytes; @@ -28,8 +29,12 @@ fn data_to_bytes(data: &UploadFileData) -> Result, AiMuxError> { UploadFileData::Data { data } => match data { FileBytes::Binary(bytes) => Ok(bytes.clone()), FileBytes::Base64(b64) => { - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid base64: {e}"))) + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err( + |e| AiMuxError::InvalidArgument { + message: format!("invalid base64: {e}"), + cause: Some(ErrorCause::source(&e)), + }, + ) } }, UploadFileData::Text { text } => Ok(text.as_bytes().to_vec()), diff --git a/aimux-providers/src/anthropic/mod.rs b/aimux-providers/src/anthropic/mod.rs index f38986348..6e5dfa044 100644 --- a/aimux-providers/src/anthropic/mod.rs +++ b/aimux-providers/src/anthropic/mod.rs @@ -77,8 +77,8 @@ const DEFAULT_NAME: &str = "anthropic.messages"; /// (`normalizeBaseURL` in `createAnthropic`); anything else is taken as given. fn normalize_base_url(url: &str) -> Result { if url.trim().is_empty() { - return Err(AiMuxError::InvalidArgument( - "baseURL must be a non-empty string.".into(), + return Err(AiMuxError::invalid_argument( + "baseURL must be a non-empty string.", )); } let url = validate_base_url(url)?; @@ -162,7 +162,7 @@ impl std::fmt::Debug for AnthropicProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when the explicit `base_url` is not +/// Returns `AiMuxError::invalid_argument` when the explicit `base_url` is not /// an `http(s)` URL with a host, or when both credentials are nonempty. Those /// are the only ways this fails: credentials are loaded per request, and an /// invalid `ANTHROPIC_BASE_URL` is reported by each request, not here. @@ -179,7 +179,7 @@ pub fn create_anthropic( if settings.api_key.as_ref().is_some_and(is_truthy) && settings.auth_token.as_ref().is_some_and(is_truthy) { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "Both apiKey and authToken were provided. Please use only one authentication method." .to_string(), )); diff --git a/aimux-providers/src/anthropic/prepare_tools.rs b/aimux-providers/src/anthropic/prepare_tools.rs index 7543a8e63..e0ce1f673 100644 --- a/aimux-providers/src/anthropic/prepare_tools.rs +++ b/aimux-providers/src/anthropic/prepare_tools.rs @@ -102,7 +102,7 @@ fn arg(args: &Map, key: &str) -> Value { /// /// # Errors /// -/// [`AiMuxError::InvalidArgument`] when a provider-defined tool's `args` do +/// [`AiMuxError::invalid_argument`] when a provider-defined tool's `args` do /// not match the schema upstream validates them against. pub fn prepare_tools_with_provider( tools: Option<&[AnthropicTool]>, @@ -617,7 +617,7 @@ pub(crate) fn validate_provider_tool_args( if valid { Ok(()) } else { - Err(AiMuxError::InvalidArgument(format!( + Err(AiMuxError::invalid_argument(format!( "Invalid configuration for provider-defined tool {id}" ))) } diff --git a/aimux-providers/src/anthropic/skills.rs b/aimux-providers/src/anthropic/skills.rs index 343274527..e24250697 100644 --- a/aimux-providers/src/anthropic/skills.rs +++ b/aimux-providers/src/anthropic/skills.rs @@ -7,6 +7,7 @@ use serde::Deserialize; use serde_json::json; use aimux_core::AiMuxError; +use aimux_core::ErrorCause; use aimux_core::files_model::UploadFileData; use aimux_core::shared::FileBytes; use aimux_core::skills_model::{Skills, UploadSkillCallOptions, UploadSkillResult}; @@ -116,7 +117,10 @@ impl Skills for AnthropicSkills { ), ); body.extend_from_slice(&engine.decode(normalized).map_err(|error| { - AiMuxError::InvalidArgument(format!("invalid base64: {error}")) + AiMuxError::InvalidArgument { + message: format!("invalid base64: {error}"), + cause: Some(ErrorCause::source(&error)), + } })?); } } diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index d1a1bee78..53ba5fc4a 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -1268,7 +1268,7 @@ impl AnthropicMessagesStream { return; } controller.enqueue(StreamPart::Error { - error: AiMuxError::InvalidResponseData(format!( + error: AiMuxError::invalid_response_data(format!( "Received message_start for message {:?} while message {:?} is still open.", message.id, active_id, )), diff --git a/aimux-providers/src/anthropic_aws/mod.rs b/aimux-providers/src/anthropic_aws/mod.rs index 97c4b01ae..c08a7eeba 100644 --- a/aimux-providers/src/anthropic_aws/mod.rs +++ b/aimux-providers/src/anthropic_aws/mod.rs @@ -132,7 +132,7 @@ impl std::fmt::Debug for AnthropicAwsProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host, or, with no `base_url`, an explicit `region` is not a /// single DNS label. Those are the only ways this fails: the region, the /// workspace id and the credentials are loaded per request, not here. diff --git a/aimux-providers/src/anthropic_aws/model.rs b/aimux-providers/src/anthropic_aws/model.rs index 5f1ff055c..50e56cad7 100644 --- a/aimux-providers/src/anthropic_aws/model.rs +++ b/aimux-providers/src/anthropic_aws/model.rs @@ -72,7 +72,7 @@ impl Auth { pub(crate) fn host_region(&self) -> Result { let region = self.region()?; if !is_valid_hostname_part(®ion) { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "Invalid AWS region. Expected a single DNS label (letters, digits, and hyphens). \ Use `base_url` for custom endpoints." .to_string(), diff --git a/aimux-providers/src/assemblyai.rs b/aimux-providers/src/assemblyai.rs index d29cc8867..7e8f2e7f7 100644 --- a/aimux-providers/src/assemblyai.rs +++ b/aimux-providers/src/assemblyai.rs @@ -20,6 +20,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::{Map, Value, json}; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::error::ApiCallError; use aimux_core::shared::{SharedProviderMetadata, Warning}; @@ -128,7 +129,7 @@ impl std::fmt::Debug for AssemblyAIProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_assemblyai( @@ -249,8 +250,12 @@ fn audio_input_to_bytes(audio: &AudioInput) -> Result, AiMuxError> { match audio { AudioInput::Binary(bytes) => Ok(bytes.clone()), AudioInput::Base64(b64) => { - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid base64: {e}"))) + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err(|e| { + AiMuxError::InvalidArgument { + message: format!("invalid base64: {e}"), + cause: Some(ErrorCause::source(&e)), + } + }) } AudioInput::Url(_) => Err(AudioInput::url_error()), } diff --git a/aimux-providers/src/aws_polly.rs b/aimux-providers/src/aws_polly.rs index a5ba67bc0..0cae407ca 100644 --- a/aimux-providers/src/aws_polly.rs +++ b/aimux-providers/src/aws_polly.rs @@ -28,6 +28,7 @@ use std::sync::{Arc, OnceLock}; use async_trait::async_trait; use serde_json::{Map, Value, json}; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::shared::{SharedProviderOptions, Warning}; use aimux_core::speech_model::{ @@ -265,7 +266,7 @@ impl ResolveEndpoint for PollyEndpoint { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the region and the /// credentials are loaded per request, not here. pub fn create_aws_polly( @@ -370,8 +371,12 @@ impl SpeechModel for AwsPollySpeechModel { async fn do_generate(&self, options: &SpeechCallOptions) -> Result { let (body, warnings) = build_request(options, &self.model_id); - let body_str = serde_json::to_string(&Value::Object(body.clone())) - .map_err(|e| AiMuxError::JsonParse(e.to_string()))?; + let body_str = serde_json::to_string(&Value::Object(body.clone())).map_err(|e| { + AiMuxError::JsonParse { + message: e.to_string(), + cause: Some(ErrorCause::source(&e)), + } + })?; // The request is signed by the transport (`SigV4Fetch`), over the exact // bytes sent: user-supplied headers and `Content-Type` are part of the // signature. diff --git a/aimux-providers/src/azure/mod.rs b/aimux-providers/src/azure/mod.rs index a3b7c6159..3b92a9a11 100644 --- a/aimux-providers/src/azure/mod.rs +++ b/aimux-providers/src/azure/mod.rs @@ -26,11 +26,13 @@ mod transcription; pub use transcription::AzureTranscriptionModel; +use aimux_core::SourceError; use std::sync::{Arc, OnceLock}; use futures::future::BoxFuture; use crate::shared::ProviderHeaders; +use aimux_core::ErrorCause; use aimux_core::embedding_model::EmbeddingModel; use aimux_core::error::AiMuxError; use aimux_core::image_model::ImageModel; @@ -151,8 +153,10 @@ fn base_url_info(base_url: Option<&str>) -> Result { is_versioned: false, }); }; - let url = url::Url::parse(base_url) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid Azure base URL: {e}")))?; + let url = url::Url::parse(base_url).map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid Azure base URL: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; let host = url.host_str().unwrap_or_default().to_ascii_lowercase(); let is_azure_openai = host.ends_with(".openai.azure.com") || host.ends_with(".services.ai.azure.com") @@ -170,7 +174,7 @@ fn base_url_info(base_url: Option<&str>) -> Result { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when a non-empty `api_key` and +/// Returns `AiMuxError::invalid_argument` when a non-empty `api_key` and /// `token_provider` are given or `base_url` / `speech_base_url` is not an `http(s)` URL with a /// host. Those are the only ways this fails: credentials and the resource /// name are resolved per request, not here. @@ -183,7 +187,7 @@ pub fn create_azure( Some(_) => true, }; if has_api_key && settings.token_provider.is_some() { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "Both apiKey and tokenProvider were provided. Please use only one authentication \ method." .to_string(), @@ -271,12 +275,12 @@ impl Fetch for BearerTokenFetch { .token .resolve() .await - .map_err(|error| FetchError::Other(error.to_string()))?; + .map_err(|error| FetchError::Other(SourceError::new(&error)))?; request.headers.insert( reqwest::header::AUTHORIZATION, format!("Bearer {token}").parse().map_err( |error: reqwest::header::InvalidHeaderValue| { - FetchError::Other(error.to_string()) + FetchError::Other(SourceError::new(&error)) }, )?, ); @@ -341,7 +345,7 @@ impl UrlRules { // The resource name becomes part of the request host, so only a DNS // label is accepted (`user@internal:8080/#` would rewrite the host). if !is_valid_hostname_part(&resource) { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "Invalid Azure resource name. Expected a single DNS label (letters, digits, and \ hyphens). Use `base_url` for custom endpoints." .to_string(), @@ -363,8 +367,10 @@ impl UrlRules { } else { format!("{prefix}/v1{path}") }; - let mut url = url::Url::parse(&full) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid Azure URL `{full}`: {e}")))?; + let mut url = url::Url::parse(&full).map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid Azure URL `{full}`: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; if self.use_deployment_based_urls || (info.is_azure_openai && !info.is_versioned && !info.is_foundry_project) { @@ -593,8 +599,13 @@ impl ProviderDiscovery for AzureOpenAIProvider { } let provider_headers = config.request_headers(None).await?; - let mut url = url::Url::parse(&format!("{}/deployments", rules.prefix()?)) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid Azure URL: {e}")))?; + let mut url = + url::Url::parse(&format!("{}/deployments", rules.prefix()?)).map_err(|e| { + AiMuxError::InvalidArgument { + message: format!("invalid Azure URL: {e}"), + cause: Some(ErrorCause::source(&e)), + } + })?; set_query_param(&mut url, "api-version", &rules.api_version); let url = url.to_string(); let resp = aimux_provider_utils::get_from_api( diff --git a/aimux-providers/src/azure/transcription.rs b/aimux-providers/src/azure/transcription.rs index dae49473b..4905ef06d 100644 --- a/aimux-providers/src/azure/transcription.rs +++ b/aimux-providers/src/azure/transcription.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::shared::{SharedProviderOptions, Warning, provider_namespace}; use aimux_core::transcription_model::{ @@ -73,21 +74,24 @@ fn parse_options(options: Option<&SharedProviderOptions>) -> Result Result { @@ -438,7 +438,7 @@ impl CodexModel { response_headers, ), None => Err(failure.unwrap_or_else(|| { - AiMuxError::InvalidResponseData( + AiMuxError::invalid_response_data( "subscription stream ended without response.completed".to_string(), ) })), @@ -609,7 +609,7 @@ pub async fn codex_refresh_at( .get("access_token") .and_then(|v| v.as_str()) .ok_or_else(|| { - AiMuxError::InvalidResponseData("oauth token response missing access_token".to_string()) + AiMuxError::invalid_response_data("oauth token response missing access_token") })?; Ok(CodexTokens { diff --git a/aimux-providers/src/cohere/convert.rs b/aimux-providers/src/cohere/convert.rs index 8236462fa..5427a517e 100644 --- a/aimux-providers/src/cohere/convert.rs +++ b/aimux-providers/src/cohere/convert.rs @@ -302,8 +302,8 @@ fn image_part( .and_then(|options| options.get("detail")) { if !matches!(detail.as_str(), Some("auto" | "low" | "high")) { - return Err(AiMuxError::InvalidArgument( - "cohere.detail must be auto, low or high".into(), + return Err(AiMuxError::invalid_argument( + "cohere.detail must be auto, low or high", )); } image["detail"] = detail.clone(); @@ -457,8 +457,8 @@ pub fn resolve_cohere_thinking( && let Some(thinking) = cohere.get("thinking") { if !thinking.is_object() { - return Err(AiMuxError::InvalidArgument( - "cohere.thinking must be an object".into(), + return Err(AiMuxError::invalid_argument( + "cohere.thinking must be an object", )); } let t_type = match thinking.get("type") { @@ -466,8 +466,8 @@ pub fn resolve_cohere_thinking( Some(value) => match value.as_str() { Some(value @ ("enabled" | "disabled")) => value, _ => { - return Err(AiMuxError::InvalidArgument( - "cohere.thinking.type must be enabled or disabled".into(), + return Err(AiMuxError::invalid_argument( + "cohere.thinking.type must be enabled or disabled", )); } }, @@ -475,8 +475,8 @@ pub fn resolve_cohere_thinking( let mut obj = json!({ "type": t_type }); if let Some(budget) = thinking.get("tokenBudget") { if !budget.is_number() { - return Err(AiMuxError::InvalidArgument( - "cohere.thinking.tokenBudget must be a number".into(), + return Err(AiMuxError::invalid_argument( + "cohere.thinking.tokenBudget must be a number", )); } obj["token_budget"] = budget.clone(); diff --git a/aimux-providers/src/cohere/embedding.rs b/aimux-providers/src/cohere/embedding.rs index 2e2eef584..51c6a4a7a 100644 --- a/aimux-providers/src/cohere/embedding.rs +++ b/aimux-providers/src/cohere/embedding.rs @@ -53,7 +53,7 @@ impl EmbeddingModel for CohereEmbeddingModel { ) -> Result { let cohere_options = parse_cohere_provider_options(options.provider_options.as_ref())?; if options.values.len() > 96 { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Too many values for a single embedding call: {} supports at most 96 values, received {}", self.provider(), options.values.len() @@ -132,7 +132,7 @@ fn parse_cohere_provider_options( .as_str() .filter(|value| allowed.contains(value)) .map(str::to_owned) - .ok_or_else(|| AiMuxError::InvalidArgument(format!("Invalid cohere.{key}"))) + .ok_or_else(|| AiMuxError::invalid_argument(format!("Invalid cohere.{key}"))) }) .transpose() }; @@ -143,7 +143,7 @@ fn parse_cohere_provider_options( .as_f64() .filter(|value| [256.0, 512.0, 1024.0, 1536.0].contains(value)) .map(|value| value as u32) - .ok_or_else(|| AiMuxError::InvalidArgument("Invalid cohere.outputDimension".into())) + .ok_or_else(|| AiMuxError::invalid_argument("Invalid cohere.outputDimension")) }) .transpose()?; Ok(CohereEmbeddingProviderOptions { diff --git a/aimux-providers/src/cohere/model.rs b/aimux-providers/src/cohere/model.rs index 583e00aa8..3f3439c02 100644 --- a/aimux-providers/src/cohere/model.rs +++ b/aimux-providers/src/cohere/model.rs @@ -8,6 +8,7 @@ use aimux_core::tool::RawToolCall; use async_trait::async_trait; use serde_json::Value; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::language_model::{LanguageModel, SupportedUrls}; use aimux_core::options::CallOptions; @@ -410,13 +411,16 @@ impl Transformer for CohereChatStream { }) { Ok(value) if !contains_prototype_key(&value) => value.to_string(), Ok(_) => { - controller.error(AiMuxError::InvalidResponseData( - "Object contains forbidden prototype property".into(), + controller.error(AiMuxError::invalid_response_data( + "Object contains forbidden prototype property", )); return; } Err(error) => { - controller.error(AiMuxError::JsonParse(error.to_string())); + controller.error(AiMuxError::JsonParse { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + }); return; } }; diff --git a/aimux-providers/src/cohere/reranking.rs b/aimux-providers/src/cohere/reranking.rs index ac25fe153..d88b87c4e 100644 --- a/aimux-providers/src/cohere/reranking.rs +++ b/aimux-providers/src/cohere/reranking.rs @@ -33,7 +33,9 @@ fn parse_cohere_reranking_options( .and_then(|options| options.get(key)) .map(|value| match value { Value::Number(number) => Ok(number.clone()), - _ => Err(AiMuxError::InvalidArgument(format!("Invalid cohere.{key}"))), + _ => Err(AiMuxError::invalid_argument(format!( + "Invalid cohere.{key}" + ))), }) .transpose() }; diff --git a/aimux-providers/src/cohere/types.rs b/aimux-providers/src/cohere/types.rs index bbff669ee..9ef6eec97 100644 --- a/aimux-providers/src/cohere/types.rs +++ b/aimux-providers/src/cohere/types.rs @@ -189,8 +189,8 @@ impl StreamEvent { return Ok(event); } } - Err(aimux_core::error::AiMuxError::InvalidResponseData(format!( - "Invalid Cohere stream event: {raw}" - ))) + Err(aimux_core::error::AiMuxError::invalid_response_data( + format!("Invalid Cohere stream event: {raw}"), + )) } } diff --git a/aimux-providers/src/dataforseo.rs b/aimux-providers/src/dataforseo.rs index 7b3cf15e1..ae0638352 100644 --- a/aimux-providers/src/dataforseo.rs +++ b/aimux-providers/src/dataforseo.rs @@ -111,7 +111,7 @@ impl std::fmt::Debug for DataforseoProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the credentials are /// loaded per request, not here. pub fn create_dataforseo( diff --git a/aimux-providers/src/decision_scoring.rs b/aimux-providers/src/decision_scoring.rs index ee9e459a8..119e64e03 100644 --- a/aimux-providers/src/decision_scoring.rs +++ b/aimux-providers/src/decision_scoring.rs @@ -8,6 +8,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use aimux_core::AiMuxError; +use aimux_core::ErrorCause; use aimux_core::decision_model::*; use serde::Deserialize; use serde_json::{Map, Value, json}; @@ -39,8 +40,12 @@ pub(crate) async fn score( options: Map, images: &[DecisionImage], ) -> Result { - let options: Options = serde_json::from_value(Value::Object(options)) - .map_err(|e| AiMuxError::InvalidArgument(format!("candidate scoring options: {e}")))?; + let options: Options = serde_json::from_value(Value::Object(options)).map_err(|e| { + AiMuxError::InvalidArgument { + message: format!("candidate scoring options: {e}"), + cause: Some(ErrorCause::source(&e)), + } + })?; if !images.is_empty() { return Err(AiMuxError::UnsupportedFunctionality("pre-encoded scoring accepts text token IDs only; use the runtime's native decision endpoint for images".into())); } @@ -52,20 +57,22 @@ pub(crate) async fn score( .iter() .any(|s| s.trim().is_empty()) { - return Err(AiMuxError::InvalidArgument("candidate scoring requires modelRevision, tokenizerRevision and promptFormatVersion provenance".into())); + return Err(AiMuxError::invalid_argument( + "candidate scoring requires modelRevision, tokenizerRevision and promptFormatVersion provenance", + )); } let temperature = options.temperature.unwrap_or(1.0); if !temperature.is_finite() || temperature <= 0.0 || (model.provider() == "vllm" && temperature != 1.0) { - return Err(AiMuxError::InvalidArgument( - "scoring temperature must be positive; vLLM generative_scoring only supports 1".into(), + return Err(AiMuxError::invalid_argument( + "scoring temperature must be positive; vLLM generative_scoring only supports 1", )); } if options.encoded.len() != request.questions.len() { - return Err(AiMuxError::InvalidArgument( - "encoded questions must exactly match decision question IDs".into(), + return Err(AiMuxError::invalid_argument( + "encoded questions must exactly match decision question IDs", )); } let mut encoded = Vec::new(); @@ -73,15 +80,14 @@ pub(crate) async fn score( let entry = options .encoded .get(id) - .ok_or_else(|| AiMuxError::InvalidArgument(format!("missing encoding for {id}")))?; + .ok_or_else(|| AiMuxError::invalid_argument(format!("missing encoding for {id}")))?; let count = candidate_count(question); if entry.prompt_token_ids.is_empty() || entry.label_token_ids.len() != count || entry.label_token_ids.iter().collect::>().len() != count { - return Err(AiMuxError::InvalidArgument( - "each encoding requires a nonempty prompt and one distinct token ID per candidate" - .into(), + return Err(AiMuxError::invalid_argument( + "each encoding requires a nonempty prompt and one distinct token ID per candidate", )); } encoded.push(entry); @@ -96,8 +102,13 @@ pub(crate) async fn score( &format!("{base}/score"), body, |raw, headers| { - let scores: Vec> = serde_json::from_value(raw["scores"].clone()) - .map_err(|e| invalid(format!("SGLang score matrix: {e}")))?; + let scores: Vec> = + serde_json::from_value(raw["scores"].clone()).map_err(|e| { + AiMuxError::InvalidResponseData { + message: format!("SGLang score matrix: {e}"), + cause: Some(ErrorCause::source(&e)), + } + })?; let response_model = raw["model"].as_str().unwrap_or_default().to_owned(); result_from_distributions( request, diff --git a/aimux-providers/src/decision_support.rs b/aimux-providers/src/decision_support.rs index 8a37aa446..0ed1416d7 100644 --- a/aimux-providers/src/decision_support.rs +++ b/aimux-providers/src/decision_support.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use aimux_core::AiMuxError; +use aimux_core::ErrorCause; use aimux_core::decision_model::{DecisionCallOptions, DecisionQuestion}; use aimux_core::shared::{ResponseInfo, SharedProviderMetadata}; use aimux_provider_utils::HttpRequest; @@ -41,7 +42,7 @@ impl std::str::FromStr for DecisionProbabilitySource { "native" => Ok(Self::Native), "logit_scoring" => Ok(Self::LogitScoring), "model_estimate" => Ok(Self::ModelEstimate), - _ => Err(AiMuxError::InvalidArgument(format!( + _ => Err(AiMuxError::invalid_argument(format!( "unknown decision probability source: {value:?}" ))), } @@ -70,8 +71,10 @@ pub(crate) fn take_images( ) -> Result, AiMuxError> { match options.remove("images") { None | Some(Value::Null) => Ok(Vec::new()), - Some(images) => serde_json::from_value(images) - .map_err(|e| AiMuxError::InvalidArgument(format!("decision images: {e}"))), + Some(images) => serde_json::from_value(images).map_err(|e| AiMuxError::InvalidArgument { + message: format!("decision images: {e}"), + cause: Some(ErrorCause::source(&e)), + }), } } @@ -141,20 +144,21 @@ pub(crate) struct InlineImage { pub(crate) fn inline_image(image: &DecisionImage) -> Result { if !image.media_type.starts_with("image/") || image.media_type.contains([';', ',']) { - return Err(AiMuxError::InvalidArgument( - "decision image requires an image MIME type".into(), + return Err(AiMuxError::invalid_argument( + "decision image requires an image MIME type", )); } // The image's bytes are a base64 string, read as the AI SDK reads // base64 (`convertBase64ToUint8Array`) and sent on verbatim. let decoded_len = aimux_core::wire::decode_base64(&image.data) - .map_err(|e| AiMuxError::InvalidArgument(format!("decision image base64: {e}")))? + .map_err(|e| AiMuxError::InvalidArgument { + message: format!("decision image base64: {e}"), + cause: Some(ErrorCause::source(&e)), + })? .len(); let base64 = image.data.clone(); if decoded_len == 0 { - return Err(AiMuxError::InvalidArgument( - "decision image is empty".into(), - )); + return Err(AiMuxError::invalid_argument("decision image is empty")); } Ok(InlineImage { data_url: format!("data:{};base64,{base64}", image.media_type), @@ -205,19 +209,12 @@ pub(crate) async fn post( let raw = response.value; let headers = response.response_headers; convert(raw.clone(), headers.clone()).map_err(|error| { - aimux_provider_utils::invalid_response_api_call( - error.to_string(), - 200, - url, - body, - raw, - headers, - ) + aimux_provider_utils::invalid_response_api_call(&error, 200, url, body, raw, headers) }) } pub(crate) fn invalid(message: impl Into) -> AiMuxError { - AiMuxError::InvalidResponseData(message.into()) + AiMuxError::invalid_response_data(message.into()) } /// What a native provider reports beyond the V4 result, under its provider diff --git a/aimux-providers/src/deepgram.rs b/aimux-providers/src/deepgram.rs index c7a54cfcc..3d9b0d0dc 100644 --- a/aimux-providers/src/deepgram.rs +++ b/aimux-providers/src/deepgram.rs @@ -21,6 +21,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::Value; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::shared::{SharedProviderOptions, Warning}; use aimux_core::transcription_model::{ @@ -120,7 +121,7 @@ impl std::fmt::Debug for DeepgramProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_deepgram(settings: DeepgramProviderSettings) -> Result { @@ -280,8 +281,12 @@ fn audio_input_to_bytes(audio: &AudioInput) -> Result, AiMuxError> { match audio { AudioInput::Binary(bytes) => Ok(bytes.clone()), AudioInput::Base64(b64) => { - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid base64: {e}"))) + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err(|e| { + AiMuxError::InvalidArgument { + message: format!("invalid base64: {e}"), + cause: Some(ErrorCause::source(&e)), + } + }) } AudioInput::Url(_) => Err(AudioInput::url_error()), } diff --git a/aimux-providers/src/deepseek/convert.rs b/aimux-providers/src/deepseek/convert.rs index 353bc875a..eac1a6621 100644 --- a/aimux-providers/src/deepseek/convert.rs +++ b/aimux-providers/src/deepseek/convert.rs @@ -42,9 +42,7 @@ fn convert_image_part( ) -> Result { if tool_result && let FileData::Reference { reference } = &part.data { let file_id = reference.get("deepseek").ok_or_else(|| { - AiMuxError::InvalidArgument( - "No provider reference found for provider 'deepseek'.".into(), - ) + AiMuxError::invalid_argument("No provider reference found for provider 'deepseek'.") })?; return Ok(json!({ "type": "file", "file_id": file_id })); } @@ -63,7 +61,7 @@ fn convert_image_part( match &part.data { FileData::Reference { reference } => { let file_id = reference.get("deepseek").ok_or_else(|| { - AiMuxError::InvalidArgument( + AiMuxError::invalid_argument( "No provider reference found for provider 'deepseek'.".to_string(), ) })?; @@ -72,12 +70,12 @@ fn convert_image_part( FileData::Url { url, .. } => { resolve_deepseek_image_media_type(part)?; if url.len() > 8192 { - return Err(AiMuxError::InvalidPrompt( + return Err(AiMuxError::invalid_prompt( "DeepSeek image URLs must not exceed 8192 characters.".to_string(), )); } if options.file_data == Some(true) { - return Err(AiMuxError::InvalidPrompt( + return Err(AiMuxError::invalid_prompt( "DeepSeek `fileData` image parts require inline data, not a URL.".to_string(), )); } @@ -99,7 +97,7 @@ fn convert_image_part( return Ok(image_url(&data_url)); } if options.image_detail.is_some() { - return Err(AiMuxError::InvalidPrompt( + return Err(AiMuxError::invalid_prompt( "DeepSeek `imageDetail` cannot be combined with `fileData`.".to_string(), )); } @@ -181,7 +179,7 @@ pub(crate) fn convert_to_deepseek_chat_messages( if options.prefix == Some(true) && !matches!(message, LanguageModelMessage::Assistant { .. }) { - return Err(AiMuxError::InvalidPrompt( + return Err(AiMuxError::invalid_prompt( "DeepSeek assistant prefix completion requires `prefix: true` on an assistant message." .to_string(), )); @@ -245,7 +243,7 @@ pub(crate) fn convert_to_deepseek_chat_messages( LanguageModelMessage::Assistant { content, .. } => { if options.prefix == Some(true) { if index != prompt.len() - 1 { - return Err(AiMuxError::InvalidPrompt( + return Err(AiMuxError::invalid_prompt( "DeepSeek assistant prefix completion requires the prefixed assistant message to be the final message." .to_string(), )); diff --git a/aimux-providers/src/deepseek/mod.rs b/aimux-providers/src/deepseek/mod.rs index b07e1a1af..eadd66868 100644 --- a/aimux-providers/src/deepseek/mod.rs +++ b/aimux-providers/src/deepseek/mod.rs @@ -81,7 +81,7 @@ impl std::fmt::Debug for DeepSeekProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. The key is not read here. pub fn create_deepseek(settings: DeepSeekProviderSettings) -> Result { let base_url = match settings.base_url.as_deref() { diff --git a/aimux-providers/src/deepseek/model.rs b/aimux-providers/src/deepseek/model.rs index 6675d1b98..1ae071c44 100644 --- a/aimux-providers/src/deepseek/model.rs +++ b/aimux-providers/src/deepseek/model.rs @@ -382,7 +382,7 @@ impl LanguageModel for DeepSeekChatLanguageModel { let data = resp.value; let choice = data.choices.into_iter().next().ok_or_else(|| { - AiMuxError::InvalidResponseData("Response did not contain any choices.".to_string()) + AiMuxError::invalid_response_data("Response did not contain any choices.") })?; let mut metadata = Map::new(); diff --git a/aimux-providers/src/deepseek/options.rs b/aimux-providers/src/deepseek/options.rs index 62d808722..dd90c597a 100644 --- a/aimux-providers/src/deepseek/options.rs +++ b/aimux-providers/src/deepseek/options.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Deserializer}; use serde_json::Value; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::shared::{JsonObject, SharedProviderOptions}; @@ -106,13 +107,16 @@ fn parse_namespace Deserialize<'de>>( match namespace { None => Ok(T::default()), Some(options) => serde_json::from_value(Value::Object(options.clone())).map_err(|error| { - AiMuxError::InvalidArgument(format!("invalid provider options for \"{name}\": {error}")) + AiMuxError::InvalidArgument { + message: format!("invalid provider options for \"{name}\": {error}"), + cause: Some(ErrorCause::source(&error)), + } }), } } fn invalid(name: &str, message: &str) -> AiMuxError { - AiMuxError::InvalidArgument(format!( + AiMuxError::invalid_argument(format!( "invalid provider options for \"{name}\": {message}" )) } diff --git a/aimux-providers/src/default_providers.rs b/aimux-providers/src/default_providers.rs index 1b8263b9b..41bdab7b6 100644 --- a/aimux-providers/src/default_providers.rs +++ b/aimux-providers/src/default_providers.rs @@ -113,7 +113,7 @@ pub fn create_provider( transform_request_body, } = settings; let unsupported = |field: &str| { - Err(AiMuxError::InvalidArgument(format!( + Err(AiMuxError::invalid_argument(format!( "provider '{name}' has no `{field}` setting" ))) }; @@ -576,7 +576,7 @@ mod tests { ]); let error = model.do_generate(&options).await.expect_err(name); assert!( - matches!(&error, AiMuxError::InvalidArgument(message) if message.starts_with(var)), + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message.starts_with(var)), "{name}: {error:?}" ); } diff --git a/aimux-providers/src/elevenlabs.rs b/aimux-providers/src/elevenlabs.rs index 34a5f4367..e0bb81b30 100644 --- a/aimux-providers/src/elevenlabs.rs +++ b/aimux-providers/src/elevenlabs.rs @@ -18,6 +18,7 @@ use std::sync::{Arc, OnceLock}; use async_trait::async_trait; use serde_json::{Map, Value, json}; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::shared::{SharedProviderOptions, Warning}; use aimux_core::speech_model::{ @@ -144,7 +145,7 @@ impl std::fmt::Debug for ElevenLabsProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_elevenlabs( @@ -689,8 +690,12 @@ fn audio_input_to_bytes_stt(audio: &AudioInput) -> Result, AiMuxError> { match audio { AudioInput::Binary(bytes) => Ok(bytes.clone()), AudioInput::Base64(b64) => { - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid base64: {e}"))) + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err(|e| { + AiMuxError::InvalidArgument { + message: format!("invalid base64: {e}"), + cause: Some(ErrorCause::source(&e)), + } + }) } AudioInput::Url(_) => Err(AudioInput::url_error()), } diff --git a/aimux-providers/src/exa_ai.rs b/aimux-providers/src/exa_ai.rs index a5751af43..767b60941 100644 --- a/aimux-providers/src/exa_ai.rs +++ b/aimux-providers/src/exa_ai.rs @@ -93,7 +93,7 @@ impl std::fmt::Debug for ExaAiProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_exa_ai(settings: ExaAiProviderSettings) -> Result { diff --git a/aimux-providers/src/fal.rs b/aimux-providers/src/fal.rs index 5372a9505..f2cea67be 100644 --- a/aimux-providers/src/fal.rs +++ b/aimux-providers/src/fal.rs @@ -134,7 +134,7 @@ impl std::fmt::Debug for FalProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_fal(settings: FalProviderSettings) -> Result { @@ -836,7 +836,7 @@ impl VideoModel for FalVideoModel { .get("request_id") .and_then(Value::as_str) .ok_or_else(|| { - AiMuxError::InvalidArgument( + AiMuxError::invalid_argument( "fal operation reference is missing request_id".to_string(), ) })?; @@ -879,7 +879,7 @@ impl VideoModel for FalVideoModel { media_type: "video/mp4".to_string(), }] } else { - return Err(AiMuxError::InvalidResponseData( + return Err(AiMuxError::invalid_response_data( "Fal video result missing video URL".to_string(), )); }; diff --git a/aimux-providers/src/firecrawl.rs b/aimux-providers/src/firecrawl.rs index a34d3e5c7..5990ed6b2 100644 --- a/aimux-providers/src/firecrawl.rs +++ b/aimux-providers/src/firecrawl.rs @@ -90,7 +90,7 @@ impl std::fmt::Debug for FirecrawlProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_firecrawl( diff --git a/aimux-providers/src/gladia.rs b/aimux-providers/src/gladia.rs index 9c90a955f..e78561f8c 100644 --- a/aimux-providers/src/gladia.rs +++ b/aimux-providers/src/gladia.rs @@ -20,6 +20,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::{Map, Value, json}; +use aimux_core::ErrorCause; use aimux_core::error::{AiMuxError, ApiCallError}; use aimux_core::shared::{SharedProviderMetadata, Warning, provider_namespace}; use aimux_core::transcription_model::{ @@ -116,7 +117,7 @@ impl std::fmt::Debug for GladiaProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_gladia(settings: GladiaProviderSettings) -> Result { @@ -230,8 +231,12 @@ fn audio_input_to_bytes(audio: &AudioInput) -> Result, AiMuxError> { match audio { AudioInput::Binary(bytes) => Ok(bytes.clone()), AudioInput::Base64(b64) => { - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid base64: {e}"))) + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err(|e| { + AiMuxError::InvalidArgument { + message: format!("invalid base64: {e}"), + cause: Some(ErrorCause::source(&e)), + } + }) } AudioInput::Url(_) => Err(AudioInput::url_error()), } @@ -361,7 +366,7 @@ impl TranscriptionModel for GladiaTranscriptionModel { let parsed = resp.value; let result = parsed.result.ok_or_else(|| { - AiMuxError::InvalidResponseData( + AiMuxError::invalid_response_data( "Gladia transcription completed without a result".to_string(), ) })?; diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index 74a07506d..6a8a3263b 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -1041,7 +1041,7 @@ fn build_request_body_with_warnings_for_namespace( None => true, Some(Value::Bool(value)) => *value, Some(_) => { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "invalid {} provider options", provider_options.unwrap().0 ))); @@ -1430,8 +1430,9 @@ pub(crate) fn validate_call_options_for_namespace( let Some(values) = Namespace::Google.read_in(options.provider_options.as_ref()) else { return Ok(()); }; - let invalid = - |name: &str| AiMuxError::InvalidArgument(format!("Invalid Google provider option: {name}")); + let invalid = |name: &str| { + AiMuxError::invalid_argument(format!("Invalid Google provider option: {name}")) + }; for name in [ "structuredOutputs", "audioTimestamp", @@ -1622,7 +1623,7 @@ fn validate_file_part( if let FileData::Reference { reference } = &file.data && !reference.contains_key(GOOGLE) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "No provider reference found for provider 'google'. Available providers: {}", reference.keys().cloned().collect::>().join(", ") ))); diff --git a/aimux-providers/src/google/embedding.rs b/aimux-providers/src/google/embedding.rs index 9384c14ec..f9d1e1e4e 100644 --- a/aimux-providers/src/google/embedding.rs +++ b/aimux-providers/src/google/embedding.rs @@ -60,8 +60,8 @@ impl EmbeddingModel for GoogleEmbeddingModel { if let Some(opts) = provider_options { if let Some(dimension) = opts.get("outputDimensionality") { if !dimension.is_number() { - return Err(AiMuxError::InvalidArgument( - "outputDimensionality must be a number".into(), + return Err(AiMuxError::invalid_argument( + "outputDimensionality must be a number", )); } settings.insert("outputDimensionality".into(), dimension.clone()); @@ -80,15 +80,15 @@ impl EmbeddingModel for GoogleEmbeddingModel { | "CODE_RETRIEVAL_QUERY" ) ) { - return Err(AiMuxError::InvalidArgument( - "Invalid Google embedding taskType".into(), + return Err(AiMuxError::invalid_argument( + "Invalid Google embedding taskType", )); } settings.insert("taskType".into(), task.clone()); } } if options.values.len() > 100 { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Too many values for a single embedding call. The {} model \"{}\" can only embed up to 100 values per call, but {} values were provided.", self.provider(), self.model_id, @@ -99,13 +99,13 @@ impl EmbeddingModel for GoogleEmbeddingModel { let multimodal = multimodal .map(|v| { v.as_array().ok_or_else(|| { - AiMuxError::InvalidArgument("Google embedding content must be an array".into()) + AiMuxError::invalid_argument("Google embedding content must be an array") }) }) .transpose()?; if let Some(content) = multimodal { if content.len() != options.values.len() { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "The number of multimodal content entries ({}) must match the number of values ({}).", content.len(), options.values.len() @@ -113,8 +113,8 @@ impl EmbeddingModel for GoogleEmbeddingModel { } for entry in content.iter().filter(|v| !v.is_null()) { let parts = entry.as_array().filter(|p| !p.is_empty()).ok_or_else(|| { - AiMuxError::InvalidArgument( - "Google embedding content entries must be nonempty arrays or null".into(), + AiMuxError::invalid_argument( + "Google embedding content entries must be nonempty arrays or null", ) })?; for part in parts { @@ -128,8 +128,8 @@ impl EmbeddingModel for GoogleEmbeddingModel { && p.get("fileUri").is_some_and(Value::is_string) }); if !valid { - return Err(AiMuxError::InvalidArgument( - "Invalid Google embedding content part".into(), + return Err(AiMuxError::invalid_argument( + "Invalid Google embedding content part", )); } } @@ -190,13 +190,13 @@ impl EmbeddingModel for GoogleEmbeddingModel { vec![ raw_value .get("embedding") - .ok_or_else(|| AiMuxError::InvalidResponseData("Missing embedding".into()))?, + .ok_or_else(|| AiMuxError::invalid_response_data("Missing embedding"))?, ] } else { raw_value .get("embeddings") .and_then(Value::as_array) - .ok_or_else(|| AiMuxError::InvalidResponseData("Missing embeddings".into()))? + .ok_or_else(|| AiMuxError::invalid_response_data("Missing embeddings"))? .iter() .collect() }; @@ -206,16 +206,12 @@ impl EmbeddingModel for GoogleEmbeddingModel { let values = item .get("values") .and_then(Value::as_array) - .ok_or_else(|| { - AiMuxError::InvalidResponseData("Missing embedding values".into()) - })?; + .ok_or_else(|| AiMuxError::invalid_response_data("Missing embedding values"))?; values .iter() .map(|v| { v.as_f64().map(|v| v as f32).ok_or_else(|| { - AiMuxError::InvalidResponseData( - "Embedding values must be numbers".into(), - ) + AiMuxError::invalid_response_data("Embedding values must be numbers") }) }) .collect::, _>>() diff --git a/aimux-providers/src/google/files.rs b/aimux-providers/src/google/files.rs index 3c67e422e..7f6884636 100644 --- a/aimux-providers/src/google/files.rs +++ b/aimux-providers/src/google/files.rs @@ -19,6 +19,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::{Value, json}; +use aimux_core::ErrorCause; use aimux_core::error::{AiMuxError, ApiCallError}; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData, UploadFileResult}; use aimux_core::shared::{FileBytes, SharedProviderOptions}; @@ -46,14 +47,15 @@ fn parse_google_files_options( return Ok(GoogleFilesUploadOptions::default()); }; let options: GoogleFilesUploadOptions = serde_json::from_value(Value::Object(google.clone())) - .map_err(|error| { - AiMuxError::InvalidArgument(format!("Invalid Google file options: {error}")) + .map_err(|error| AiMuxError::InvalidArgument { + message: format!("Invalid Google file options: {error}"), + cause: Some(ErrorCause::source(&error)), })?; if options.poll_interval_ms.is_some_and(|value| value <= 0.0) || options.poll_timeout_ms.is_some_and(|value| value <= 0.0) { - return Err(AiMuxError::InvalidArgument( - "Google file polling intervals must be positive".into(), + return Err(AiMuxError::invalid_argument( + "Google file polling intervals must be positive", )); } Ok(options) @@ -65,8 +67,12 @@ fn data_to_bytes(data: &UploadFileData) -> Result, AiMuxError> { UploadFileData::Data { data } => match data { FileBytes::Binary(bytes) => Ok(bytes.clone()), FileBytes::Base64(b64) => { - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid base64: {e}"))) + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err( + |e| AiMuxError::InvalidArgument { + message: format!("invalid base64: {e}"), + cause: Some(ErrorCause::source(&e)), + }, + ) } }, UploadFileData::Text { text } => Ok(text.as_bytes().to_vec()), @@ -242,7 +248,7 @@ impl Files for GoogleFiles { .get("x-goog-upload-url") .cloned() .ok_or_else(|| { - AiMuxError::InvalidResponseData( + AiMuxError::invalid_response_data( "No upload URL returned from initiation request".to_string(), ) })?; @@ -313,8 +319,12 @@ impl Files for GoogleFiles { } sleep_or_abort( - Duration::try_from_secs_f64(poll_interval_ms / 1000.0) - .map_err(|error| AiMuxError::InvalidArgument(error.to_string()))?, + Duration::try_from_secs_f64(poll_interval_ms / 1000.0).map_err(|error| { + AiMuxError::InvalidArgument { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + } + })?, options.abort_signal.as_ref(), ) .await?; diff --git a/aimux-providers/src/google/image.rs b/aimux-providers/src/google/image.rs index c99e529b6..0d84081d8 100644 --- a/aimux-providers/src/google/image.rs +++ b/aimux-providers/src/google/image.rs @@ -123,7 +123,7 @@ impl ImageModel for GoogleImageModel { .or_default(); if let Some(search) = google.remove("googleSearch") { let invalid = - || AiMuxError::InvalidArgument("Invalid Google image googleSearch option".into()); + || AiMuxError::invalid_argument("Invalid Google image googleSearch option"); let object = search.as_object().ok_or_else(invalid)?; if object.get("searchTypes").is_some_and(|types| { !types.as_object().is_some_and(|types| { diff --git a/aimux-providers/src/google/mod.rs b/aimux-providers/src/google/mod.rs index e94835d86..5b94f30f1 100644 --- a/aimux-providers/src/google/mod.rs +++ b/aimux-providers/src/google/mod.rs @@ -232,7 +232,7 @@ impl std::fmt::Debug for GoogleProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_google(settings: GoogleProviderSettings) -> Result { diff --git a/aimux-providers/src/google/speech.rs b/aimux-providers/src/google/speech.rs index 0ca4d2230..7a1c25704 100644 --- a/aimux-providers/src/google/speech.rs +++ b/aimux-providers/src/google/speech.rs @@ -15,6 +15,7 @@ use serde::de::{Deserialize, Deserializer, Error as _}; use serde::{Deserialize as DeriveDeserialize, Serialize}; use serde_json::{Value, json}; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::shared::Warning; use aimux_core::speech_model::{ @@ -115,10 +116,9 @@ struct SpeechOptions { fn parse_options( raw: Option<&aimux_core::shared::JsonObject>, ) -> Result { - let invalid = |error: String| { - AiMuxError::InvalidArgument(format!( - "Invalid argument for parameter providerOptions: {error}" - )) + let invalid = |error: String, cause: Option| AiMuxError::InvalidArgument { + message: format!("Invalid argument for parameter providerOptions: {error}"), + cause, }; let Some(raw) = raw else { return Ok(SpeechOptions { @@ -127,10 +127,10 @@ fn parse_options( multi_speaker_voice_config: None, }); }; - let options: SpeechOptions = - serde_json::from_value(Value::Object(raw.clone())).map_err(|e| invalid(e.to_string()))?; + let options: SpeechOptions = serde_json::from_value(Value::Object(raw.clone())) + .map_err(|e| invalid(e.to_string(), Some(ErrorCause::source(&e))))?; if options.turns.as_ref().is_some_and(Vec::is_empty) { - return Err(invalid("turns must contain at least one turn".into())); + return Err(invalid("turns must contain at least one turn".into(), None)); } Ok(options) } @@ -291,8 +291,8 @@ impl GoogleSpeechModel { .as_ref(), ); if input.uses_custom_voice { - return Err(AiMuxError::InvalidArgument( - "Invalid argument for parameter voice: Custom voices are not supported. Use a prebuilt voice instead.".into(), + return Err(AiMuxError::invalid_argument( + "Invalid argument for parameter voice: Custom voices are not supported. Use a prebuilt voice instead.", )); } @@ -323,9 +323,8 @@ impl GoogleSpeechModel { let mut parts = vec![json!({"text": prompt_text})]; if structured { if google.turns.is_some() && google.speech_metadata.is_some() { - return Err(AiMuxError::InvalidArgument( - "Invalid argument for parameter providerOptions: Set speechMetadata on each turn when using turns." - .into(), + return Err(AiMuxError::invalid_argument( + "Invalid argument for parameter providerOptions: Set speechMetadata on each turn when using turns.", )); } if google.turns.is_some() && !text.is_empty() { @@ -353,9 +352,8 @@ impl GoogleSpeechModel { .iter() .any(|config| Some(&config.speaker) == speaker.as_ref()) { - return Err(AiMuxError::InvalidArgument( - "Invalid argument for parameter speechMetadata.speaker: Every multi-speaker turn must specify a speechMetadata.speaker matching a configured speaker." - .into(), + return Err(AiMuxError::invalid_argument( + "Invalid argument for parameter speechMetadata.speaker: Every multi-speaker turn must specify a speechMetadata.speaker matching a configured speaker.", )); } let mut part = json!({"text": part_text}); @@ -365,16 +363,14 @@ impl GoogleSpeechModel { parts.push(part); } } else if google.turns.is_some() || google.speech_metadata.is_some() { - return Err(AiMuxError::InvalidArgument( - "Invalid argument for parameter providerOptions: Structured speech metadata and turns require Gemini 3.8 TTS." - .into(), + return Err(AiMuxError::invalid_argument( + "Invalid argument for parameter providerOptions: Structured speech metadata and turns require Gemini 3.8 TTS.", )); } if input.text.is_empty() { - return Err(AiMuxError::InvalidArgument( - "Invalid argument for parameter text: Speech input must contain a non-empty transcript." - .into(), + return Err(AiMuxError::invalid_argument( + "Invalid argument for parameter text: Speech input must contain a non-empty transcript.", )); } @@ -489,8 +485,9 @@ impl SpeechModel for GoogleSpeechModel { let bytes = match inline.and_then(|inline| inline.data) { Some(data) => base64::engine::general_purpose::STANDARD .decode(data) - .map_err(|e| { - AiMuxError::InvalidResponseData(format!("invalid base64 audio: {e}")) + .map_err(|e| AiMuxError::InvalidResponseData { + message: format!("invalid base64 audio: {e}"), + cause: Some(ErrorCause::source(&e)), })?, None => Vec::new(), }; diff --git a/aimux-providers/src/google/transcription.rs b/aimux-providers/src/google/transcription.rs index e8c388040..7164ba3fa 100644 --- a/aimux-providers/src/google/transcription.rs +++ b/aimux-providers/src/google/transcription.rs @@ -15,6 +15,7 @@ use serde_json::{Map, Value, json}; use super::options::{google_metadata, no_null}; use crate::shared::EndpointConfig; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::shared::JsonObject; use aimux_core::transcription_model::{ @@ -66,9 +67,10 @@ impl TranscriptionOptions { match raw { None => Ok(Self::default()), Some(raw) => serde_json::from_value(Value::Object(raw.clone())).map_err(|error| { - AiMuxError::InvalidArgument(format!( - "Invalid argument for parameter providerOptions: {error}" - )) + AiMuxError::InvalidArgument { + message: format!("Invalid argument for parameter providerOptions: {error}"), + cause: Some(ErrorCause::source(&error)), + } }), } } @@ -209,7 +211,7 @@ impl TranscriptionModel for GoogleTranscriptionModel { options: &TranscriptionCallOptions, ) -> Result { if is_live(&self.model_id) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Model '{}' only supports streaming transcription. Use stream_transcribe, \ or a unary model such as 'gemini-3.5-transcribe'.", self.model_id @@ -302,7 +304,7 @@ impl TranscriptionModel for GoogleTranscriptionModel { ) -> Option>> { Some(Box::pin(async move { if !is_live(&self.model_id) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Model '{}' does not support streaming transcription. \ Use a live model such as 'gemini-3.5-transcribe-live'.", self.model_id @@ -397,8 +399,10 @@ impl GoogleTranscriptionModel { /// trailing `/v1beta` or `/v1alpha` of the base URL is dropped. #[cfg(feature = "realtime")] fn live_web_socket_url(base_url: &str, path: &str) -> Result { - let mut url = url::Url::parse(base_url) - .map_err(|error| AiMuxError::InvalidArgument(format!("invalid base URL: {error}")))?; + let mut url = url::Url::parse(base_url).map_err(|error| AiMuxError::InvalidArgument { + message: format!("invalid base URL: {error}"), + cause: Some(ErrorCause::source(&error)), + })?; let mut segments: Vec<&str> = url.path().split('/').collect(); if matches!(segments.last(), Some(&("v1beta" | "v1alpha"))) { segments.pop(); @@ -407,7 +411,7 @@ fn live_web_socket_url(base_url: &str, path: &str) -> Result Result<(), AiMuxError> { let format = &options.input_audio_format; if format.format_type != "audio/pcm" || format.rate.is_some_and(|rate| rate != 16000) { - return Err(AiMuxError::InvalidArgument( - "The Gemini Live transcription API only supports 16kHz 16-bit PCM input audio.".into(), + return Err(AiMuxError::invalid_argument( + "The Gemini Live transcription API only supports 16kHz 16-bit PCM input audio.", )); } Ok(()) @@ -496,7 +500,7 @@ pub(crate) fn live_stream( Some(Err(error)) => { yield Err(error); break; } None if audio_ended => { complete = true; break; } None => { - yield Err(AiMuxError::InvalidResponseData(format!("{label} Live transcription WebSocket closed unexpectedly before finishing"))); + yield Err(AiMuxError::invalid_response_data(format!("{label} Live transcription WebSocket closed unexpectedly before finishing"))); break; } }; @@ -509,7 +513,7 @@ pub(crate) fn live_stream( if message.get("setupComplete").is_some_and(|value| !value.is_null()) { ready = true; } if let Some(value) = message.get("usageMetadata").filter(|value| !value.is_null()) { usage_metadata = Some(value.clone()); } if let Some(error) = message.get("error").filter(|value| !value.is_null()) { - yield Err(AiMuxError::InvalidResponseData(error.get("message").and_then(Value::as_str).map_or_else(|| format!("{label} Live API error"), str::to_owned))); + yield Err(AiMuxError::invalid_response_data(error.get("message").and_then(Value::as_str).map_or_else(|| format!("{label} Live API error"), str::to_owned))); break; } let content = message.get("serverContent"); diff --git a/aimux-providers/src/google/utils.rs b/aimux-providers/src/google/utils.rs index bc38a0e1a..55782b22f 100644 --- a/aimux-providers/src/google/utils.rs +++ b/aimux-providers/src/google/utils.rs @@ -154,14 +154,14 @@ impl GoogleJsonAccumulator { /// Process a batch of partial args, returning the structured object and /// the JSON text delta for this call. /// - /// Returns [`AiMuxError::JsonParse`] when a partial-arg path conflicts with the + /// Returns [`AiMuxError::json_parse`] when a partial-arg path conflicts with the /// already-accumulated structure (e.g. `$.a` was a string, then `$.a[0]` /// arrives). `partialArgs` is provider-controlled input, so such conflicts /// must not panic. /// /// # Errors /// - /// Returns `AiMuxError::JsonParse` when a partial-arg JSON path conflicts + /// Returns `AiMuxError::json_parse` when a partial-arg JSON path conflicts /// with the already-accumulated structure. pub fn process_partial_args( &mut self, @@ -420,7 +420,7 @@ const MAX_PARTIAL_ARG_DEPTH: usize = 64; /// Set a value at a nested path, creating intermediate objects or arrays. /// -/// Returns [`AiMuxError::JsonParse`] instead of panicking when the incoming path +/// Returns [`AiMuxError::json_parse`] instead of panicking when the incoming path /// conflicts with the already-accumulated type (e.g. `$.a` was set to a /// string and a later `partialArgs` chunk targets `$.a[0].b`), or when the /// path exceeds the resource caps ([`MAX_PARTIAL_ARG_INDEX`], @@ -431,7 +431,7 @@ fn set_nested_value(obj: &mut Value, segments: &[Segment], value: Value) -> Resu return Ok(()); } if segments.len() > MAX_PARTIAL_ARG_DEPTH { - return Err(AiMuxError::JsonParse(format!( + return Err(AiMuxError::json_parse(format!( "partial args path exceeds maximum depth of {MAX_PARTIAL_ARG_DEPTH}" ))); } @@ -444,7 +444,7 @@ fn set_nested_value(obj: &mut Value, segments: &[Segment], value: Value) -> Resu if let Segment::Index(index) = segment && *index > MAX_PARTIAL_ARG_INDEX { - return Err(AiMuxError::JsonParse(format!( + return Err(AiMuxError::json_parse(format!( "partial args array index {index} exceeds maximum of {MAX_PARTIAL_ARG_INDEX}" ))); } @@ -499,14 +499,14 @@ fn set_nested_value(obj: &mut Value, segments: &[Segment], value: Value) -> Resu .ok_or_else(|| parent_must_be(seg))? .get_mut(k) .ok_or_else(|| { - AiMuxError::JsonParse(format!("partial args path conflict at {seg:?}")) + AiMuxError::json_parse(format!("partial args path conflict at {seg:?}")) })?, Segment::Index(i) => current .as_array_mut() .ok_or_else(|| parent_must_be(seg))? .get_mut(*i) .ok_or_else(|| { - AiMuxError::JsonParse(format!("partial args path conflict at {seg:?}")) + AiMuxError::json_parse(format!("partial args path conflict at {seg:?}")) })?, Segment::Root => current, }; @@ -537,7 +537,7 @@ fn set_nested_value(obj: &mut Value, segments: &[Segment], value: Value) -> Resu /// Build the path-type-conflict error for a segment whose actual JSON type /// does not match the path's expectation (e.g. an index into a string). fn parent_must_be(seg: &Segment) -> AiMuxError { - AiMuxError::JsonParse(format!( + AiMuxError::json_parse(format!( "partial args path type conflict at {seg:?}: accumulated value does not match the path" )) } diff --git a/aimux-providers/src/google/video.rs b/aimux-providers/src/google/video.rs index 15733284e..33545914c 100644 --- a/aimux-providers/src/google/video.rs +++ b/aimux-providers/src/google/video.rs @@ -74,7 +74,7 @@ impl VideoModel for GoogleVideoModel { _ => true, }; if !valid { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Invalid Google video option: {key}" ))); } @@ -186,7 +186,7 @@ impl VideoModel for GoogleVideoModel { .and_then(|v| v.as_str()) .filter(|name| !name.is_empty()) .ok_or_else(|| { - AiMuxError::InvalidResponseData("No operation name returned from API".to_string()) + AiMuxError::invalid_response_data("No operation name returned from API") })? .to_string(); @@ -211,7 +211,7 @@ impl VideoModel for GoogleVideoModel { .get("operationName") .and_then(Value::as_str) .ok_or_else(|| { - AiMuxError::InvalidArgument( + AiMuxError::invalid_argument( "google operation reference is missing operationName".to_string(), ) })?; @@ -256,7 +256,7 @@ impl VideoModel for GoogleVideoModel { .and_then(Value::as_array) .filter(|outputs| !outputs.is_empty()) .ok_or_else(|| { - AiMuxError::InvalidResponseData(format!( + AiMuxError::invalid_response_data(format!( "No videos in response. Response: {raw_body}" )) })?; @@ -283,7 +283,7 @@ impl VideoModel for GoogleVideoModel { }) .collect(); if videos.is_empty() { - return Err(AiMuxError::InvalidResponseData( + return Err(AiMuxError::invalid_response_data( "No valid videos in response".to_string(), )); } diff --git a/aimux-providers/src/google_pse.rs b/aimux-providers/src/google_pse.rs index 6c3c7af0d..926df119f 100644 --- a/aimux-providers/src/google_pse.rs +++ b/aimux-providers/src/google_pse.rs @@ -20,6 +20,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::Value; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, @@ -78,7 +79,7 @@ pub struct GooglePseProviderSettings { pub api_key: Option>, /// The search-engine ID (`cx`). `None` uses /// `providerOptions.google_pse.cx` of the call, then `GOOGLE_CSE_ID`; - /// a call with none of them fails with `AiMuxError::InvalidArgument`. + /// a call with none of them fails with `AiMuxError::invalid_argument`. pub cx: Option, /// Extra headers on every request. A `None` value removes the header. /// Per-call headers win over these. @@ -113,7 +114,7 @@ impl std::fmt::Debug for GooglePseProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_google_pse( @@ -265,7 +266,7 @@ impl SearchModel for GooglePseSearchModel { async fn do_search(&self, options: &SearchCallOptions) -> Result { let cx = resolve_cx(self.cx.as_deref(), options.provider_options.as_ref()) .ok_or_else(|| { - AiMuxError::InvalidArgument( + AiMuxError::invalid_argument( "Google PSE requires a `cx` (search-engine ID). Set the `cx` setting, the `GOOGLE_CSE_ID` \ environment variable or pass it via `provider_options[\"google_pse\"][\"cx\"]`." .to_string(), @@ -277,9 +278,11 @@ impl SearchModel for GooglePseSearchModel { let exchange = self.config.exchange(options.headers.as_ref()).await?; let api_key = self.credential.secret().await?.unwrap_or_default(); - let mut url = url::Url::parse(exchange.base_url()).map_err(|e| { - AiMuxError::InvalidArgument(format!("invalid google_pse endpoint: {e}")) - })?; + let mut url = + url::Url::parse(exchange.base_url()).map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid google_pse endpoint: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; { let mut qp = url.query_pairs_mut(); qp.append_pair("key", &api_key) diff --git a/aimux-providers/src/groq/mod.rs b/aimux-providers/src/groq/mod.rs index 54dffbbdc..9eaf8c8ae 100644 --- a/aimux-providers/src/groq/mod.rs +++ b/aimux-providers/src/groq/mod.rs @@ -87,7 +87,7 @@ impl std::fmt::Debug for GroqProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. The key is not read here. pub fn create_groq(settings: GroqProviderSettings) -> Result { let base_url = match settings.base_url.as_deref() { diff --git a/aimux-providers/src/groq/model.rs b/aimux-providers/src/groq/model.rs index 1511fd250..d8b8cc096 100644 --- a/aimux-providers/src/groq/model.rs +++ b/aimux-providers/src/groq/model.rs @@ -290,7 +290,7 @@ impl LanguageModel for GroqChatLanguageModel { let response = resp.value; let choice = response.choices.into_iter().next().ok_or_else(|| { - AiMuxError::InvalidResponseData("Response did not contain any choices.".to_string()) + AiMuxError::invalid_response_data("Response did not contain any choices.") })?; let mut content = Vec::new(); diff --git a/aimux-providers/src/groq/options.rs b/aimux-providers/src/groq/options.rs index 433561b5a..4e2bd186d 100644 --- a/aimux-providers/src/groq/options.rs +++ b/aimux-providers/src/groq/options.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::options::CallOptions; @@ -90,9 +91,10 @@ pub(crate) fn parse_groq_options( NAMESPACE, |groq| { serde_json::from_value(Value::Object(groq.clone())).map_err(|error| { - AiMuxError::InvalidArgument(format!( - "invalid argument for parameter providerOptions: {error}" - )) + AiMuxError::InvalidArgument { + message: format!("invalid argument for parameter providerOptions: {error}"), + cause: Some(ErrorCause::source(&error)), + } }) }, ) @@ -125,15 +127,16 @@ pub(crate) fn parse_groq_transcription_options( crate::openai::convert::parse_chat_provider_options(provider_options, NAMESPACE, |groq| { let parsed: GroqTranscriptionModelOptions = serde_json::from_value(Value::Object(groq.clone())).map_err(|error| { - AiMuxError::InvalidArgument(format!( - "invalid argument for parameter providerOptions: {error}" - )) + AiMuxError::InvalidArgument { + message: format!("invalid argument for parameter providerOptions: {error}"), + cause: Some(ErrorCause::source(&error)), + } })?; if parsed .temperature .is_some_and(|t| !(0.0..=1.0).contains(&t)) { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "invalid argument for parameter providerOptions: temperature must be between 0 and 1" .to_string(), )); diff --git a/aimux-providers/src/groq/transcription.rs b/aimux-providers/src/groq/transcription.rs index 85eb426c7..4ee805bee 100644 --- a/aimux-providers/src/groq/transcription.rs +++ b/aimux-providers/src/groq/transcription.rs @@ -9,6 +9,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::Value; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::transcription_model::{ AudioInput, TranscriptionCallOptions, TranscriptionModel, TranscriptionRequest, @@ -96,7 +97,10 @@ impl TranscriptionModel for GroqTranscriptionModel { AudioInput::Binary(bytes) => bytes.clone(), AudioInput::Base64(data) => { base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data).map_err( - |error| AiMuxError::InvalidArgument(format!("invalid base64: {error}")), + |error| AiMuxError::InvalidArgument { + message: format!("invalid base64: {error}"), + cause: Some(ErrorCause::source(&error)), + }, )? } }; diff --git a/aimux-providers/src/huggingface.rs b/aimux-providers/src/huggingface.rs index 5e1d82e74..ce76d95b2 100644 --- a/aimux-providers/src/huggingface.rs +++ b/aimux-providers/src/huggingface.rs @@ -100,7 +100,7 @@ impl std::fmt::Debug for HuggingFaceProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_huggingface( diff --git a/aimux-providers/src/hume.rs b/aimux-providers/src/hume.rs index 05ef9c39c..4fe4aae11 100644 --- a/aimux-providers/src/hume.rs +++ b/aimux-providers/src/hume.rs @@ -122,7 +122,7 @@ impl std::fmt::Debug for HumeProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_hume(settings: HumeProviderSettings) -> Result { diff --git a/aimux-providers/src/jev.rs b/aimux-providers/src/jev.rs index f9740bdd0..ba78484a4 100644 --- a/aimux-providers/src/jev.rs +++ b/aimux-providers/src/jev.rs @@ -98,8 +98,8 @@ impl Provider for JevProvider { fn decision_model(&self, model_id: &str) -> Option, AiMuxError>> { if model_id.trim().is_empty() { - return Some(Err(AiMuxError::InvalidArgument( - "decision model ID cannot be empty".into(), + return Some(Err(AiMuxError::invalid_argument( + "decision model ID cannot be empty", ))); } Some(Ok(Arc::new(JevDecisionModel { @@ -143,7 +143,7 @@ impl DecisionModel for JevDecisionModel { DecisionQuestion::Boolean { .. } => false, }; if over_limit { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "question {id:?} exceeds TypeSafe criteria limits" ))); } @@ -185,7 +185,7 @@ impl DecisionModel for JevDecisionModel { ); result.map_err(|error| { aimux_provider_utils::invalid_response_api_call( - error.to_string(), + &error, 200, &self.config.endpoint, body, diff --git a/aimux-providers/src/jina_ai.rs b/aimux-providers/src/jina_ai.rs index b7ee8b524..376a1efe3 100644 --- a/aimux-providers/src/jina_ai.rs +++ b/aimux-providers/src/jina_ai.rs @@ -102,7 +102,7 @@ impl std::fmt::Debug for JinaAiProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_jina_ai(settings: JinaAiProviderSettings) -> Result { diff --git a/aimux-providers/src/klingai.rs b/aimux-providers/src/klingai.rs index 5123bdd98..b453da862 100644 --- a/aimux-providers/src/klingai.rs +++ b/aimux-providers/src/klingai.rs @@ -101,7 +101,7 @@ impl std::fmt::Debug for KlingAIProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_klingai(settings: KlingAIProviderSettings) -> Result { @@ -352,14 +352,14 @@ impl VideoModel for KlingAIVideoModel { } let task_data = task.data.ok_or_else(|| { - AiMuxError::InvalidResponseData( + AiMuxError::invalid_response_data( "KlingAI task submission did not return data".to_string(), ) })?; let task_id = task_data.task_id; let id = task_data.id.ok_or_else(|| { - AiMuxError::InvalidResponseData( + AiMuxError::invalid_response_data( "KlingAI task submission did not return a job id".to_string(), ) })?; @@ -385,7 +385,7 @@ impl VideoModel for KlingAIVideoModel { ) -> Result { let field = |name: &str| { operation.get(name).and_then(Value::as_str).ok_or_else(|| { - AiMuxError::InvalidArgument(format!( + AiMuxError::invalid_argument(format!( "klingai operation reference is missing {name}" )) }) @@ -442,7 +442,7 @@ impl VideoModel for KlingAIVideoModel { }) .collect(); if videos.is_empty() { - return Err(AiMuxError::InvalidResponseData(format!( + return Err(AiMuxError::invalid_response_data(format!( "KlingAI task {task_id} succeeded without video URLs" ))); } diff --git a/aimux-providers/src/linkup.rs b/aimux-providers/src/linkup.rs index ccf42a438..5f2a6f7bb 100644 --- a/aimux-providers/src/linkup.rs +++ b/aimux-providers/src/linkup.rs @@ -129,7 +129,7 @@ impl std::fmt::Debug for LinkupProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_linkup(settings: LinkupProviderSettings) -> Result { diff --git a/aimux-providers/src/lmnt.rs b/aimux-providers/src/lmnt.rs index 22ba728f1..cc5320217 100644 --- a/aimux-providers/src/lmnt.rs +++ b/aimux-providers/src/lmnt.rs @@ -107,7 +107,7 @@ impl std::fmt::Debug for LMNTProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_lmnt(settings: LMNTProviderSettings) -> Result { diff --git a/aimux-providers/src/luma.rs b/aimux-providers/src/luma.rs index 4e33662ab..9d2f596ac 100644 --- a/aimux-providers/src/luma.rs +++ b/aimux-providers/src/luma.rs @@ -107,7 +107,7 @@ impl std::fmt::Debug for LumaProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_luma(settings: LumaProviderSettings) -> Result { @@ -235,8 +235,8 @@ impl ImageModel for LumaImageModel { // Validate all files are URL-based for file in files { if !matches!(file, ImageFile::Url { .. }) { - return Err(AiMuxError::InvalidArgument( - "Luma AI only supports URL-based images.".into(), + return Err(AiMuxError::invalid_argument( + "Luma AI only supports URL-based images.", )); } } @@ -271,8 +271,8 @@ impl ImageModel for LumaImageModel { } "modify_image" => { if files.len() > 1 { - return Err(AiMuxError::InvalidArgument( - "Luma AI modify_image only supports a single input image.".into(), + return Err(AiMuxError::invalid_argument( + "Luma AI modify_image only supports a single input image.", )); } let url = if let ImageFile::Url { url } = &files[0] { @@ -294,8 +294,8 @@ impl ImageModel for LumaImageModel { _ => { // image (default) if files.len() > 4 { - return Err(AiMuxError::InvalidArgument( - "Luma AI image supports up to 4 reference images.".into(), + return Err(AiMuxError::invalid_argument( + "Luma AI image supports up to 4 reference images.", )); } let arr: Vec = files @@ -322,8 +322,8 @@ impl ImageModel for LumaImageModel { } if options.mask.is_some() { - return Err(AiMuxError::InvalidArgument( - "Luma AI does not support mask-based image editing.".into(), + return Err(AiMuxError::invalid_argument( + "Luma AI does not support mask-based image editing.", )); } @@ -371,9 +371,7 @@ impl ImageModel for LumaImageModel { let generation_id = submit_body .get("id") .and_then(|v| v.as_str()) - .ok_or_else(|| { - AiMuxError::InvalidResponseData("missing id in Luma response".to_string()) - })? + .ok_or_else(|| AiMuxError::invalid_response_data("missing id in Luma response"))? .to_string(); // Poll for completion. @@ -401,7 +399,7 @@ impl ImageModel for LumaImageModel { .and_then(|v| v.as_str()) .map(|url| PollStep::Ready(url.to_string())) .ok_or_else(|| { - AiMuxError::InvalidResponseData(format!( + AiMuxError::invalid_response_data(format!( "Luma generation {generation_id} completed without assets.image" )) }); diff --git a/aimux-providers/src/mistral/embedding.rs b/aimux-providers/src/mistral/embedding.rs index 2243690c7..cec136515 100644 --- a/aimux-providers/src/mistral/embedding.rs +++ b/aimux-providers/src/mistral/embedding.rs @@ -51,7 +51,7 @@ impl EmbeddingModel for MistralEmbeddingModel { options: &EmbeddingCallOptions, ) -> Result { if options.values.len() > 32 { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "{} model {} supports at most 32 embeddings per call; received {}", self.provider(), self.model_id, @@ -145,8 +145,8 @@ fn parse_mistral_provider_options( .cloned(); let output_dtype = provider_opts.and_then(|o| o.get("outputDtype")); if metadata.as_ref().is_some_and(|value| !value.is_object()) { - return Err(AiMuxError::InvalidArgument( - "mistral.metadata must be a record".into(), + return Err(AiMuxError::invalid_argument( + "mistral.metadata must be a record", )); } if output_dimension.as_ref().is_some_and(|value| { @@ -154,8 +154,8 @@ fn parse_mistral_provider_options( number > 0.0 && number.fract() == 0.0 && number <= 9_007_199_254_740_991.0 }) }) { - return Err(AiMuxError::InvalidArgument( - "mistral.outputDimension must be a positive integer".into(), + return Err(AiMuxError::invalid_argument( + "mistral.outputDimension must be a positive integer", )); } if output_dtype.is_some_and(|value| { @@ -164,8 +164,8 @@ fn parse_mistral_provider_options( Some("float" | "int8" | "uint8" | "binary" | "ubinary") ) }) { - return Err(AiMuxError::InvalidArgument( - "mistral.outputDtype must be float, int8, uint8, binary, or ubinary".into(), + return Err(AiMuxError::invalid_argument( + "mistral.outputDtype must be float, int8, uint8, binary, or ubinary", )); } Ok(MistralEmbeddingProviderOptions { diff --git a/aimux-providers/src/mistral/mod.rs b/aimux-providers/src/mistral/mod.rs index 49ecce861..e47c4243d 100644 --- a/aimux-providers/src/mistral/mod.rs +++ b/aimux-providers/src/mistral/mod.rs @@ -155,7 +155,7 @@ impl std::fmt::Debug for MistralProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_mistral(settings: MistralProviderSettings) -> Result { diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index e3ad9af4d..1f3764ebc 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -188,10 +188,11 @@ impl LanguageModel for MistralModel { let response_headers = resp.response_headers; let data: ChatCompletionResponse = resp.value; - let choice = - data.choices.into_iter().next().ok_or_else(|| { - AiMuxError::InvalidResponseData("no choices in response".to_string()) - })?; + let choice = data + .choices + .into_iter() + .next() + .ok_or_else(|| AiMuxError::invalid_response_data("no choices in response"))?; // Build content array. let mut content = Vec::new(); diff --git a/aimux-providers/src/mistral/options.rs b/aimux-providers/src/mistral/options.rs index 2adc46237..1796b6cae 100644 --- a/aimux-providers/src/mistral/options.rs +++ b/aimux-providers/src/mistral/options.rs @@ -37,7 +37,7 @@ pub(crate) fn validated_mistral_options( _ => true, }; if !valid { - return Err(aimux_core::AiMuxError::InvalidArgument(format!( + return Err(aimux_core::AiMuxError::invalid_argument(format!( "Invalid mistral provider option {key}" ))); } @@ -48,7 +48,7 @@ pub(crate) fn validated_mistral_options( /// The `parseProviderOptions` failure: an `InvalidArgumentError` naming the /// provider, with the offending key as the cause. fn invalid(key: &str) -> AiMuxError { - AiMuxError::InvalidArgument(format!("invalid mistral provider options: {key}")) + AiMuxError::invalid_argument(format!("invalid mistral provider options: {key}")) } /// One optional key, converted by `get`; a present value of the wrong shape diff --git a/aimux-providers/src/mistral/transcription.rs b/aimux-providers/src/mistral/transcription.rs index a4b01622e..8d7733765 100644 --- a/aimux-providers/src/mistral/transcription.rs +++ b/aimux-providers/src/mistral/transcription.rs @@ -10,6 +10,7 @@ use base64::Engine as _; use serde::Deserialize; use serde_json::{Map, Number, Value, json}; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::shared::SharedProviderMetadata; use aimux_core::transcription_model::{ @@ -96,10 +97,9 @@ impl TranscriptionModel for MistralTranscriptionModel { // the combination locally provides a stable SDK error instead of an // API 4xx. if mistral.language.is_some() && mistral.timestamp_granularities.is_some() { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "providerOptions.mistral.language cannot be combined with \ - providerOptions.mistral.timestampGranularities" - .into(), + providerOptions.mistral.timestampGranularities", )); } @@ -108,7 +108,10 @@ impl TranscriptionModel for MistralTranscriptionModel { AudioInput::Binary(bytes) => bytes.clone(), AudioInput::Base64(data) => base64::engine::general_purpose::STANDARD .decode(data) - .map_err(|error| AiMuxError::InvalidArgument(format!("invalid base64: {error}")))?, + .map_err(|error| AiMuxError::InvalidArgument { + message: format!("invalid base64: {error}"), + cause: Some(ErrorCause::source(&error)), + })?, }; let mut form = MultipartForm::new(); form.text("model", &self.model_id)?; @@ -154,8 +157,8 @@ impl TranscriptionModel for MistralTranscriptionModel { .as_deref() .is_some_and(|kind| kind != "transcription_segment") }) { - return Err(AiMuxError::InvalidResponseData( - "Invalid transcription segment type".into(), + return Err(AiMuxError::invalid_response_data( + "Invalid transcription segment type", )); } diff --git a/aimux-providers/src/open_responses.rs b/aimux-providers/src/open_responses.rs index 4f285f390..bff9608cb 100644 --- a/aimux-providers/src/open_responses.rs +++ b/aimux-providers/src/open_responses.rs @@ -175,14 +175,14 @@ fn options_name_of(name: &str) -> String { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `name` is empty or `base_url` is +/// Returns `AiMuxError::invalid_argument` when `name` is empty or `base_url` is /// not an `http(s)` URL with a host. Credentials are resolved per request, not /// here. pub fn create_open_responses( settings: OpenResponsesProviderSettings, ) -> Result { if settings.name.trim().is_empty() { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "Open Responses requires a non-empty `name`.".to_string(), )); } @@ -322,7 +322,7 @@ impl LanguageModel for OpenResponsesModel { Some(d) => format!("Responses API returned no output ({d})"), None => "Responses API returned no output".to_string(), }; - return Err(AiMuxError::InvalidResponseData(message)); + return Err(AiMuxError::invalid_response_data(message)); } // Build content array from output items. diff --git a/aimux-providers/src/openai/convert.rs b/aimux-providers/src/openai/convert.rs index a89718d16..ab878f853 100644 --- a/aimux-providers/src/openai/convert.rs +++ b/aimux-providers/src/openai/convert.rs @@ -219,7 +219,7 @@ pub fn prepare_tools( /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when a message part cannot be +/// Returns `AiMuxError::invalid_argument` when a message part cannot be /// converted (e.g. an unsupported file part shape or a missing provider /// reference). pub fn convert_prompt_to_openai_messages_with_mode_fallible( @@ -280,7 +280,7 @@ fn convert_file_part_to_openai(file: &FilePart, part_index: usize) -> Result { let file_id = resolve_provider_reference(reference, "openai") - .map_err(AiMuxError::InvalidArgument)?; + .map_err(AiMuxError::invalid_argument)?; let mut part = json!({ "type": "file", "file": { "file_id": file_id } }); if let Some(bpt) = prompt_cache_breakpoint { part["prompt_cache_breakpoint"] = bpt; @@ -314,8 +314,8 @@ fn convert_file_part_to_openai(file: &FilePart, part_index: usize) -> Result Result "wav", @@ -378,7 +377,7 @@ fn convert_file_part_to_openai(file: &FilePart, part_index: usize) -> Result Result { if model_id.trim().is_empty() { - return Err(AiMuxError::InvalidArgument( - "decision model ID cannot be empty".into(), + return Err(AiMuxError::invalid_argument( + "decision model ID cannot be empty", )); } Ok(Self { @@ -104,8 +105,8 @@ fn score_level(level: Option<&DecisionInput>) -> Result { _ => return Err(unsupported()), }; if level.label.trim().is_empty() { - return Err(AiMuxError::InvalidArgument( - "OpenAI score level label cannot be blank".into(), + return Err(AiMuxError::invalid_argument( + "OpenAI score level label cannot be blank", )); } let mut wire = json!({"label": level.label}); @@ -130,7 +131,10 @@ fn request_body(model: &str, options: &DecisionCallOptions) -> Result ProviderOptions::default(), Some(openai) => serde_json::from_value(Value::Object(openai.clone())).map_err(|error| { - AiMuxError::InvalidArgument(format!("OpenAI decision options: {error}")) + AiMuxError::InvalidArgument { + message: format!("OpenAI decision options: {error}"), + cause: Some(ErrorCause::source(&error)), + } })?, }; let state = match &options.state { @@ -138,9 +142,7 @@ fn request_body(model: &str, options: &DecisionCallOptions) -> Result serde_json::to_string(other)?, }; if provider_options.images.len() > MAX_IMAGES { - return Err(AiMuxError::InvalidArgument( - "too many decision images".into(), - )); + return Err(AiMuxError::invalid_argument("too many decision images")); } let input = if provider_options.images.is_empty() { json!(state) @@ -151,9 +153,7 @@ fn request_body(model: &str, options: &DecisionCallOptions) -> Result Result Result { if !(2..=255).contains(&criteria.len()) { - return Err(AiMuxError::InvalidArgument( - "OpenAI choices require 2–255 options".into(), + return Err(AiMuxError::invalid_argument( + "OpenAI choices require 2–255 options", )); } let overrides = provider_options.choice_values.get(id); @@ -211,7 +211,7 @@ fn request_body(model: &str, options: &DecisionCallOptions) -> Result Result, _>>()?; let mut labels = HashSet::new(); if levels.iter().any(|level| !labels.insert(&level["label"])) { - return Err(AiMuxError::InvalidArgument( - "duplicate OpenAI score level label".into(), + return Err(AiMuxError::invalid_argument( + "duplicate OpenAI score level label", )); } json!({"type":"score", "instructions":text(instructions)?, "levels":levels}) @@ -252,8 +252,8 @@ fn request_body(model: &str, options: &DecisionCallOptions) -> Result 128 { - return Err(AiMuxError::InvalidArgument( - "safetyIdentifier exceeds 128 characters".into(), + return Err(AiMuxError::invalid_argument( + "safetyIdentifier exceeds 128 characters", )); } body["safety_identifier"] = json!(identifier); @@ -468,8 +468,12 @@ impl DecisionModel for OpenAIDecisionModel { let Request { body, values } = request_body(&self.model_id, options)?; let url = self.config.url("/decisions")?; crate::decision_support::post(&self.config, options, &url, body, |raw, headers| { - let data = serde_json::from_value(raw.clone()) - .map_err(|e| invalid(format!("OpenAI decisions: {e}")))?; + let data = serde_json::from_value(raw.clone()).map_err(|e| { + AiMuxError::InvalidResponseData { + message: format!("OpenAI decisions: {e}"), + cause: Some(ErrorCause::source(&e)), + } + })?; convert_response(data, raw, headers, options, &values) }) .await diff --git a/aimux-providers/src/openai/embedding.rs b/aimux-providers/src/openai/embedding.rs index 8b4bef080..a2ba011e4 100644 --- a/aimux-providers/src/openai/embedding.rs +++ b/aimux-providers/src/openai/embedding.rs @@ -8,6 +8,7 @@ use async_trait::async_trait; use serde_json::{Map, Value, json}; +use aimux_core::ErrorCause; use aimux_core::embedding_model::{ EmbeddingCallOptions, EmbeddingModel, EmbeddingResponse, EmbeddingResult, EmbeddingUsage, }; @@ -54,7 +55,7 @@ impl EmbeddingModel for OpenAIEmbeddingModel { options: &EmbeddingCallOptions, ) -> Result { if options.values.len() > 2048 { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "too many embedding values: {} exceeds 2048", options.values.len() ))); @@ -93,8 +94,13 @@ impl EmbeddingModel for OpenAIEmbeddingModel { let raw_value: Value = resp.value; - let parsed: OpenAIEmbeddingResponse = serde_json::from_value(raw_value.clone()) - .map_err(|error| AiMuxError::InvalidResponseData(error.to_string()))?; + let parsed: OpenAIEmbeddingResponse = + serde_json::from_value(raw_value.clone()).map_err(|error| { + AiMuxError::InvalidResponseData { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + } + })?; let embeddings = parsed.data.into_iter().map(|item| item.embedding).collect(); let usage = parsed.usage.map(|usage| EmbeddingUsage { tokens: usage.prompt_tokens as u32, @@ -147,12 +153,15 @@ fn parse_openai_provider_options( .is_some_and(|value| !value.is_number()) || value.get("user").is_some_and(|value| !value.is_string()) { - return Err(AiMuxError::InvalidArgument( - "invalid openai embedding options".into(), + return Err(AiMuxError::invalid_argument( + "invalid openai embedding options", )); } serde_json::from_value(Value::Object(value.clone())).map_err(|error| { - AiMuxError::InvalidArgument(format!("invalid openai embedding options: {error}")) + AiMuxError::InvalidArgument { + message: format!("invalid openai embedding options: {error}"), + cause: Some(ErrorCause::source(&error)), + } }) } None => Ok(OpenAIEmbeddingProviderOptions::default()), diff --git a/aimux-providers/src/openai/files.rs b/aimux-providers/src/openai/files.rs index 793aaebbc..6c3b80436 100644 --- a/aimux-providers/src/openai/files.rs +++ b/aimux-providers/src/openai/files.rs @@ -8,6 +8,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::json; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData, UploadFileResult}; use aimux_core::shared::{FileBytes, SharedProviderOptions}; @@ -56,8 +57,12 @@ fn data_to_bytes(data: &UploadFileData) -> Result, AiMuxError> { UploadFileData::Data { data } => match data { FileBytes::Binary(bytes) => Ok(bytes.clone()), FileBytes::Base64(b64) => { - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid base64: {e}"))) + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err( + |e| AiMuxError::InvalidArgument { + message: format!("invalid base64: {e}"), + cause: Some(ErrorCause::source(&e)), + }, + ) } }, UploadFileData::Text { text } => Ok(text.as_bytes().to_vec()), diff --git a/aimux-providers/src/openai/image.rs b/aimux-providers/src/openai/image.rs index 1f87d665b..6da766a25 100644 --- a/aimux-providers/src/openai/image.rs +++ b/aimux-providers/src/openai/image.rs @@ -11,6 +11,7 @@ use async_trait::async_trait; use serde_json::{Map, Value, json}; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, ImageResponse, @@ -193,7 +194,7 @@ fn validate_image_options(options: &SharedProviderOptions, edit: bool) -> Result if let Some(value) = options.get(name) && !value.as_str().is_some_and(|value| values.contains(&value)) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "invalid openai image option: {name}" ))); } @@ -213,13 +214,13 @@ fn validate_image_options(options: &SharedProviderOptions, edit: bool) -> Result .as_f64() .is_some_and(|value| (0.0..=100.0).contains(&value) && value.fract() == 0.0) { - return Err(AiMuxError::InvalidArgument( - "invalid openai image option: outputCompression".into(), + return Err(AiMuxError::invalid_argument( + "invalid openai image option: outputCompression", )); } if options.get("user").is_some_and(|value| !value.is_string()) { - return Err(AiMuxError::InvalidArgument( - "invalid openai image option: user".into(), + return Err(AiMuxError::invalid_argument( + "invalid openai image option: user", )); } Ok(()) @@ -334,8 +335,9 @@ async fn image_file_to_bytes( ImageFileData::Binary(bytes) => bytes.clone(), ImageFileData::Base64(value) => { base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value) - .map_err(|error| { - AiMuxError::InvalidArgument(format!("invalid base64: {error}")) + .map_err(|error| AiMuxError::InvalidArgument { + message: format!("invalid base64: {error}"), + cause: Some(ErrorCause::source(&error)), })? } }; diff --git a/aimux-providers/src/openai/mod.rs b/aimux-providers/src/openai/mod.rs index 99e57b31b..7f0a3eecd 100644 --- a/aimux-providers/src/openai/mod.rs +++ b/aimux-providers/src/openai/mod.rs @@ -246,7 +246,7 @@ impl std::fmt::Debug for OpenAIProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when the explicit `base_url` is not +/// Returns `AiMuxError::invalid_argument` when the explicit `base_url` is not /// an `http(s)` URL with a host. That is the only way this fails: the key is /// loaded per request, and an invalid `OPENAI_BASE_URL` is reported by each /// request, not here. diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 23527ee08..0ab0d5ac0 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -248,7 +248,7 @@ pub(crate) async fn execute_generate( let data = resp.value; let choice = data.choices.into_iter().next().ok_or_else(|| { - AiMuxError::InvalidResponseData("Response did not contain any choices.".to_string()) + AiMuxError::invalid_response_data("Response did not contain any choices.") })?; // Build content array. diff --git a/aimux-providers/src/openai/responses/convert.rs b/aimux-providers/src/openai/responses/convert.rs index 23e64f6db..3c358d45a 100644 --- a/aimux-providers/src/openai/responses/convert.rs +++ b/aimux-providers/src/openai/responses/convert.rs @@ -11,6 +11,7 @@ //! - `map-openai-responses-finish-reason.ts` -> [`map_responses_finish_reason`] use aimux_core::AiMuxError; +use aimux_core::ErrorCause; use serde_json::{Value, json}; use aimux_core::language_model_message::{ @@ -564,8 +565,12 @@ fn provider_tool_call_input( ) -> Result, AiMuxError> { let parsed; let args = if kind != "custom" && args.is_string() { - parsed = serde_json::from_str(args.as_str().unwrap_or_default()) - .map_err(|error| AiMuxError::InvalidArgument(error.to_string()))?; + parsed = serde_json::from_str(args.as_str().unwrap_or_default()).map_err(|error| { + AiMuxError::InvalidArgument { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + } + })?; &parsed } else { args @@ -591,7 +596,7 @@ fn provider_tool_call_input( .get("actions") .and_then(Value::as_array) .cloned() - .ok_or_else(|| AiMuxError::InvalidArgument("computer actions".into()))?; + .ok_or_else(|| AiMuxError::invalid_argument("computer actions"))?; for action in &mut actions { if action["type"] == "scroll" && let Some(object) = action.as_object_mut() @@ -610,8 +615,10 @@ fn provider_tool_call_input( } "tool_search" => { let args = if let Some(text) = args.as_str() { - serde_json::from_str(text) - .map_err(|error| AiMuxError::InvalidArgument(error.to_string()))? + serde_json::from_str(text).map_err(|error| AiMuxError::InvalidArgument { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + })? } else { args.clone() }; @@ -658,7 +665,7 @@ fn provider_tool_result_input( .get("output") .and_then(Value::as_array) .cloned() - .ok_or_else(|| AiMuxError::InvalidArgument("shell output".into()))?; + .ok_or_else(|| AiMuxError::invalid_argument("shell output"))?; for item in &mut output { if let Some(outcome) = item.get_mut("outcome").and_then(Value::as_object_mut) && let Some(code) = outcome.remove("exitCode") @@ -740,7 +747,10 @@ fn convert_user_part( } FileData::Reference { reference } => { let file_id = reference.get(ns.write_key()).ok_or_else(|| { - AiMuxError::InvalidArgument(format!("No file reference for {}", ns.write_key())) + AiMuxError::invalid_argument(format!( + "No file reference for {}", + ns.write_key() + )) })?; let mut part = json!({ "type": if file.media_type.split('/').next() == Some("image") { "input_image" } else { "input_file" }, "file_id": file_id }); if part["type"] == "input_image" @@ -1008,18 +1018,15 @@ fn prepare_responses_tools_for( &mut tool_warnings, ); if let Some(namespace) = opts.get("namespace") { - let name = - namespace - .get("name") - .and_then(Value::as_str) - .ok_or_else(|| { - AiMuxError::InvalidArgument("tool namespace name".into()) - })?; + let name = namespace + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| AiMuxError::invalid_argument("tool namespace name"))?; let description = namespace .get("description") .and_then(Value::as_str) .ok_or_else(|| { - AiMuxError::InvalidArgument("tool namespace description".into()) + AiMuxError::invalid_argument("tool namespace description") })?; if let Some(group) = prepared .iter_mut() @@ -1117,7 +1124,7 @@ fn prepare_allowed_tools( tools: &[Tool], warnings: &mut Vec, ) -> Result { - let invalid = || AiMuxError::InvalidArgument("allowedTools".into()); + let invalid = || AiMuxError::invalid_argument("allowedTools"); let names = allowed .get("toolNames") .and_then(Value::as_array) @@ -1424,7 +1431,7 @@ fn map_shell_environment(environment: &Value) -> Result { if let Some(skills) = environment.get("skills").and_then(Value::as_array) { value["skills"] = Value::Array(skills.iter().map(|skill| { if skill["type"] == "skillReference" { - let id = skill.get("providerReference").and_then(|reference| reference.get("openai")).and_then(Value::as_str).ok_or_else(|| AiMuxError::InvalidArgument("No skill reference for openai".into()))?; + let id = skill.get("providerReference").and_then(|reference| reference.get("openai")).and_then(Value::as_str).ok_or_else(|| AiMuxError::invalid_argument("No skill reference for openai"))?; Ok(json!({"type":"skill_reference", "skill_id":id, "version":skill.get("version").cloned().unwrap_or_else(|| json!("latest"))})) } else { let mut value = map_tool_fields(skill, &[("name", "name"), ("description", "description")]); diff --git a/aimux-providers/src/openai/responses/mod.rs b/aimux-providers/src/openai/responses/mod.rs index 7377faa98..006e6a5f9 100644 --- a/aimux-providers/src/openai/responses/mod.rs +++ b/aimux-providers/src/openai/responses/mod.rs @@ -120,7 +120,7 @@ impl OpenAIResponsesModel { ) }) { - return Err(AiMuxError::InvalidArgument("reasoningEffortUpdate".into())); + return Err(AiMuxError::invalid_argument("reasoningEffortUpdate")); } let update_incompatible = provider_options.is_some_and(|v| { v.get("reasoningMode") == Some(&serde_json::json!("pro")) @@ -143,7 +143,7 @@ impl OpenAIResponsesModel { .and_then(Value::as_str) { if !matches!(effort, "none" | "low" | "medium" | "high" | "xhigh" | "max") { - return Err(AiMuxError::InvalidArgument("reasoningEffortUpdate".into())); + return Err(AiMuxError::invalid_argument("reasoningEffortUpdate")); } if !content.is_empty() || !caps.supports_configuration_update @@ -190,7 +190,7 @@ impl OpenAIResponsesModel { FileData::Reference { reference } if !reference.contains_key(profile.namespace.write_key()) => { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "No file reference for {}", profile.namespace.write_key() ))); diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_convert.rs index f9e0de143..9a2b2e8b2 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_convert.rs @@ -147,7 +147,7 @@ pub(crate) fn build_responses_generate_result_with_tools( .and_then(|v| v.as_str()) .unwrap_or(""); // A success response that cannot yield a usable result (§2.2). - AiMuxError::InvalidResponseData(if detail.is_empty() { + AiMuxError::invalid_response_data(if detail.is_empty() { "Responses API returned no output".to_string() } else { format!("Responses API returned no output ({detail})") @@ -436,7 +436,7 @@ fn text_metadata(item: &Value, annotations: Option<&Vec>) -> Value { fn annotation_source(annotation: &Value, provider_key: &str) -> Result { let invalid = |field: &str| { - AiMuxError::InvalidResponseData(format!("Invalid Responses annotation field: {field}")) + AiMuxError::invalid_response_data(format!("Invalid Responses annotation field: {field}")) }; let string = |field| { annotation diff --git a/aimux-providers/src/openai/responses/tool_args.rs b/aimux-providers/src/openai/responses/tool_args.rs index 337f7959d..df0c3fef7 100644 --- a/aimux-providers/src/openai/responses/tool_args.rs +++ b/aimux-providers/src/openai/responses/tool_args.rs @@ -274,7 +274,7 @@ pub(crate) fn validate_tool_args(kind: &str, args: &Value) -> Result<(), AiMuxEr if valid { Ok(()) } else { - Err(AiMuxError::InvalidArgument(format!( + Err(AiMuxError::invalid_argument(format!( "Invalid arguments for provider tool openai.{kind}" ))) } @@ -375,7 +375,7 @@ pub(super) fn validate_tool_call(kind: &str, input: &Value) -> Result<(), AiMuxE if valid { Ok(()) } else { - Err(AiMuxError::InvalidArgument(format!( + Err(AiMuxError::invalid_argument(format!( "Invalid input for provider tool openai.{kind}" ))) } @@ -430,7 +430,7 @@ pub(super) fn validate_tool_result(kind: &str, output: &Value) -> Result<(), AiM if valid { Ok(()) } else { - Err(AiMuxError::InvalidArgument(format!( + Err(AiMuxError::invalid_argument(format!( "Invalid output for provider tool openai.{kind}" ))) } diff --git a/aimux-providers/src/openai/speech.rs b/aimux-providers/src/openai/speech.rs index 97459975c..b39a470f3 100644 --- a/aimux-providers/src/openai/speech.rs +++ b/aimux-providers/src/openai/speech.rs @@ -146,7 +146,7 @@ fn build_request_body_and_warnings( .as_f64() .filter(|speed| (0.25..=4.0).contains(speed)) .ok_or_else(|| { - AiMuxError::InvalidArgument("invalid openai speech option: speed".into()) + AiMuxError::invalid_argument("invalid openai speech option: speed") })?; body.insert("speed".into(), json!(speed)); } @@ -155,7 +155,7 @@ fn build_request_body_and_warnings( .filter(|value| !value.is_null()) { let instructions = value.as_str().ok_or_else(|| { - AiMuxError::InvalidArgument("invalid openai speech option: instructions".into()) + AiMuxError::invalid_argument("invalid openai speech option: instructions") })?; body.insert("instructions".into(), json!(instructions)); } diff --git a/aimux-providers/src/openai/tools.rs b/aimux-providers/src/openai/tools.rs index 0e7380211..c6bec675b 100644 --- a/aimux-providers/src/openai/tools.rs +++ b/aimux-providers/src/openai/tools.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; +use aimux_core::ErrorCause; use aimux_core::{AiMuxError, tool::ProviderTool, types::NamespacedKind}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -521,14 +522,16 @@ fn openai_id(kind: &str) -> NamespacedKind { } fn provider_tool(kind: &str, args: impl Serialize) -> Result { - let args = serde_json::to_value(args) - .map_err(|error| AiMuxError::InvalidArgument(error.to_string()))?; + let args = serde_json::to_value(args).map_err(|error| AiMuxError::InvalidArgument { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + })?; super::responses::tool_args::validate_tool_args(kind, &args)?; Ok(ProviderTool { id: openai_id(kind), name: kind.to_owned(), args: args.as_object().cloned().ok_or_else(|| { - AiMuxError::InvalidArgument("provider tool arguments must be an object".into()) + AiMuxError::invalid_argument("provider tool arguments must be an object") })?, supports_deferred_results: None, }) diff --git a/aimux-providers/src/openai/transcription.rs b/aimux-providers/src/openai/transcription.rs index de8944e56..7cd68278c 100644 --- a/aimux-providers/src/openai/transcription.rs +++ b/aimux-providers/src/openai/transcription.rs @@ -21,6 +21,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::Value; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::shared::{SharedProviderOptions, Warning}; use aimux_core::transcription_model::{ @@ -204,8 +205,12 @@ fn audio_input_to_bytes(audio: &AudioInput) -> Result, AiMuxError> { match audio { AudioInput::Binary(bytes) => Ok(bytes.clone()), AudioInput::Base64(b64) => { - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid base64: {e}"))) + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err(|e| { + AiMuxError::InvalidArgument { + message: format!("invalid base64: {e}"), + cause: Some(ErrorCause::source(&e)), + } + }) } AudioInput::Url(_) => Err(AudioInput::url_error()), } @@ -496,10 +501,12 @@ impl OpenAITranscriptionModel { // Session establishment (send session.update). Send errors are // abort-aware inside ws.send_text. - ws.send_text( - &serde_json::to_string(&session_update) - .map_err(|e| AiMuxError::JsonParse(format!("serialize session.update: {e}")))?, - ) + ws.send_text(&serde_json::to_string(&session_update).map_err(|e| { + AiMuxError::JsonParse { + message: format!("serialize session.update: {e}"), + cause: Some(ErrorCause::source(&e)), + } + })?) .await?; let mut audio = options.audio; diff --git a/aimux-providers/src/openai_compatible/chat.rs b/aimux-providers/src/openai_compatible/chat.rs index 00d0399a4..907ce5aa0 100644 --- a/aimux-providers/src/openai_compatible/chat.rs +++ b/aimux-providers/src/openai_compatible/chat.rs @@ -216,7 +216,7 @@ impl LanguageModel for OpenAICompatibleChatModel { let data = resp.value; let choice = data.choices.into_iter().next().ok_or_else(|| { - AiMuxError::InvalidResponseData("Response did not contain any choices.".to_string()) + AiMuxError::invalid_response_data("Response did not contain any choices.") })?; let mut content = Vec::new(); @@ -644,7 +644,7 @@ impl Transformer for OpenAICompatibleChatStream { if self.final_finish_reason.is_none() { controller.enqueue(StreamPart::Error { - error: AiMuxError::InvalidResponseData( + error: AiMuxError::invalid_response_data( "Response stream ended without a finish reason.".to_string(), ), }); @@ -880,7 +880,10 @@ mod tests { let provider = provider(lazy, ChatSettings::default()); let model = provider.chat("m"); let error = model.do_generate(&hello()).await.unwrap_err(); - assert!(matches!(error, AiMuxError::InvalidArgument(_)), "{error:?}"); + assert!( + matches!(error, AiMuxError::InvalidArgument { .. }), + "{error:?}" + ); // SAFETY: as above. unsafe { std::env::set_var(VAR, server.uri()) }; let result = model.do_generate(&hello()).await; diff --git a/aimux-providers/src/openai_compatible/completion.rs b/aimux-providers/src/openai_compatible/completion.rs index 94bc61d34..db78661e6 100644 --- a/aimux-providers/src/openai_compatible/completion.rs +++ b/aimux-providers/src/openai_compatible/completion.rs @@ -119,7 +119,7 @@ impl OpenAICompatibleCompletionModel { _ => true, }; if !valid { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "invalid {namespace} provider option {key}" ))); } @@ -212,8 +212,8 @@ fn completion_prompt(options: &CallOptions) -> Result { text.push_str("\n\n"); } LanguageModelMessage::System { .. } => { - return Err(AiMuxError::InvalidPrompt( - "Unexpected system message in prompt".into(), + return Err(AiMuxError::invalid_prompt( + "Unexpected system message in prompt", )); } LanguageModelMessage::User { content, .. } => { @@ -270,7 +270,7 @@ fn parse_response( native: bool, streaming: bool, ) -> Result { - let invalid = || AiMuxError::InvalidResponseData("invalid completion response".into()); + let invalid = || AiMuxError::invalid_response_data("invalid completion response"); if let Some(usage) = value.get("usage").filter(|value| !value.is_null()) && !["prompt_tokens", "completion_tokens", "total_tokens"] .iter() @@ -408,7 +408,7 @@ impl LanguageModel for OpenAICompatibleCompletionModel { .choices .drain(..) .next() - .ok_or_else(|| AiMuxError::InvalidResponseData("no choices in response".into()))?; + .ok_or_else(|| AiMuxError::invalid_response_data("no choices in response"))?; let provider_metadata = self.native().then(|| { provider_namespace( "openai", diff --git a/aimux-providers/src/openai_compatible/config.rs b/aimux-providers/src/openai_compatible/config.rs index 579572f90..852c7fc8c 100644 --- a/aimux-providers/src/openai_compatible/config.rs +++ b/aimux-providers/src/openai_compatible/config.rs @@ -10,6 +10,7 @@ //! `transformRequestBody`) are kept as given. The shared model never compares //! a provider name. +use aimux_core::SourceError; use futures::future::BoxFuture; use std::collections::HashMap; use std::sync::Arc; @@ -18,6 +19,7 @@ use aimux_core::types::{ProviderMetadata, Usage}; use serde_json::Value; use aimux_core::AiMuxError; +use aimux_core::ErrorCause; use aimux_core::language_model::SupportedUrls; use aimux_provider_utils::{ ExchangeContext, Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, HeaderMapOpt, @@ -233,9 +235,11 @@ impl CompatModelConfig { /// origin credentialed headers may be sent to. pub(crate) fn url(&self, path: &str) -> Result<(String, String), AiMuxError> { let base = self.base_url.resolve()?; - let mut url = url::Url::parse(&format!("{base}{path}")).map_err(|e| { - AiMuxError::InvalidArgument(format!("invalid request URL for {}: {e}", self.provider)) - })?; + let mut url = + url::Url::parse(&format!("{base}{path}")).map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid request URL for {}: {e}", self.provider), + cause: Some(ErrorCause::source(&e)), + })?; if let Some(params) = &self.query_params { // As the AI SDK does: the configured parameters replace whatever // query the base URL carried. @@ -353,8 +357,13 @@ struct FetchUserAgent { #[async_trait::async_trait] impl Fetch for FetchUserAgent { async fn fetch(&self, mut request: FetchRequest) -> Result { - let value = reqwest::header::HeaderValue::try_from(self.user_agent.as_str()) - .map_err(|_| FetchError::Other("invalid header value for user-agent".to_string()))?; + let value = + reqwest::header::HeaderValue::try_from(self.user_agent.as_str()).map_err(|error| { + FetchError::Other(SourceError::with_cause( + "invalid header value for user-agent", + &error, + )) + })?; request.headers.insert(reqwest::header::USER_AGENT, value); self.inner .clone() diff --git a/aimux-providers/src/openai_compatible/convert.rs b/aimux-providers/src/openai_compatible/convert.rs index c16fc641b..43005077c 100644 --- a/aimux-providers/src/openai_compatible/convert.rs +++ b/aimux-providers/src/openai_compatible/convert.rs @@ -153,7 +153,7 @@ fn option_string( match merged.get(key) { None => Ok(None), Some(Value::String(value)) => Ok(Some(value.clone())), - Some(_) => Err(AiMuxError::InvalidArgument(format!( + Some(_) => Err(AiMuxError::invalid_argument(format!( "invalid provider options for \"{namespace_name}\": `{key}` must be a string" ))), } @@ -167,7 +167,7 @@ fn validate_options(options: &Map, namespace_name: &str) -> Resul .get("strictJsonSchema") .is_some_and(|value| !value.is_boolean()) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "invalid provider options for \"{namespace_name}\": `strictJsonSchema` must be a boolean" ))); } diff --git a/aimux-providers/src/openai_compatible/embedding.rs b/aimux-providers/src/openai_compatible/embedding.rs index baec1c17e..b26f0f540 100644 --- a/aimux-providers/src/openai_compatible/embedding.rs +++ b/aimux-providers/src/openai_compatible/embedding.rs @@ -7,6 +7,7 @@ use async_trait::async_trait; use serde_json::{Map, Value, json}; +use aimux_core::ErrorCause; use aimux_core::embedding_model::{ EmbeddingCallOptions, EmbeddingModel, EmbeddingResponse, EmbeddingResult, EmbeddingUsage, }; @@ -50,7 +51,7 @@ fn embedding_options( ] { if let Some(value) = object.get(setting) { if !valid(value) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "invalid providerOptions.{key}.{setting}" ))); } @@ -109,7 +110,7 @@ impl EmbeddingModel for OpenAICompatibleEmbeddingModel { let compatible_options = embedding_options(options, self.config.provider_options_name(), &mut warnings)?; if options.values.len() > self.max_embeddings_per_call().unwrap_or(2048) as usize { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Too many embedding values for {} / {}: maximum 2048, received {}", self.provider(), self.model_id(), @@ -172,7 +173,10 @@ impl EmbeddingModel for OpenAICompatibleEmbeddingModel { .get("providerMetadata") .map(|metadata| serde_json::from_value(metadata.clone())) .transpose() - .map_err(|error| AiMuxError::InvalidResponseData(error.to_string()))?, + .map_err(|error| AiMuxError::InvalidResponseData { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + })?, response: Some(EmbeddingResponse { headers: Some(response_headers), body: Some(raw_value), diff --git a/aimux-providers/src/openai_compatible/image.rs b/aimux-providers/src/openai_compatible/image.rs index 580807dd9..2077f50c6 100644 --- a/aimux-providers/src/openai_compatible/image.rs +++ b/aimux-providers/src/openai_compatible/image.rs @@ -10,6 +10,7 @@ use async_trait::async_trait; use serde_json::{Map, Value, json}; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, ImageResponse, @@ -63,7 +64,10 @@ async fn file_bytes(file: &ImageFile) -> Result<(Vec, String), AiMuxError> { ImageFileData::Binary(bytes) => bytes.clone(), ImageFileData::Base64(b64) => { base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid base64: {e}")))? + .map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid base64: {e}"), + cause: Some(ErrorCause::source(&e)), + })? } }; Ok((bytes, media_type.clone())) diff --git a/aimux-providers/src/openai_compatible/mod.rs b/aimux-providers/src/openai_compatible/mod.rs index 34f6d4e02..cd2777745 100644 --- a/aimux-providers/src/openai_compatible/mod.rs +++ b/aimux-providers/src/openai_compatible/mod.rs @@ -136,7 +136,7 @@ impl std::fmt::Debug for OpenAICompatibleProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. The key is not read /// here. pub fn create_openai_compatible( diff --git a/aimux-providers/src/parallel_ai.rs b/aimux-providers/src/parallel_ai.rs index 66f33c9d8..25a053633 100644 --- a/aimux-providers/src/parallel_ai.rs +++ b/aimux-providers/src/parallel_ai.rs @@ -103,7 +103,7 @@ impl std::fmt::Debug for ParallelAiProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_parallel_ai( diff --git a/aimux-providers/src/preset.rs b/aimux-providers/src/preset.rs index 52feb5744..18ed46bf0 100644 --- a/aimux-providers/src/preset.rs +++ b/aimux-providers/src/preset.rs @@ -398,7 +398,7 @@ fn assemble( .iter() .find(|spec| &spec.name == name && spec.derive.is_none()); if declared.is_none() { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "preset '{}' has no template parameter `{name}`; declared parameters: [{}]", descriptor.name, settable_params(descriptor).join(", ") @@ -497,7 +497,7 @@ fn check_param_value( if plain { Ok(()) } else { - Err(AiMuxError::InvalidArgument(format!( + Err(AiMuxError::invalid_argument(format!( "preset '{}': template parameter `{name}` must be a plain segment \ (letters, digits, `.`, `_`, `-`; no `/`, `@`, `:`, `?`, `#` or spaces), got {value:?}", descriptor.name @@ -523,8 +523,10 @@ pub(crate) fn resolve_base_url( if let Some(var) = descriptor.base_url_env.as_deref() && let Some(url) = env_value(&[var]) { - return validate_base_url(&url) - .map_err(|e| AiMuxError::InvalidArgument(format!("{var}: {e}"))); + return validate_base_url(&url).map_err(|e| AiMuxError::InvalidArgument { + message: format!("{var}: {e}"), + cause: Some(e.into()), + }); } let mut values: HashMap<&str, String> = HashMap::new(); @@ -544,7 +546,7 @@ pub(crate) fn resolve_base_url( } else { format!(" or set {}", spec.env.join(" / ")) }; - AiMuxError::InvalidArgument(format!( + AiMuxError::invalid_argument(format!( "preset '{}' needs the template parameter `{}`: pass it in `params`{source}, \ or pass a concrete `base_url`", descriptor.name, spec.name @@ -556,7 +558,7 @@ pub(crate) fn resolve_base_url( for spec in descriptor.params.iter() { let Some(derive) = &spec.derive else { continue }; let from = values.get(derive.from.as_str()).cloned().ok_or_else(|| { - AiMuxError::InvalidArgument(format!( + AiMuxError::invalid_argument(format!( "preset '{}': derived parameter `{}` depends on undeclared `{}`", descriptor.name, spec.name, derive.from )) @@ -579,7 +581,7 @@ pub(crate) fn resolve_base_url( url = url.replace(&format!("{{{name}}}"), value); } if url.chars().any(|c| matches!(c, '{' | '}' | '<' | '>')) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "preset '{}': base URL {:?} still has an unexpanded placeholder after expanding its \ parameters", descriptor.name, url diff --git a/aimux-providers/src/prodia.rs b/aimux-providers/src/prodia.rs index 172f3523c..178bfd132 100644 --- a/aimux-providers/src/prodia.rs +++ b/aimux-providers/src/prodia.rs @@ -107,7 +107,7 @@ impl std::fmt::Debug for ProdiaProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_prodia(settings: ProdiaProviderSettings) -> Result { @@ -364,7 +364,7 @@ impl ImageModel for ProdiaImageModel { p.strip_prefix("boundary=").map(|s| s.trim().to_string()) }) .ok_or_else(|| { - AiMuxError::InvalidResponseData(format!( + AiMuxError::invalid_response_data(format!( "Prodia response missing multipart boundary: {content_type}" )) })?; @@ -390,7 +390,7 @@ impl ImageModel for ProdiaImageModel { } let image_bytes = image_bytes.ok_or_else(|| { - AiMuxError::InvalidResponseData("Prodia multipart response missing output image".into()) + AiMuxError::invalid_response_data("Prodia multipart response missing output image") })?; let job_result = job_result.unwrap_or(Value::Null); @@ -483,7 +483,7 @@ impl VideoModel for ProdiaVideoModel { .get("job") .and_then(|v| v.as_str()) .ok_or_else(|| { - AiMuxError::InvalidResponseData( + AiMuxError::invalid_response_data( "Prodia job submission response missing job id".to_string(), ) })? @@ -510,7 +510,7 @@ impl VideoModel for ProdiaVideoModel { .get("job_id") .and_then(Value::as_str) .ok_or_else(|| { - AiMuxError::InvalidArgument( + AiMuxError::invalid_argument( "prodia operation reference is missing job_id".to_string(), ) })?; @@ -558,7 +558,7 @@ impl VideoModel for ProdiaVideoModel { .or_else(|| raw_body.get("video_url")) .and_then(|v| v.as_str()) .ok_or_else(|| { - AiMuxError::InvalidResponseData("Prodia job done without a video URL".to_string()) + AiMuxError::invalid_response_data("Prodia job done without a video URL") })?; let videos: Vec = vec![VideoData::Url { url: video_url.to_string(), diff --git a/aimux-providers/src/recraft.rs b/aimux-providers/src/recraft.rs index 32a7cb82e..9f118657f 100644 --- a/aimux-providers/src/recraft.rs +++ b/aimux-providers/src/recraft.rs @@ -108,7 +108,7 @@ impl std::fmt::Debug for RecraftProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_recraft(settings: RecraftProviderSettings) -> Result { diff --git a/aimux-providers/src/replay.rs b/aimux-providers/src/replay.rs index f6d8516b4..ad28e7c9e 100644 --- a/aimux-providers/src/replay.rs +++ b/aimux-providers/src/replay.rs @@ -29,8 +29,8 @@ pub fn rebuild_decision_provider( ) -> Result, AiMuxError> { let provider_id = p.registry_id()?; if p.model_id.is_empty() { - return Err(AiMuxError::InvalidArgument( - "decision replay: empty provider/model identity".into(), + return Err(AiMuxError::invalid_argument( + "decision replay: empty provider/model identity", )); } let model = registry @@ -38,8 +38,8 @@ pub fn rebuild_decision_provider( .decision_model(&p.model_id) .unwrap_or_else(|| Err(AiMuxError::no_such_model(&p.model_id, "decisionModel")))?; if model.provider() != p.provider { - return Err(AiMuxError::InvalidArgument( - "decision replay: registered model provider differs from recording".into(), + return Err(AiMuxError::invalid_argument( + "decision replay: registered model provider differs from recording", )); } Ok(model) @@ -61,15 +61,15 @@ pub fn rebuild_provider( ) -> Result, AiMuxError> { p.registry_id()?; if p.model_id.is_empty() { - return Err(AiMuxError::InvalidArgument( - "mock replay: provider record has empty model_id".into(), + return Err(AiMuxError::invalid_argument( + "mock replay: provider record has empty model_id", )); } let model = registry .provider(&p.provider_id)? .language_model(&p.model_id)?; if model.provider() != p.provider { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "mock replay: the recording was made with `{}` but provider '{}' rebuilds `{}`; \ pass the model to replay_with_model", p.provider, @@ -91,7 +91,7 @@ mod tests { let err = rebuild_provider(&ProviderRecord::new("groq", "groq.chat", ""), ®istry) .err() .unwrap(); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err}"); + assert!(matches!(err, AiMuxError::InvalidArgument { .. }), "{err}"); } /// A recording written by master (aimux 0.5, schema 2) has no @@ -109,7 +109,7 @@ mod tests { let err = rebuild_provider(&recording.provider, ®istry) .err() .unwrap(); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err}"); + assert!(matches!(err, AiMuxError::InvalidArgument { .. }), "{err}"); assert!(err.to_string().contains("no provider_id"), "{err}"); assert!(err.to_string().contains("aimux 0.5"), "{err}"); let err = rebuild_decision_provider(&recording.provider, ®istry) diff --git a/aimux-providers/src/replicate.rs b/aimux-providers/src/replicate.rs index 66c77f022..18656aefd 100644 --- a/aimux-providers/src/replicate.rs +++ b/aimux-providers/src/replicate.rs @@ -107,7 +107,7 @@ impl std::fmt::Debug for ReplicateProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_replicate( @@ -543,9 +543,7 @@ impl VideoModel for ReplicateVideoModel { let prediction_id = prediction .get("id") .and_then(|v| v.as_str()) - .ok_or_else(|| { - AiMuxError::InvalidResponseData("Replicate prediction missing id".to_string()) - })? + .ok_or_else(|| AiMuxError::invalid_response_data("Replicate prediction missing id"))? .to_string(); Ok(VideoOperationStart { @@ -569,7 +567,7 @@ impl VideoModel for ReplicateVideoModel { .get("prediction_id") .and_then(Value::as_str) .ok_or_else(|| { - AiMuxError::InvalidArgument( + AiMuxError::invalid_argument( "replicate operation reference is missing prediction_id".to_string(), ) })?; @@ -631,7 +629,7 @@ impl VideoModel for ReplicateVideoModel { vec![] }; if videos.is_empty() { - return Err(AiMuxError::InvalidResponseData( + return Err(AiMuxError::invalid_response_data( "Replicate prediction succeeded without a usable output URL".to_string(), )); } diff --git a/aimux-providers/src/revai.rs b/aimux-providers/src/revai.rs index 25bd2b897..769750f65 100644 --- a/aimux-providers/src/revai.rs +++ b/aimux-providers/src/revai.rs @@ -20,6 +20,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::{Value, json}; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::error::ApiCallError; use aimux_core::shared::Warning; @@ -117,7 +118,7 @@ impl std::fmt::Debug for RevaiProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_revai(settings: RevaiProviderSettings) -> Result { @@ -221,8 +222,12 @@ fn audio_input_to_bytes(audio: &AudioInput) -> Result, AiMuxError> { match audio { AudioInput::Binary(bytes) => Ok(bytes.clone()), AudioInput::Base64(b64) => { - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid base64: {e}"))) + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err(|e| { + AiMuxError::InvalidArgument { + message: format!("invalid base64: {e}"), + cause: Some(ErrorCause::source(&e)), + } + }) } AudioInput::Url(_) => Err(AudioInput::url_error()), } @@ -296,7 +301,7 @@ impl TranscriptionModel for RevaiTranscriptionModel { } let job_id = submit_response.id.ok_or_else(|| { - AiMuxError::InvalidResponseData( + AiMuxError::invalid_response_data( "Rev.ai job submission did not return an id".to_string(), ) })?; diff --git a/aimux-providers/src/runtime_decision.rs b/aimux-providers/src/runtime_decision.rs index 120da2009..019b3970d 100644 --- a/aimux-providers/src/runtime_decision.rs +++ b/aimux-providers/src/runtime_decision.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use aimux_core::AiMuxError; +use aimux_core::ErrorCause; use aimux_core::decision_model::*; use async_trait::async_trait; use indexmap::IndexMap; @@ -90,8 +91,8 @@ impl RuntimeDecisionModel { ))); } if model_id.trim().is_empty() { - return Err(AiMuxError::InvalidArgument( - "decision model ID cannot be empty".into(), + return Err(AiMuxError::invalid_argument( + "decision model ID cannot be empty", )); } Ok(Self { @@ -115,7 +116,7 @@ impl RuntimeDecisionModel { ))); } if let Some(max) = limits.max_images.filter(|max| images.len() > *max) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "{provider} accepts at most {max} decision images" ))); } @@ -123,7 +124,7 @@ impl RuntimeDecisionModel { .max_questions .filter(|max| request.questions.len() > *max) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "{provider} accepts at most {max} decision questions" ))); } @@ -139,7 +140,7 @@ impl RuntimeDecisionModel { DecisionQuestion::Boolean { .. } => true, }; if !within { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "question {id:?} exceeds {provider} criteria limits" ))); } @@ -167,7 +168,7 @@ impl RuntimeDecisionModel { DecisionQuestion::Boolean { .. } => true, }; if !valid || (provider == "vllm" && request.questions.len() > 64) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "{provider} SystemOne question or candidate limit exceeded" ))); } @@ -180,9 +181,8 @@ impl RuntimeDecisionModel { }; let count: usize = request.questions.values().map(candidate_count).sum(); if state_len > 50_000 || count > 512 { - return Err(AiMuxError::InvalidArgument( - "Laya state exceeds 50,000 characters or questions exceed 512 total options" - .into(), + return Err(AiMuxError::invalid_argument( + "Laya state exceeds 50,000 characters or questions exceed 512 total options", )); } } @@ -214,20 +214,18 @@ impl RuntimeDecisionModel { if provider == "vllm" { if let Some(extensions) = options.remove("questions") { let extensions = extensions.as_object().ok_or_else(|| { - AiMuxError::InvalidArgument("vllm.questions must be an object".into()) + AiMuxError::invalid_argument("vllm.questions must be an object") })?; for (id, extension) in extensions { let target = body["questions"].get_mut(id).ok_or_else(|| { - AiMuxError::InvalidArgument(format!("unknown vllm question {id}")) + AiMuxError::invalid_argument(format!("unknown vllm question {id}")) })?; let extension = extension.as_object().ok_or_else(|| { - AiMuxError::InvalidArgument( - "vllm question extensions must be objects".into(), - ) + AiMuxError::invalid_argument("vllm question extensions must be objects") })?; for (key, value) in extension { if !matches!(key.as_str(), "depends_on" | "ask_if" | "alone") { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "unsupported vllm question option {key}" ))); } @@ -267,9 +265,8 @@ impl RuntimeDecisionModel { .bytes() .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) { - return Err(AiMuxError::InvalidArgument( - "Cloudflare question IDs require 1–100 ASCII letters, digits, _, . or -" - .into(), + return Err(AiMuxError::invalid_argument( + "Cloudflare question IDs require 1–100 ASCII letters, digits, _, . or -", )); } } @@ -289,7 +286,7 @@ impl RuntimeDecisionModel { _ => None, }; if body_limit.is_some_and(|limit| body.to_string().len() > limit) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "{provider} decision request exceeds body limit" ))); } @@ -313,7 +310,7 @@ impl RuntimeDecisionModel { let allowed = matches!(image.media_type.as_str(), "image/png" | "image/jpeg") || (provider != "localai" && image.media_type == "image/webp"); if !allowed || (provider != "localai" && inline.decoded_len > 4 * 1024 * 1024) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "{provider} unsupported image format or image exceeds 4 MiB" ))); } @@ -336,13 +333,13 @@ impl RuntimeDecisionModel { }); } if restricted && total > 8 * 1024 * 1024 { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "{provider} images exceed 8 MiB decoded" ))); } if provider == "localai" && encoded_total > 12 * 1024 * 1024 { - return Err(AiMuxError::InvalidArgument( - "LocalAI image URLs exceed 12 MiB".into(), + return Err(AiMuxError::invalid_argument( + "LocalAI image URLs exceed 12 MiB", )); } body["images"] = json!(wire); @@ -410,8 +407,10 @@ impl RuntimeDecisionModel { let latency = raw.get("latency_ms").filter(|v| v.is_number()).cloned(); let mut result = crate::systemone::convert_response( request, - serde_json::from_value(data) - .map_err(|e| invalid(format!("{provider} SystemOne: {e}")))?, + serde_json::from_value(data).map_err(|e| AiMuxError::InvalidResponseData { + message: format!("{provider} SystemOne: {e}"), + cause: Some(ErrorCause::source(&e)), + })?, raw, headers, crate::systemone::ResponseProfile { @@ -468,8 +467,8 @@ impl RuntimeDecisionModel { criteria, } => { if !(2..=26).contains(&criteria.len()) { - return Err(AiMuxError::InvalidArgument( - "SGLang decisions choices require 2–26 options".into(), + return Err(AiMuxError::invalid_argument( + "SGLang decisions choices require 2–26 options", )); } let options: Vec<_> = criteria @@ -489,8 +488,8 @@ impl RuntimeDecisionModel { criteria, } => { if criteria.len() > 10 { - return Err(AiMuxError::InvalidArgument( - "SGLang decisions scores support at most 10 levels".into(), + return Err(AiMuxError::invalid_argument( + "SGLang decisions scores support at most 10 levels", )); } json!({"id":id,"type":"score","question":instructions,"levels":criteria}) @@ -521,7 +520,7 @@ pub(crate) fn copy_options( ) -> Result<(), AiMuxError> { for (key, value) in options { if !allowed.contains(&key.as_str()) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "unsupported decision option {key}" ))); } diff --git a/aimux-providers/src/runwayml.rs b/aimux-providers/src/runwayml.rs index 06036f6db..c960a24e5 100644 --- a/aimux-providers/src/runwayml.rs +++ b/aimux-providers/src/runwayml.rs @@ -99,7 +99,7 @@ impl std::fmt::Debug for RunwaymlProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_runwayml(settings: RunwaymlProviderSettings) -> Result { @@ -298,7 +298,7 @@ impl VideoModel for RunwaymlVideoModel { .get("task_id") .and_then(Value::as_str) .ok_or_else(|| { - AiMuxError::InvalidArgument(format!( + AiMuxError::invalid_argument(format!( "{PROVIDER_NAME} operation reference is missing task_id" )) })?; @@ -325,7 +325,7 @@ impl VideoModel for RunwaymlVideoModel { .output .filter(|o| !o.is_empty()) .ok_or_else(|| { - AiMuxError::InvalidResponseData(format!( + AiMuxError::invalid_response_data(format!( "{PROVIDER_NAME} task {task_id} succeeded without output" )) })?; diff --git a/aimux-providers/src/searxng.rs b/aimux-providers/src/searxng.rs index a55eb2bc4..cb208a3d9 100644 --- a/aimux-providers/src/searxng.rs +++ b/aimux-providers/src/searxng.rs @@ -21,6 +21,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::Value; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, @@ -81,7 +82,7 @@ impl std::fmt::Debug for SearxngProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is given and is not +/// Returns `AiMuxError::invalid_argument` when `base_url` is given and is not /// an `http(s)` URL with a host. That is the only way this fails: a missing /// instance URL is reported by the first request. pub fn create_searxng(settings: SearxngProviderSettings) -> Result { @@ -209,8 +210,11 @@ impl SearchModel for SearxngSearchModel { async fn do_search(&self, options: &SearchCallOptions) -> Result { let exchange = self.config.exchange(options.headers.as_ref()).await?; - let mut url = url::Url::parse(&exchange.url("/search")) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid searxng endpoint: {e}")))?; + let mut url = + url::Url::parse(&exchange.url("/search")).map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid searxng endpoint: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; url.query_pairs_mut() .append_pair("q", &options.query) .append_pair("format", "json"); diff --git a/aimux-providers/src/serper.rs b/aimux-providers/src/serper.rs index bc617a791..2f02764b7 100644 --- a/aimux-providers/src/serper.rs +++ b/aimux-providers/src/serper.rs @@ -98,7 +98,7 @@ impl std::fmt::Debug for SerperProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_serper(settings: SerperProviderSettings) -> Result { diff --git a/aimux-providers/src/shared/mod.rs b/aimux-providers/src/shared/mod.rs index 15bc9edd9..3158f5cc9 100644 --- a/aimux-providers/src/shared/mod.rs +++ b/aimux-providers/src/shared/mod.rs @@ -63,8 +63,10 @@ pub(crate) fn base_url_setting( } Ok( match aimux_provider_utils::load_optional_setting(None, var) { - Some(url) => normalize(&url) - .map_err(|error| AiMuxError::InvalidArgument(format!("{var}: {error}"))), + Some(url) => normalize(&url).map_err(|error| AiMuxError::InvalidArgument { + message: format!("{var}: {error}"), + cause: Some(error.into()), + }), None => Ok(default.to_string()), }, ) diff --git a/aimux-providers/src/stability.rs b/aimux-providers/src/stability.rs index 3de3d8d1f..8f2a8636e 100644 --- a/aimux-providers/src/stability.rs +++ b/aimux-providers/src/stability.rs @@ -20,6 +20,7 @@ use async_trait::async_trait; use bytes::Bytes; use serde_json::Value; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::image_model::{ ImageCallOptions, ImageModel, ImageOutputs, ImageResponse, ImageResult, @@ -81,6 +82,7 @@ fn stability_successful_response_handler() -> aimux_provider_utils::ResponseHand status_code: Some(status), response_headers: Some(headers.clone()), response_body: Some(String::from_utf8_lossy(&bytes).into_owned()), + cause: Some(aimux_core::ErrorCause::source(&error)), ..aimux_core::ApiCallError::new( format!("Invalid JSON response: {error}"), input.url, @@ -167,7 +169,7 @@ impl std::fmt::Debug for StabilityProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_stability( @@ -373,12 +375,13 @@ impl ImageModel for StabilityImageModel { let image_bytes = match resp.value { StabilitySuccess::Json(v) => { let b64 = v.get("image").and_then(|i| i.as_str()).ok_or_else(|| { - AiMuxError::InvalidResponseData( - "Stability response missing `image` field".into(), - ) + AiMuxError::invalid_response_data("Stability response missing `image` field") })?; base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err( - |e| AiMuxError::InvalidResponseData(format!("invalid base64 image: {e}")), + |e| AiMuxError::InvalidResponseData { + message: format!("invalid base64 image: {e}"), + cause: Some(ErrorCause::source(&e)), + }, )? } StabilitySuccess::Binary(bytes) => bytes.to_vec(), diff --git a/aimux-providers/src/stream/chat_tool_calls.rs b/aimux-providers/src/stream/chat_tool_calls.rs index 587be6a5c..d6792e353 100644 --- a/aimux-providers/src/stream/chat_tool_calls.rs +++ b/aimux-providers/src/stream/chat_tool_calls.rs @@ -348,7 +348,7 @@ impl ChatToolCalls { /// End the stream with the upstream `InvalidResponseDataError`. fn invalid(controller: &mut Controller, message: &str) -> bool { - controller.error(AiMuxError::InvalidResponseData(message.to_string())); + controller.error(AiMuxError::invalid_response_data(message.to_string())); false } diff --git a/aimux-providers/src/stream/chat_tool_calls_tests.rs b/aimux-providers/src/stream/chat_tool_calls_tests.rs index fb6e41919..847f19b83 100644 --- a/aimux-providers/src/stream/chat_tool_calls_tests.rs +++ b/aimux-providers/src/stream/chat_tool_calls_tests.rs @@ -208,7 +208,7 @@ impl Harness { for item in controller.drain() { match item { Ok(part) => self.parts.extend(project(part)), - Err(AiMuxError::InvalidResponseData(message)) => result = Err(message), + Err(AiMuxError::InvalidResponseData { message, .. }) => result = Err(message), Err(error) => panic!("unexpected stream error: {error:?}"), } } diff --git a/aimux-providers/src/tavily.rs b/aimux-providers/src/tavily.rs index 1e4fbac95..8bbdc9fd7 100644 --- a/aimux-providers/src/tavily.rs +++ b/aimux-providers/src/tavily.rs @@ -90,7 +90,7 @@ impl std::fmt::Debug for TavilyProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_tavily(settings: TavilyProviderSettings) -> Result { diff --git a/aimux-providers/src/tinyfish.rs b/aimux-providers/src/tinyfish.rs index 2945c5de0..17e0a6612 100644 --- a/aimux-providers/src/tinyfish.rs +++ b/aimux-providers/src/tinyfish.rs @@ -18,6 +18,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::Value; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, @@ -107,7 +108,7 @@ impl std::fmt::Debug for TinyfishProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_tinyfish(settings: TinyfishProviderSettings) -> Result { @@ -242,8 +243,11 @@ impl SearchModel for TinyfishSearchModel { let count = resolve_count(options.max_results); let exchange = self.config.exchange(options.headers.as_ref()).await?; - let mut url = url::Url::parse(exchange.base_url()) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid tinyfish endpoint: {e}")))?; + let mut url = + url::Url::parse(exchange.base_url()).map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid tinyfish endpoint: {e}"), + cause: Some(ErrorCause::source(&e)), + })?; url.query_pairs_mut() .append_pair("query", &options.query) .append_pair("count", &count.to_string()); diff --git a/aimux-providers/src/vertex/auth.rs b/aimux-providers/src/vertex/auth.rs index 787c638a6..f18d18ec4 100644 --- a/aimux-providers/src/vertex/auth.rs +++ b/aimux-providers/src/vertex/auth.rs @@ -1,8 +1,10 @@ //! Application default Google credentials and OAuth access-token generation. +use aimux_core::SourceError; use std::path::PathBuf; use std::time::{Duration, Instant}; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_provider_utils::{ FetchFunction, HttpBody, HttpRequest, Resolvable, create_binary_response_handler, @@ -157,14 +159,16 @@ impl GoogleAuth { let Some(path) = path else { return Ok(None); }; - let data = std::fs::read(path).map_err(|error| { - AiMuxError::InvalidArgument(format!( - "Could not read Google application credentials: {error}" - )) + let data = std::fs::read(path).map_err(|error| AiMuxError::InvalidArgument { + message: format!("Could not read Google application credentials: {error}"), + cause: Some(ErrorCause::source(&error)), })?; - serde_json::from_slice(&data).map(Some).map_err(|error| { - AiMuxError::InvalidArgument(format!("Invalid Google application credentials: {error}")) - }) + serde_json::from_slice(&data) + .map(Some) + .map_err(|error| AiMuxError::InvalidArgument { + message: format!("Invalid Google application credentials: {error}"), + cause: Some(ErrorCause::source(&error)), + }) } pub(super) async fn access_token(&self) -> Result { @@ -221,8 +225,8 @@ impl GoogleAuth { Instant::now() .checked_add(Duration::from_millis(remaining as u64)) .ok_or_else(|| { - AiMuxError::InvalidResponseData( - "Google OAuth token expiry is out of range".into(), + AiMuxError::invalid_response_data( + "Google OAuth token expiry is out of range", ) }) }) @@ -256,8 +260,8 @@ impl GoogleAuth { ) }); if !external && response.access_token.is_empty() { - return Err(AiMuxError::InvalidResponseData( - "Could not refresh Google access token".into(), + return Err(AiMuxError::invalid_response_data( + "Could not refresh Google access token", )); } let threshold = self @@ -277,8 +281,8 @@ impl GoogleAuth { .saturating_sub(Duration::from_millis(threshold)), ) .ok_or_else(|| { - AiMuxError::InvalidResponseData( - "Google OAuth token expiry is out of range".into(), + AiMuxError::invalid_response_data( + "Google OAuth token expiry is out of range", ) }) }) @@ -350,17 +354,15 @@ impl GoogleAuth { .filter(|line| !line.starts_with("-----")) .collect::(), ) - .map_err(|_| { - AiMuxError::InvalidArgument( - "Invalid Google service-account private key encoding".into(), - ) + .map_err(|error| AiMuxError::InvalidArgument { + message: "Invalid Google service-account private key encoding".into(), + cause: Some(ErrorCause::source(&error)), })?; let key_pair = RsaKeyPair::from_pkcs8(&der) .or_else(|_| RsaKeyPair::from_der(&der)) - .map_err(|_| { - AiMuxError::InvalidArgument( - "Invalid Google service-account RSA private key".into(), - ) + .map_err(|error| AiMuxError::InvalidArgument { + message: "Invalid Google service-account RSA private key".into(), + cause: Some(SourceError::from(error.to_string()).into()), })?; let mut signature = vec![0; key_pair.public().modulus_len()]; key_pair @@ -370,10 +372,9 @@ impl GoogleAuth { unsigned.as_bytes(), &mut signature, ) - .map_err(|_| { - AiMuxError::InvalidArgument( - "Could not sign Google service-account assertion".into(), - ) + .map_err(|error| AiMuxError::InvalidArgument { + message: "Could not sign Google service-account assertion".into(), + cause: Some(SourceError::from(error.to_string()).into()), })?; let assertion = format!("{unsigned}.{}", encoding.encode(signature)); self.exchange( @@ -423,8 +424,8 @@ impl GoogleAuth { == Some("impersonated_service_account") => { let source = credentials.get("source_credentials").ok_or_else(|| { - AiMuxError::InvalidArgument( - "Google impersonation credentials require source_credentials".into(), + AiMuxError::invalid_argument( + "Google impersonation credentials require source_credentials", ) })?; let token = self.credential_token(Some(source.clone())).await?; @@ -465,11 +466,11 @@ impl GoogleAuth { } else { format!("http://{host}") }; - let mut url = url::Url::parse(&host).map_err(|error| { - AiMuxError::InvalidArgument(format!( - "Invalid Google metadata host: {error}" - )) - })?; + let mut url = + url::Url::parse(&host).map_err(|error| AiMuxError::InvalidArgument { + message: format!("Invalid Google metadata host: {error}"), + cause: Some(ErrorCause::source(&error)), + })?; url.set_path("/computeMetadata/v1/instance/service-accounts/default/token"); url.set_query(None); let scopes = self @@ -496,8 +497,8 @@ impl GoogleAuth { if !response.response_headers.iter().any(|(name, value)| { name.eq_ignore_ascii_case("Metadata-Flavor") && value == "Google" }) { - return Err(AiMuxError::InvalidResponseData( - "Invalid Google metadata response Metadata-Flavor header".into(), + return Err(AiMuxError::invalid_response_data( + "Invalid Google metadata response Metadata-Flavor header", )); } response.value @@ -508,12 +509,12 @@ impl GoogleAuth { async fn gdch_token(&self, credentials: &Value) -> Result { if required(credentials, "format_version")? != "1" { - return Err(AiMuxError::InvalidArgument( - "Google GDCH credentials require format_version 1".into(), + return Err(AiMuxError::invalid_argument( + "Google GDCH credentials require format_version 1", )); } let client = self.options.client_options.as_ref(); - let audience = client.and_then(|options| options.get("apiAudience")).and_then(Value::as_str).filter(|value| !value.is_empty()).ok_or_else(|| AiMuxError::InvalidArgument("Audience cannot be null or empty for GDCH service account credentials; provide client_options.apiAudience".into()))?; + let audience = client.and_then(|options| options.get("apiAudience")).and_then(Value::as_str).filter(|value| !value.is_empty()).ok_or_else(|| AiMuxError::invalid_argument("Audience cannot be null or empty for GDCH service account credentials; provide client_options.apiAudience"))?; let fetch = match ( self.fetch.clone(), credentials @@ -536,9 +537,9 @@ impl GoogleAuth { .and_then(Value::as_i64) .filter(|value| *value != 0) .unwrap_or(3600); - let expiry = now.checked_add(lifetime).ok_or_else(|| { - AiMuxError::InvalidArgument("Invalid Google GDCH token lifetime".into()) - })?; + let expiry = now + .checked_add(lifetime) + .ok_or_else(|| AiMuxError::invalid_argument("Invalid Google GDCH token lifetime"))?; let header = json!({"alg": "ES256", "typ": "JWT", "kid": required(credentials, "private_key_id")?}); let claims = @@ -556,8 +557,9 @@ impl GoogleAuth { .filter(|line| !line.starts_with("-----")) .collect::(), ) - .map_err(|_| { - AiMuxError::InvalidArgument("Invalid Google GDCH private key encoding".into()) + .map_err(|error| AiMuxError::InvalidArgument { + message: "Invalid Google GDCH private key encoding".into(), + cause: Some(ErrorCause::source(&error)), })?; if key.contains("BEGIN EC PRIVATE KEY") { let mut wrapped = vec![ @@ -573,17 +575,23 @@ impl GoogleAuth { &der, &random, ) - .map_err(|_| AiMuxError::InvalidArgument("Invalid Google GDCH P-256 private key".into()))?; - let signature = pair.sign(&random, unsigned.as_bytes()).map_err(|_| { - AiMuxError::InvalidArgument("Could not sign Google GDCH assertion".into()) + .map_err(|error| AiMuxError::InvalidArgument { + message: "Invalid Google GDCH P-256 private key".into(), + cause: Some(SourceError::from(error.to_string()).into()), + })?; + let signature = pair.sign(&random, unsigned.as_bytes()).map_err(|error| { + AiMuxError::InvalidArgument { + message: "Could not sign Google GDCH assertion".into(), + cause: Some(SourceError::from(error.to_string()).into()), + } })?; let assertion = format!("{unsigned}.{}", encoding.encode(signature.as_ref())); let response = post_to_api(HttpRequest { url: token_url.into(), response_timeout: Some(Duration::from_secs(10)), fetch, ..Default::default() }, HttpBody::Json(json!({"audience": audience, "grant_type": "urn:ietf:params:oauth:token-type:token-exchange", "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", "subject_token": assertion, "subject_token_type": "urn:k8s:params:oauth:token-type:serviceaccount"})), create_json_response_handler::(), crate::google::google_failed_response_handler()).await?.value; if response.access_token.is_empty() || response.expires_in.is_none_or(|seconds| seconds == 0) { - return Err(AiMuxError::InvalidResponseData( - "Google GDCH token response requires access_token and expires_in".into(), + return Err(AiMuxError::invalid_response_data( + "Google GDCH token response requires access_token and expires_in", )); } Ok(response) @@ -591,9 +599,7 @@ impl GoogleAuth { async fn external_token(&self, credentials: &Value) -> Result { let source = credentials.get("credential_source").ok_or_else(|| { - AiMuxError::InvalidArgument( - "Google external credentials require credential_source".into(), - ) + AiMuxError::invalid_argument("Google external credentials require credential_source") })?; let file = source .get("file") @@ -608,9 +614,8 @@ impl GoogleAuth { || (url.is_some() && certificate.is_some()) || (file.is_some() && certificate.is_some()) { - return Err(AiMuxError::InvalidArgument( - "Google identity pool credentials require exactly one of file, URL or certificate" - .into(), + return Err(AiMuxError::invalid_argument( + "Google identity pool credentials require exactly one of file, URL or certificate", )); } let parse_format = source.get("environment_id").is_none() @@ -627,8 +632,9 @@ impl GoogleAuth { } else if source.get("executable").is_some() { super::auth_external::executable(credentials).await? } else if let Some(file) = file { - std::fs::read_to_string(file).map_err(|error| { - AiMuxError::InvalidArgument(format!("Could not read Google subject token: {error}")) + std::fs::read_to_string(file).map_err(|error| AiMuxError::InvalidArgument { + message: format!("Could not read Google subject token: {error}"), + cause: Some(ErrorCause::source(&error)), })? } else if let Some(url) = url { let headers = source @@ -642,8 +648,8 @@ impl GoogleAuth { .as_str() .map(|value| (name.clone(), value.to_string())) .ok_or_else(|| { - AiMuxError::InvalidArgument( - "Google credential_source headers must be strings".into(), + AiMuxError::invalid_argument( + "Google credential_source headers must be strings", ) }) }) @@ -663,8 +669,9 @@ impl GoogleAuth { ) .await? .value; - String::from_utf8(bytes.to_vec()).map_err(|_| { - AiMuxError::InvalidResponseData("Google subject token is not UTF-8".into()) + String::from_utf8(bytes.to_vec()).map_err(|error| AiMuxError::InvalidResponseData { + message: "Google subject token is not UTF-8".into(), + cause: Some(ErrorCause::source(&error)), })? } else { return Err(AiMuxError::UnsupportedFunctionality( @@ -685,9 +692,10 @@ impl GoogleAuth { "text" => token, "json" => { let value: Value = serde_json::from_str(&token).map_err(|error| { - AiMuxError::InvalidResponseData(format!( - "Invalid Google subject-token JSON: {error}" - )) + AiMuxError::InvalidResponseData { + message: format!("Invalid Google subject-token JSON: {error}"), + cause: Some(ErrorCause::source(&error)), + } })?; required( &value, @@ -703,8 +711,8 @@ impl GoogleAuth { } }; if token.is_empty() { - return Err(AiMuxError::InvalidResponseData( - "Unable to parse Google subject token".into(), + return Err(AiMuxError::invalid_response_data( + "Unable to parse Google subject token", )); } let token_auth = GoogleAuth::new(self.options.clone(), fetch); @@ -752,9 +760,8 @@ impl GoogleAuth { .expect("static workforce audience pattern") .is_match(audience) { - return Err(AiMuxError::InvalidArgument( - "workforce_pool_user_project must not be set for non-workforce credentials" - .into(), + return Err(AiMuxError::invalid_argument( + "workforce_pool_user_project must not be set for non-workforce credentials", )); } if credentials @@ -821,8 +828,8 @@ impl GoogleAuth { == Some("impersonated_service_account") { if url.len() > 256 { - return Err(AiMuxError::InvalidArgument( - "Google impersonation target principal is too long".into(), + return Err(AiMuxError::invalid_argument( + "Google impersonation target principal is too long", )); } let target = url @@ -835,9 +842,7 @@ impl GoogleAuth { }) .filter(|target| !target.is_empty()) .ok_or_else(|| { - AiMuxError::InvalidArgument( - "Invalid Google impersonation target principal".into(), - ) + AiMuxError::invalid_argument("Invalid Google impersonation target principal") })?; let endpoint = credentials .get("endpoint") @@ -868,9 +873,10 @@ impl GoogleAuth { .value; let expiry = chrono::DateTime::parse_from_rfc3339(&response.expire_time).map_err(|error| { - AiMuxError::InvalidResponseData(format!( - "Invalid Google impersonation expiry: {error}" - )) + AiMuxError::InvalidResponseData { + message: format!("Invalid Google impersonation expiry: {error}"), + cause: Some(ErrorCause::source(&error)), + } })?; Ok(TokenResponse { access_token: response.access_token, @@ -902,8 +908,8 @@ impl GoogleAuth { let client = self.options.client_options.as_ref(); if let Some(options) = client { let Some(options) = options.as_object() else { - return Err(AiMuxError::InvalidArgument( - "Google auth client_options must be an object".into(), + return Err(AiMuxError::invalid_argument( + "Google auth client_options must be an object", )); }; for (name, value) in options { @@ -911,7 +917,7 @@ impl GoogleAuth { "projectId" | "project_id" | "quotaProjectId" | "quota_project_id" | "universeDomain" | "universe_domain" | "apiKey" => { if !value.is_string() && !value.is_null() { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Google auth client option {name} must be a string" ))); } @@ -946,7 +952,9 @@ impl GoogleAuth { .and_then(|options| options.get("credentials")) .is_some_and(|value| !value.is_null()) { - AiMuxError::InvalidArgument("API Keys and Credentials are mutually exclusive authentication methods and cannot be used together.".into()) + AiMuxError::invalid_argument( + "API Keys and Credentials are mutually exclusive authentication methods and cannot be used together.", + ) } else { AiMuxError::UnsupportedFunctionality("Google auth API-key clients cannot generate OAuth access tokens; use the Vertex provider api_key setting".into()) }, @@ -997,7 +1005,7 @@ pub(super) fn required<'a>(credentials: &'a Value, name: &str) -> Result<&'a str .and_then(Value::as_str) .filter(|value| !value.is_empty()) .ok_or_else(|| { - AiMuxError::InvalidArgument(format!("Google application credentials require {name}")) + AiMuxError::invalid_argument(format!("Google application credentials require {name}")) }) } diff --git a/aimux-providers/src/vertex/auth_certificate.rs b/aimux-providers/src/vertex/auth_certificate.rs index 6c6368ee0..8c477fa3a 100644 --- a/aimux-providers/src/vertex/auth_certificate.rs +++ b/aimux-providers/src/vertex/auth_certificate.rs @@ -1,21 +1,32 @@ //! Certificate subject tokens and their native TLS transport. +use aimux_core::SourceError; use std::path::PathBuf; use std::sync::Arc; use aimux_core::AiMuxError; +use aimux_core::ErrorCause; use aimux_provider_utils::{Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse}; use base64::Engine; use futures::StreamExt; use serde_json::Value; -fn invalid(message: impl std::fmt::Display) -> AiMuxError { - AiMuxError::InvalidArgument(message.to_string()) +fn invalid(message: impl Into) -> AiMuxError { + AiMuxError::invalid_argument(message) +} + +fn invalid_from(error: impl std::error::Error + 'static) -> AiMuxError { + AiMuxError::InvalidArgument { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + } } fn read(path: &str) -> Result, AiMuxError> { - std::fs::read(path) - .map_err(|error| invalid(format!("Cannot read certificate source {path}: {error}"))) + std::fs::read(path).map_err(|error| AiMuxError::InvalidArgument { + message: format!("Cannot read certificate source {path}: {error}"), + cause: Some(ErrorCause::source(&error)), + }) } fn nonempty(value: Option<&str>) -> Option<&str> { @@ -52,7 +63,8 @@ pub(super) fn certificate( }); directory.join("certificate_config.json") }); - let config: Value = serde_json::from_slice(&read(&path.to_string_lossy())?).map_err(invalid)?; + let config: Value = + serde_json::from_slice(&read(&path.to_string_lossy())?).map_err(invalid_from)?; let workload = &config["cert_configs"]["workload"]; let cert_path = nonempty(workload["cert_path"].as_str()) .ok_or_else(|| invalid("Missing workload cert_path"))?; @@ -73,7 +85,7 @@ pub(super) fn certificate( }; identity_pem.push(b'\n'); identity_pem.extend(key); - let identity = reqwest::Identity::from_pem(&identity_pem).map_err(invalid)?; + let identity = reqwest::Identity::from_pem(&identity_pem).map_err(invalid_from)?; let mut chain = match nonempty(options["trust_chain_path"].as_str()) { Some(path) => pem_certificates(&read(path)?)?, None => Vec::new(), @@ -90,22 +102,22 @@ pub(super) fn certificate( // Root-store parsing validates DER without changing the mTLS server trust. let mut validation = reqwest::Client::builder(); for der in &chain { - validation = - validation.add_root_certificate(reqwest::Certificate::from_der(der).map_err(invalid)?); + validation = validation + .add_root_certificate(reqwest::Certificate::from_der(der).map_err(invalid_from)?); } - validation.build().map_err(invalid)?; + validation.build().map_err(invalid_from)?; let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .identity(identity) .build() - .map_err(invalid)?; + .map_err(invalid_from)?; let subject_token = serde_json::to_string( &chain .iter() .map(|der| base64::engine::general_purpose::STANDARD.encode(der)) .collect::>(), ) - .map_err(invalid)?; + .map_err(invalid_from)?; Ok(( subject_token, Some(fetch.unwrap_or_else(|| Arc::new(CertificateFetch(client)))), @@ -115,7 +127,7 @@ pub(super) fn certificate( fn pem_certificates(bytes: &[u8]) -> Result>, AiMuxError> { let text = String::from_utf8_lossy(bytes); let pattern = regex::Regex::new(r"-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----") - .map_err(invalid)?; + .map_err(invalid_from)?; pattern .find_iter(&text) .map(|block| { @@ -128,13 +140,13 @@ fn pem_certificates(bytes: &[u8]) -> Result>, AiMuxError> { .collect(); base64::engine::general_purpose::STANDARD .decode(encoded) - .map_err(invalid) + .map_err(invalid_from) }) .collect() } pub(super) fn ca_fetch(path: &str) -> Result { - let certificates = reqwest::Certificate::from_pem_bundle(&read(path)?).map_err(invalid)?; + let certificates = reqwest::Certificate::from_pem_bundle(&read(path)?).map_err(invalid_from)?; let mut builder = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .tls_built_in_root_certs(false); @@ -142,7 +154,7 @@ pub(super) fn ca_fetch(path: &str) -> Result { builder = builder.add_root_certificate(certificate); } Ok(Arc::new(CertificateFetch( - builder.build().map_err(invalid)?, + builder.build().map_err(invalid_from)?, ))) } @@ -181,14 +193,17 @@ impl Fetch for CertificateFetch { } } +/// The request URL is reported separately, sanitized, so it is dropped from +/// reqwest's own text. fn fetch_error(error: reqwest::Error) -> FetchError { + let error = error.without_url(); if error.is_builder() { - FetchError::Other(error.to_string()) + FetchError::Other(SourceError::new(&error)) } else if error.is_timeout() { FetchError::Timeout } else if error.is_connect() { - FetchError::Connect(error.to_string()) + FetchError::Connect(SourceError::new(&error)) } else { - FetchError::Io(error.to_string()) + FetchError::Io(SourceError::new(&error)) } } diff --git a/aimux-providers/src/vertex/auth_external.rs b/aimux-providers/src/vertex/auth_external.rs index 26f533e73..d23de0ef1 100644 --- a/aimux-providers/src/vertex/auth_external.rs +++ b/aimux-providers/src/vertex/auth_external.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use aimux_core::AiMuxError; +use aimux_core::ErrorCause; use aimux_provider_utils::{FetchFunction, FetchRequest, default_fetch}; use futures::TryStreamExt; use serde_json::{Value, json}; @@ -17,18 +18,26 @@ pub(super) async fn text( ) -> Result { let mut request = FetchRequest::new( method, - url.parse().map_err(|error| { - AiMuxError::InvalidArgument(format!("Invalid Google credential URL: {error}")) + url.parse().map_err(|error| AiMuxError::InvalidArgument { + message: format!("Invalid Google credential URL: {error}"), + cause: Some(ErrorCause::source(&error)), })?, ); for (name, value) in headers { request.headers.insert( - reqwest::header::HeaderName::from_bytes(name.as_bytes()) - .map_err(|error| AiMuxError::InvalidArgument(error.to_string()))?, + reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|error| { + AiMuxError::InvalidArgument { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + } + })?, value .parse() .map_err(|error: reqwest::header::InvalidHeaderValue| { - AiMuxError::InvalidArgument(error.to_string()) + AiMuxError::InvalidArgument { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + } })?, ); } @@ -36,13 +45,12 @@ pub(super) async fn text( .unwrap_or_else(default_fetch) .fetch(request) .await - .map_err(|error| { - AiMuxError::InvalidResponseData(format!( - "Google credential source request failed: {error}" - )) + .map_err(|error| AiMuxError::InvalidResponseData { + message: format!("Google credential source request failed: {error}"), + cause: Some(ErrorCause::source(&error)), })?; if !response.status.is_success() { - return Err(AiMuxError::InvalidResponseData(format!( + return Err(AiMuxError::invalid_response_data(format!( "Google credential source returned HTTP {}", response.status ))); @@ -54,8 +62,14 @@ pub(super) async fn text( Ok(bytes) }) .await - .map_err(|error| AiMuxError::InvalidResponseData(error.to_string()))?; - String::from_utf8(bytes).map_err(|error| AiMuxError::InvalidResponseData(error.to_string())) + .map_err(|error| AiMuxError::InvalidResponseData { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + })?; + String::from_utf8(bytes).map_err(|error| AiMuxError::InvalidResponseData { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + }) } pub(super) async fn aws( @@ -64,8 +78,8 @@ pub(super) async fn aws( ) -> Result { let source = &credentials["credential_source"]; if required(source, "environment_id")? != "aws1" { - return Err(AiMuxError::InvalidArgument( - "Unsupported Google external AWS environment version".into(), + return Err(AiMuxError::invalid_argument( + "Unsupported Google external AWS environment version", )); } let region = std::env::var("AWS_REGION") @@ -125,7 +139,10 @@ pub(super) async fn aws( ) .await?, ) - .map_err(|error| AiMuxError::InvalidResponseData(error.to_string()))?; + .map_err(|error| AiMuxError::InvalidResponseData { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + })?; ( required(&value, "AccessKeyId")?.into(), required(&value, "SecretAccessKey")?.into(), @@ -137,10 +154,12 @@ pub(super) async fn aws( } }; let url = required(source, "regional_cred_verification_url")?.replace("{region}", ®ion); - let parsed = - url::Url::parse(&url).map_err(|error| AiMuxError::InvalidArgument(error.to_string()))?; + let parsed = url::Url::parse(&url).map_err(|error| AiMuxError::InvalidArgument { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + })?; let host = parsed.host_str().ok_or_else(|| { - AiMuxError::InvalidArgument("AWS credential verification URL has no host".into()) + AiMuxError::invalid_argument("AWS credential verification URL has no host") })?; let host = parsed .port() @@ -216,7 +235,9 @@ fn hex(value: &[u8]) -> String { pub(super) async fn executable(credentials: &Value) -> Result { if std::env::var("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES").as_deref() != Ok("1") { - return Err(AiMuxError::InvalidArgument("Google external account executables require GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1".into())); + return Err(AiMuxError::invalid_argument( + "Google external account executables require GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1", + )); } let options = &credentials["credential_source"]["executable"]; let output_file = options @@ -227,9 +248,11 @@ pub(super) async fn executable(credentials: &Value) -> Result Result Result>(); let program = components.first().ok_or_else(|| { - AiMuxError::InvalidArgument("Google credential executable command is empty".into()) + AiMuxError::invalid_argument("Google credential executable command is empty") })?; let mut command = tokio::process::Command::new(program); command @@ -292,23 +314,27 @@ pub(super) async fn executable(credentials: &Value) -> Result Result 1) { - return Err(AiMuxError::InvalidResponseData( - "Google credential executable response requires version 1".into(), + return Err(AiMuxError::invalid_response_data( + "Google credential executable response requires version 1", )); } if response.get("success").and_then(Value::as_bool) != Some(true) { - return Err(AiMuxError::InvalidResponseData(format!( + return Err(AiMuxError::invalid_response_data(format!( "Google credential executable returned {}: {}", response["code"], response["message"] ))); @@ -334,8 +360,8 @@ fn executable_response(response: &Value, output_file: bool) -> Result Result { - return Err(AiMuxError::InvalidResponseData( - "Unsupported Google credential executable token_type".into(), + return Err(AiMuxError::invalid_response_data( + "Unsupported Google credential executable token_type", )); } }; @@ -359,8 +385,8 @@ fn validate_executable_response(response: &Value) -> Result<(), AiMuxError> { .is_none_or(|version| version == 0) || response.get("success").and_then(Value::as_bool).is_none() { - return Err(AiMuxError::InvalidResponseData( - "Google credential executable response requires version and success".into(), + return Err(AiMuxError::invalid_response_data( + "Google credential executable response requires version and success", )); } if response.get("success").and_then(Value::as_bool) == Some(false) { @@ -372,8 +398,8 @@ fn validate_executable_response(response: &Value) -> Result<(), AiMuxError> { "urn:ietf:params:oauth:token-type:id_token" | "urn:ietf:params:oauth:token-type:jwt" => "id_token", _ => { - return Err(AiMuxError::InvalidResponseData( - "Unsupported Google credential executable token_type".into(), + return Err(AiMuxError::invalid_response_data( + "Unsupported Google credential executable token_type", )); } }; diff --git a/aimux-providers/src/vertex/embedding.rs b/aimux-providers/src/vertex/embedding.rs index 697d92c81..a9c342b54 100644 --- a/aimux-providers/src/vertex/embedding.rs +++ b/aimux-providers/src/vertex/embedding.rs @@ -72,7 +72,7 @@ impl EmbeddingModel for VertexEmbeddingModel { let vertex_options = parse_vertex_provider_options(options.provider_options.as_ref())?; let max = self.max_embeddings_per_call().unwrap_or(250); if options.values.len() > max as usize { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Too many embedding values for {} model {}: maximum {max}, received {}", self.provider(), self.model_id, @@ -301,7 +301,7 @@ fn parse_vertex_provider_options( ), ] { if !valid { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Invalid Vertex embedding provider option: {name}" ))); } diff --git a/aimux-providers/src/vertex/gemini_transcription.rs b/aimux-providers/src/vertex/gemini_transcription.rs index d5364c634..839e0ed97 100644 --- a/aimux-providers/src/vertex/gemini_transcription.rs +++ b/aimux-providers/src/vertex/gemini_transcription.rs @@ -95,7 +95,7 @@ impl TranscriptionModel for VertexGeminiTranscriptionModel { options: &TranscriptionCallOptions, ) -> Result { if self.model_id.contains("-live") { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Model '{}' only supports streaming transcription. Use stream_transcribe or a unary model.", self.model_id ))); @@ -196,7 +196,7 @@ impl TranscriptionModel for VertexGeminiTranscriptionModel { ) -> Option>> { Some(Box::pin(async move { if !self.model_id.contains("-live") { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Model '{}' does not support streaming transcription. Use a live model.", self.model_id ))); diff --git a/aimux-providers/src/vertex/image.rs b/aimux-providers/src/vertex/image.rs index 73260fe4b..c27ae9688 100644 --- a/aimux-providers/src/vertex/image.rs +++ b/aimux-providers/src/vertex/image.rs @@ -15,6 +15,7 @@ use crate::shared::EndpointConfig; use aimux_core::language_model_message::{FilePart, LanguageModelMessage, TextPart, UserPart}; use aimux_core::options::CallOptions; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, ImageResponse, @@ -83,8 +84,9 @@ impl VertexImageModel { let file = FilePart { data: FileData::Url { url: url::Url::parse(url) - .map_err(|error| { - AiMuxError::InvalidArgument(error.to_string()) + .map_err(|error| AiMuxError::InvalidArgument { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), })? .to_string(), original_url: None, diff --git a/aimux-providers/src/vertex/mod.rs b/aimux-providers/src/vertex/mod.rs index 494c83ee3..c9ffd848a 100644 --- a/aimux-providers/src/vertex/mod.rs +++ b/aimux-providers/src/vertex/mod.rs @@ -169,7 +169,7 @@ impl ProjectLocationSource { /// The project and location of one request. pub(crate) async fn resolve(&self) -> Result { if self.resolver.express_key().await?.is_some() { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Google Vertex {} models do not support Express Mode API keys. \ Use standard Google Cloud credentials instead.", self.what @@ -196,7 +196,7 @@ impl ResolveEndpoint for GeminiEndpoint { /// The base URL and provider headers of one request. async fn resolve(&self) -> Result { if self.tuned && self.resolver.express_key().await?.is_some() { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "Google Vertex tuned models do not support Express Mode API keys. \ Use standard Google Cloud credentials instead." .to_string(), @@ -447,7 +447,7 @@ impl VertexProvider { /// A Gemini model (e.g. `"gemini-2.0-flash"`, or a tuned /// `"endpoints/{id}"`); `provider()` is `"google.vertex.chat"`. /// - /// A tuned model fails its requests with `AiMuxError::InvalidArgument` in + /// A tuned model fails its requests with `AiMuxError::invalid_argument` in /// Express mode. #[must_use] pub fn chat(&self, model_id: &str) -> VertexModel { diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index 3a13cff54..1f782ccd0 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -11,6 +11,7 @@ use aimux_core::tool::ToolResult; use async_trait::async_trait; use serde_json::{Value, json}; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; @@ -114,8 +115,13 @@ impl LanguageModel for VertexModel { { parsed_response["candidates"] = json!([]); } - let data: GenerateContentResponse = serde_json::from_value(parsed_response) - .map_err(|error| AiMuxError::InvalidResponseData(error.to_string()))?; + let data: GenerateContentResponse = + serde_json::from_value(parsed_response).map_err(|error| { + AiMuxError::InvalidResponseData { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + } + })?; let candidate = data.candidates.into_iter().next().unwrap_or(Candidate { content: None, @@ -691,8 +697,12 @@ impl Transformer for VertexModelStream { self.final_usage = vertex_usage(Some(usage)); self.last_usage_metadata_value = Some(usage.clone()); } - serde_json::from_value::(raw) - .map_err(|error| AiMuxError::InvalidResponseData(error.to_string())) + serde_json::from_value::(raw).map_err(|error| { + AiMuxError::InvalidResponseData { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + } + }) } Err(error) => Err(error), }; diff --git a/aimux-providers/src/vertex/partner_provider.rs b/aimux-providers/src/vertex/partner_provider.rs index e9973c90c..91e54a6be 100644 --- a/aimux-providers/src/vertex/partner_provider.rs +++ b/aimux-providers/src/vertex/partner_provider.rs @@ -205,7 +205,7 @@ fn maas_request_body(mut body: Value) -> Value { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` for an explicit `base_url` that is +/// Returns `AiMuxError::invalid_argument` for an explicit `base_url` that is /// not an `http(s)` URL with a host. The project, location and token are /// resolved when a request is made. pub fn create_google_vertex_maas( @@ -288,7 +288,7 @@ fn xai_request_body(mut body: Value) -> Value { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` for an explicit `base_url` that is +/// Returns `AiMuxError::invalid_argument` for an explicit `base_url` that is /// not an `http(s)` URL with a host. The project, location and token are /// resolved when a request is made. pub fn create_google_vertex_xai( diff --git a/aimux-providers/src/vertex/video.rs b/aimux-providers/src/vertex/video.rs index 244c6e71e..198a4ce2b 100644 --- a/aimux-providers/src/vertex/video.rs +++ b/aimux-providers/src/vertex/video.rs @@ -197,7 +197,7 @@ impl VideoModel for VertexVideoModel { .and_then(|v| v.as_str()) .filter(|name| !name.is_empty()) .ok_or_else(|| { - AiMuxError::InvalidResponseData("No operation name returned from API".to_string()) + AiMuxError::invalid_response_data("No operation name returned from API") })? .to_string(); @@ -222,7 +222,7 @@ impl VideoModel for VertexVideoModel { .get("operationName") .and_then(Value::as_str) .ok_or_else(|| { - AiMuxError::InvalidArgument( + AiMuxError::invalid_argument( "google.vertex operation reference is missing operationName".to_string(), ) })?; @@ -264,7 +264,7 @@ impl VideoModel for VertexVideoModel { .and_then(Value::as_array) .filter(|outputs| !outputs.is_empty()) .ok_or_else(|| { - AiMuxError::InvalidResponseData(format!( + AiMuxError::invalid_response_data(format!( "No videos in response. Response: {raw_body}" )) })?; @@ -322,7 +322,7 @@ impl VideoModel for VertexVideoModel { .collect(); if videos.is_empty() { - return Err(AiMuxError::InvalidResponseData( + return Err(AiMuxError::invalid_response_data( "No valid videos in response".to_string(), )); } diff --git a/aimux-providers/src/voyage/mod.rs b/aimux-providers/src/voyage/mod.rs index bbdc90bc4..be1f9cf96 100644 --- a/aimux-providers/src/voyage/mod.rs +++ b/aimux-providers/src/voyage/mod.rs @@ -103,7 +103,7 @@ impl std::fmt::Debug for VoyageProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_voyage(settings: VoyageProviderSettings) -> Result { diff --git a/aimux-providers/src/xai/mod.rs b/aimux-providers/src/xai/mod.rs index 29f81edaf..f51b61dc4 100644 --- a/aimux-providers/src/xai/mod.rs +++ b/aimux-providers/src/xai/mod.rs @@ -193,6 +193,7 @@ where status_code: Some(status), response_body: Some(raw.to_string()), response_headers: Some(headers.clone()), + cause: Some(aimux_core::ErrorCause::source(&error)), ..aimux_core::ApiCallError::new( format!("Invalid JSON response: {error}"), url, diff --git a/aimux-providers/src/xai/options.rs b/aimux-providers/src/xai/options.rs index 100f2ca20..c2d083d3c 100644 --- a/aimux-providers/src/xai/options.rs +++ b/aimux-providers/src/xai/options.rs @@ -29,7 +29,7 @@ pub(crate) fn xai_metadata(payload: Value) -> ProviderMetadata { // where the schema says `.nullish()`). ── fn invalid(key: &str, expected: &str) -> AiMuxError { - AiMuxError::InvalidArgument(format!( + AiMuxError::invalid_argument(format!( "Invalid argument for parameter providerOptions: xai.{key} must be {expected}" )) } diff --git a/aimux-providers/src/xai/responses/convert.rs b/aimux-providers/src/xai/responses/convert.rs index 0566f815c..423bd306f 100644 --- a/aimux-providers/src/xai/responses/convert.rs +++ b/aimux-providers/src/xai/responses/convert.rs @@ -297,7 +297,7 @@ fn validate_value( if valid(value) { Ok(()) } else { - Err(AiMuxError::InvalidArgument(format!( + Err(AiMuxError::invalid_argument(format!( "Invalid xai option {key}: {value}" ))) } @@ -405,7 +405,7 @@ fn validate_provider_tool(tool: &aimux_core::tool::ProviderTool) -> Result<(), A _ => &[], } { if !args.contains_key(*key) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "Missing {key} for {}", tool.name ))); @@ -421,7 +421,7 @@ fn validate_provider_tool(tool: &aimux_core::tool::ProviderTool) -> Result<(), A /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when a part cannot be converted. +/// Returns `AiMuxError::invalid_argument` when a part cannot be converted. pub fn convert_to_xai_responses_input( prompt: &LanguageModelPrompt, ) -> Result<(Vec, Vec), AiMuxError> { @@ -474,7 +474,7 @@ pub fn convert_to_xai_responses_input( reference, crate::xai::options::NAMESPACE, ) - .map_err(AiMuxError::InvalidArgument)?; + .map_err(AiMuxError::invalid_argument)?; json!({ "type": "input_file", "file_id": file_id }) } FileData::Text { .. } => { diff --git a/aimux-providers/src/xai/speech.rs b/aimux-providers/src/xai/speech.rs index 6e73a308d..4775c12cc 100644 --- a/aimux-providers/src/xai/speech.rs +++ b/aimux-providers/src/xai/speech.rs @@ -12,6 +12,7 @@ use base64::Engine as _; use serde::Deserialize; use serde_json::{Map, Value, json}; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::shared::Warning; use aimux_core::speech_model::{ @@ -77,7 +78,7 @@ fn build_request(options: &SpeechCallOptions) -> Result<(Value, Vec, bo Some(Value::Object(map.clone())) } Some(_) => { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "Invalid argument for parameter providerOptions: xai.replace must be a record of strings" .to_owned(), )); @@ -174,8 +175,9 @@ impl SpeechModel for XaiSpeechModel { let audio = match &envelope.audio { Some(audio) => base64::engine::general_purpose::STANDARD .decode(audio) - .map_err(|e| { - AiMuxError::InvalidResponseData(format!("invalid base64 audio: {e}")) + .map_err(|e| AiMuxError::InvalidResponseData { + message: format!("invalid base64 audio: {e}"), + cause: Some(ErrorCause::source(&e)), })?, None => Vec::new(), }; diff --git a/aimux-providers/src/xai/transcription.rs b/aimux-providers/src/xai/transcription.rs index dfa0cdc13..90d2f6391 100644 --- a/aimux-providers/src/xai/transcription.rs +++ b/aimux-providers/src/xai/transcription.rs @@ -17,6 +17,7 @@ use base64::Engine as _; use serde::Deserialize; use serde_json::Value; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::shared::JsonObject; use aimux_core::transcription_model::{ @@ -81,8 +82,8 @@ impl Opts { None => None, Some(Value::Object(map)) => Some(map), Some(_) => { - return Err(AiMuxError::InvalidArgument( - "Invalid argument for parameter providerOptions: xai.streaming must be an object".into(), + return Err(AiMuxError::invalid_argument( + "Invalid argument for parameter providerOptions: xai.streaming must be an object", )); } }; @@ -159,7 +160,10 @@ impl TranscriptionModel for XaiTranscriptionModel { AudioInput::Binary(bytes) => bytes.clone(), AudioInput::Base64(data) => base64::engine::general_purpose::STANDARD .decode(data) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid base64: {e}")))?, + .map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid base64: {e}"), + cause: Some(ErrorCause::source(&e)), + })?, }; // xAI requires `file` to be the final multipart field. form.file( @@ -251,11 +255,15 @@ mod stream { format_type: &str, xai: &Opts, ) -> Result { - let mut url = url::Url::parse(&format!("{base_url}/stt")) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid base URL: {e}")))?; + let mut url = url::Url::parse(&format!("{base_url}/stt")).map_err(|e| { + AiMuxError::InvalidArgument { + message: format!("invalid base URL: {e}"), + cause: Some(ErrorCause::source(&e)), + } + })?; let scheme = if url.scheme() == "https" { "wss" } else { "ws" }; url.set_scheme(scheme) - .map_err(|()| AiMuxError::InvalidArgument("invalid base URL scheme".into()))?; + .map_err(|()| AiMuxError::invalid_argument("invalid base URL scheme"))?; let encoding = match format_type { "audio/pcmu" => "mulaw", "audio/pcma" => "alaw", @@ -298,9 +306,8 @@ mod stream { ) -> Result { let xai = Opts::parse(options.provider_options.as_ref())?; if xai.multichannel == Some(true) && xai.channels.is_none() { - return Err(AiMuxError::InvalidArgument( - "providerOptions.xai.channels is required when providerOptions.xai.multichannel is true" - .into(), + return Err(AiMuxError::invalid_argument( + "providerOptions.xai.channels is required when providerOptions.xai.multichannel is true", )); } let mut warnings = Vec::new(); @@ -384,7 +391,7 @@ mod stream { Some(AudioChunk::Base64(data)) => { match base64::engine::general_purpose::STANDARD.decode(data) { Ok(bytes) => ws.send_binary(&bytes).await, - Err(e) => Err(AiMuxError::InvalidArgument(format!("invalid base64: {e}"))), + Err(e) => Err(AiMuxError::InvalidArgument { message: format!("invalid base64: {e}"), cause: Some(ErrorCause::source(&e)) }), } } }; @@ -473,7 +480,7 @@ mod stream { } // xAI STT errors are terminal: surface the server message. Some("error") => { - yield Err(AiMuxError::Other( + yield Err(AiMuxError::other( raw.get("message").and_then(Value::as_str).unwrap_or("xAI STT error").to_owned(), )); ws.close().await; diff --git a/aimux-providers/src/xai/video.rs b/aimux-providers/src/xai/video.rs index b62feaddb..1c3b13cb5 100644 --- a/aimux-providers/src/xai/video.rs +++ b/aimux-providers/src/xai/video.rs @@ -114,7 +114,7 @@ fn unsupported(warnings: &mut Vec, feature: &str, details: impl Into AiMuxError { - AiMuxError::InvalidArgument(format!( + AiMuxError::invalid_argument(format!( "Invalid argument for parameter providerOptions: xai.{key} must be {expected}" )) } @@ -713,7 +713,7 @@ impl VideoModel for XaiVideoModel { .value .request_id .filter(|id| !id.is_empty()) - .ok_or_else(|| AiMuxError::Other("No request_id returned from xAI API.".into()))?; + .ok_or_else(|| AiMuxError::other("No request_id returned from xAI API."))?; Ok(VideoOperationStart { operation: json!({ "requestId": request_id }), warnings, @@ -735,7 +735,7 @@ impl VideoModel for XaiVideoModel { .get("requestId") .and_then(Value::as_str) .ok_or_else(|| { - AiMuxError::InvalidArgument("xai operation reference is missing requestId".into()) + AiMuxError::invalid_argument("xai operation reference is missing requestId") })?; let exchange = self.config.exchange(options.headers.as_ref()).await?; let url = exchange.url(&format!("/videos/{}", encode_path_segment(request_id))); diff --git a/aimux-providers/src/you_com.rs b/aimux-providers/src/you_com.rs index 525327596..44aefa58b 100644 --- a/aimux-providers/src/you_com.rs +++ b/aimux-providers/src/you_com.rs @@ -19,6 +19,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::Value; +use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, @@ -108,7 +109,7 @@ impl std::fmt::Debug for YouComProviderSettings { /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` /// URL with a host. That is the only way this fails: the key is loaded per /// request, not here. pub fn create_you_com(settings: YouComProviderSettings) -> Result { @@ -245,8 +246,12 @@ impl SearchModel for YouComSearchModel { let count = resolve_count(options.max_results); let exchange = self.config.exchange(options.headers.as_ref()).await?; - let mut url = url::Url::parse(&exchange.url("/v1/search")) - .map_err(|e| AiMuxError::InvalidArgument(format!("invalid you_com endpoint: {e}")))?; + let mut url = url::Url::parse(&exchange.url("/v1/search")).map_err(|e| { + AiMuxError::InvalidArgument { + message: format!("invalid you_com endpoint: {e}"), + cause: Some(ErrorCause::source(&e)), + } + })?; url.query_pairs_mut() .append_pair("query", &options.query) .append_pair("count", &count.to_string()); diff --git a/aimux-providers/tests/anthropic_aws_settings_test.rs b/aimux-providers/tests/anthropic_aws_settings_test.rs index c710ce420..beeb4ebc8 100644 --- a/aimux-providers/tests/anthropic_aws_settings_test.rs +++ b/aimux-providers/tests/anthropic_aws_settings_test.rs @@ -115,7 +115,7 @@ async fn region_comes_from_the_setting_or_aws_region_with_no_default() { let fetch = MockFetch::new(vec![]); let error = generate(&fetch, settings()).await.unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidArgument(message) if message.contains("Invalid AWS region")), + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message.contains("Invalid AWS region")), "{error:?}" ); assert!(fetch.seen().is_empty()); diff --git a/aimux-providers/tests/anthropic_provider_tool_args_test.rs b/aimux-providers/tests/anthropic_provider_tool_args_test.rs index ececffbf4..fbb5e5a40 100644 --- a/aimux-providers/tests/anthropic_provider_tool_args_test.rs +++ b/aimux-providers/tests/anthropic_provider_tool_args_test.rs @@ -142,7 +142,7 @@ async fn args_that_do_not_match_fail_the_call_before_any_request() { for (id, _, invalid) in cases() { let (result, requests) = call(tool(id, invalid)).await; match result { - Err(AiMuxError::InvalidArgument(message)) => assert_eq!( + Err(AiMuxError::InvalidArgument { message, .. }) => assert_eq!( message, format!("Invalid configuration for provider-defined tool {id}") ), @@ -156,7 +156,7 @@ async fn args_that_do_not_match_fail_the_call_before_any_request() { async fn the_advisor_needs_a_model() { let (result, requests) = call(tool("anthropic.advisor_20260301", json!({}))).await; assert!( - matches!(result, Err(AiMuxError::InvalidArgument(_))), + matches!(result, Err(AiMuxError::InvalidArgument { .. })), "{result:?}" ); assert_eq!(requests, 0); @@ -170,7 +170,7 @@ async fn a_toolset_config_must_be_an_object() { )) .await; assert!( - matches!(result, Err(AiMuxError::InvalidArgument(_))), + matches!(result, Err(AiMuxError::InvalidArgument { .. })), "{result:?}" ); } diff --git a/aimux-providers/tests/anthropic_remaining_test.rs b/aimux-providers/tests/anthropic_remaining_test.rs index c587ef204..5aa7e1b85 100644 --- a/aimux-providers/tests/anthropic_remaining_test.rs +++ b/aimux-providers/tests/anthropic_remaining_test.rs @@ -156,7 +156,7 @@ fn rejects_empty_base_url_option() { ..Default::default() }); assert!( - matches!(result, Err(AiMuxError::InvalidArgument(msg)) if msg == "baseURL must be a non-empty string.") + matches!(result, Err(AiMuxError::InvalidArgument { message: msg, .. }) if msg == "baseURL must be a non-empty string.") ); } @@ -187,7 +187,7 @@ fn throws_when_both_api_key_and_auth_token_provided() { ..Default::default() }); assert!( - matches!(result, Err(AiMuxError::InvalidArgument(msg)) if msg == "Both apiKey and authToken were provided. Please use only one authentication method.") + matches!(result, Err(AiMuxError::InvalidArgument { message: msg, .. }) if msg == "Both apiKey and authToken were provided. Please use only one authentication method.") ); } diff --git a/aimux-providers/tests/bedrock_upstream_e2e_test.rs b/aimux-providers/tests/bedrock_upstream_e2e_test.rs index 27340fb8a..1caab7f10 100644 --- a/aimux-providers/tests/bedrock_upstream_e2e_test.rs +++ b/aimux-providers/tests/bedrock_upstream_e2e_test.rs @@ -450,7 +450,7 @@ async fn provider_tool_args_are_checked_before_the_request() { .await .unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidArgument(message) if message == "Invalid configuration for provider-defined tool anthropic.web_fetch_20250910"), + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message == "Invalid configuration for provider-defined tool anthropic.web_fetch_20250910"), "{error:?}" ); assert!(fetch.seen().is_empty()); diff --git a/aimux-providers/tests/codex_test.rs b/aimux-providers/tests/codex_test.rs index 83ef2826f..c564181bb 100644 --- a/aimux-providers/tests/codex_test.rs +++ b/aimux-providers/tests/codex_test.rs @@ -350,7 +350,7 @@ async fn subscription_generate_returns_parse_error_without_a_completed_event() { .await .unwrap_err(); - assert!(matches!(error, AiMuxError::JsonParse(_))); + assert!(matches!(error, AiMuxError::JsonParse { .. })); } #[tokio::test] diff --git a/aimux-providers/tests/deepseek_chat_test.rs b/aimux-providers/tests/deepseek_chat_test.rs index 99d631de0..d739da50e 100644 --- a/aimux-providers/tests/deepseek_chat_test.rs +++ b/aimux-providers/tests/deepseek_chat_test.rs @@ -369,7 +369,7 @@ async fn should_reject_a_response_without_choices() { .await .unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidResponseData(m) if m == "Response did not contain any choices."), + matches!(&error, AiMuxError::InvalidResponseData { message: m, .. } if m == "Response did not contain any choices."), "{error:?}" ); } diff --git a/aimux-providers/tests/download_ssrf_guard_test.rs b/aimux-providers/tests/download_ssrf_guard_test.rs index 607e12ee6..08db09027 100644 --- a/aimux-providers/tests/download_ssrf_guard_test.rs +++ b/aimux-providers/tests/download_ssrf_guard_test.rs @@ -55,7 +55,7 @@ async fn rejects_response_body_url_pointing_at_loopback_metadata() { .await .expect_err("guard must block metadata IP"); match err { - aimux_core::AiMuxError::InvalidArgument(msg) => { + aimux_core::AiMuxError::InvalidArgument { message: msg, .. } => { assert!(msg.contains("non-public"), "unexpected message: {msg}"); } other => panic!("expected InvalidArgument, got {other:?}"), diff --git a/aimux-providers/tests/google_evaluation_test.rs b/aimux-providers/tests/google_evaluation_test.rs index f9eecf9be..1208874a4 100644 --- a/aimux-providers/tests/google_evaluation_test.rs +++ b/aimux-providers/tests/google_evaluation_test.rs @@ -215,7 +215,7 @@ async fn rejects_unfinished_generations_even_with_valid_json() { )); let error = model.do_decide(&options()).await.unwrap_err(); assert!( - matches!(error, AiMuxError::InvalidResponseData(_)), + matches!(error, AiMuxError::InvalidResponseData { .. }), "got {error:?}" ); } @@ -227,7 +227,7 @@ async fn validates_score_bounds_locally() { let (model, _) = setup(&with_candidate(fixture(), parts(r#"{"q0":"c1","q1":3}"#))); let error = model.do_decide(&options()).await.unwrap_err(); assert!( - matches!(error, AiMuxError::InvalidResponseData(_)), + matches!(error, AiMuxError::InvalidResponseData { .. }), "got {error:?}" ); } diff --git a/aimux-providers/tests/google_pse_test.rs b/aimux-providers/tests/google_pse_test.rs index d54a84d60..2a2ca16d4 100644 --- a/aimux-providers/tests/google_pse_test.rs +++ b/aimux-providers/tests/google_pse_test.rs @@ -171,7 +171,7 @@ async fn missing_cx_returns_invalid_argument_error() { let result = model.do_search(&opts("rust language")).await; assert!( - matches!(result, Err(AiMuxError::InvalidArgument(ref m)) if m.contains("cx")), + matches!(result, Err(AiMuxError::InvalidArgument { message: ref m, .. }) if m.contains("cx")), "expected InvalidArgument error mentioning cx, got {result:?}" ); diff --git a/aimux-providers/tests/google_speech_test.rs b/aimux-providers/tests/google_speech_test.rs index 0c50c4919..0a7a5cb98 100644 --- a/aimux-providers/tests/google_speech_test.rs +++ b/aimux-providers/tests/google_speech_test.rs @@ -102,7 +102,7 @@ fn has_warning(result: &SpeechResult, expected: &str) -> bool { fn invalid_argument(error: AiMuxError) -> String { match error { - AiMuxError::InvalidArgument(message) => message, + AiMuxError::InvalidArgument { message, .. } => message, other => panic!("expected InvalidArgument, got {other:?}"), } } diff --git a/aimux-providers/tests/groq_test.rs b/aimux-providers/tests/groq_test.rs index 9e5189331..5c6185655 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -1115,7 +1115,7 @@ mod do_generate { .await .unwrap_err(); assert!( - matches!(&error, aimux_core::AiMuxError::InvalidResponseData(m) if m == "Response did not contain any choices."), + matches!(&error, aimux_core::AiMuxError::InvalidResponseData { message: m, .. } if m == "Response did not contain any choices."), "{error:?}" ); } diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 8bb5c913e..c06505769 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -798,7 +798,7 @@ async fn should_handle_streaming_errors() { .position(|outcome| { matches!( outcome, - Err(AiMuxError::JsonParse(_) | AiMuxError::InvalidResponseData(_)) + Err(AiMuxError::JsonParse { .. } | AiMuxError::InvalidResponseData { .. }) ) }) .expect("malformed frame should surface as a parse error item"); diff --git a/aimux-providers/tests/jev_test.rs b/aimux-providers/tests/jev_test.rs index 44948d61d..104025b41 100644 --- a/aimux-providers/tests/jev_test.rs +++ b/aimux-providers/tests/jev_test.rs @@ -367,7 +367,7 @@ async fn invalid_requests_never_reach_http() { ] { assert!(matches!( decide(model(&server).as_ref(), options).await, - Err(AiMuxError::InvalidArgument(_)) + Err(AiMuxError::InvalidArgument { .. }) )); } let mut provider_options = options(); diff --git a/aimux-providers/tests/list_models_test.rs b/aimux-providers/tests/list_models_test.rs index 6b5aedcdf..6a45e4ff9 100644 --- a/aimux-providers/tests/list_models_test.rs +++ b/aimux-providers/tests/list_models_test.rs @@ -248,7 +248,7 @@ fn catalogue_normalize_and_lookup() { #[test] fn parse_anya2a_missing_providers_key() { let err = catalogue::parse_anya2a_all(&serde_json::json!({"updated_at":"0"})).unwrap_err(); - assert!(matches!(err, aimux_core::AiMuxError::JsonParse(_))); + assert!(matches!(err, aimux_core::AiMuxError::JsonParse { .. })); } #[test] diff --git a/aimux-providers/tests/mistral_transcription_test.rs b/aimux-providers/tests/mistral_transcription_test.rs index 61d2e7be9..9f94a9711 100644 --- a/aimux-providers/tests/mistral_transcription_test.rs +++ b/aimux-providers/tests/mistral_transcription_test.rs @@ -228,7 +228,7 @@ async fn should_pass_provider_options_as_mistral_multipart_fields() { async fn should_reject_context_bias_items_with_commas_or_whitespace() { let fetch = MockFetch::new(vec![]); let model = provider_with(&fetch, MistralProviderSettings::default()).transcription(MODEL_ID); - let Err(AiMuxError::InvalidArgument(message)) = model + let Err(AiMuxError::InvalidArgument { message, .. }) = model .do_generate(&wav(Some(json!({ "contextBias": ["AI SDK"] })))) .await else { @@ -256,7 +256,7 @@ async fn should_reject_language_combined_with_timestamp_granularities() { "timestampGranularities": ["segment"], })))) .await; - assert!(matches!(result, Err(AiMuxError::InvalidArgument(_)))); + assert!(matches!(result, Err(AiMuxError::InvalidArgument { .. }))); assert!(fetch.seen().is_empty()); } diff --git a/aimux-providers/tests/openai_decision_test.rs b/aimux-providers/tests/openai_decision_test.rs index 084ffa531..6131a8f37 100644 --- a/aimux-providers/tests/openai_decision_test.rs +++ b/aimux-providers/tests/openai_decision_test.rs @@ -386,7 +386,7 @@ fn factory_is_explicit_and_preserves_the_provider_boundary() { assert_eq!(model.supported_question_types(), DecisionQuestionType::ALL); assert!(matches!( provider.decision_model(" "), - Some(Err(AiMuxError::InvalidArgument(_))) + Some(Err(AiMuxError::InvalidArgument { .. })) )); let registry = aimux_core::create_provider_registry( std::collections::BTreeMap::from([("openai".to_string(), provider)]), diff --git a/aimux-providers/tests/openai_upstream_e2e_test.rs b/aimux-providers/tests/openai_upstream_e2e_test.rs index d6b69b1cb..9ce54f296 100644 --- a/aimux-providers/tests/openai_upstream_e2e_test.rs +++ b/aimux-providers/tests/openai_upstream_e2e_test.rs @@ -738,7 +738,7 @@ async fn image_edit_url_files_are_downloaded_through_the_guard() { .await .unwrap_err(); assert!( - matches!(error, AiMuxError::InvalidArgument(ref message) if message.contains("non-public")), + matches!(error, AiMuxError::InvalidArgument { ref message, .. } if message.contains("non-public")), "{error:?}" ); assert!(fetch.seen().is_empty()); diff --git a/aimux-providers/tests/presets_test.rs b/aimux-providers/tests/presets_test.rs index 9ad217251..1c92ee58a 100644 --- a/aimux-providers/tests/presets_test.rs +++ b/aimux-providers/tests/presets_test.rs @@ -260,7 +260,7 @@ async fn a_template_parameter_must_be_a_plain_host_segment() { }, ); assert!( - matches!(result, Err(AiMuxError::InvalidArgument(message)) if message.contains("branch_host")) + matches!(result, Err(AiMuxError::InvalidArgument { message, .. }) if message.contains("branch_host")) ); assert!(mock.seen().is_empty()); } @@ -369,7 +369,7 @@ async fn an_invalid_base_url_variable_fails_the_request_not_the_factory() { panic!("an ftp:// base URL must fail the request"); }; assert!( - matches!(&error, AiMuxError::InvalidArgument(message) + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message.contains("OLLAMA_BASE_URL") && message.contains("http or https")), "{error:?}" ); diff --git a/aimux-providers/tests/runtime_decision_test.rs b/aimux-providers/tests/runtime_decision_test.rs index c5f733713..769abb4fc 100644 --- a/aimux-providers/tests/runtime_decision_test.rs +++ b/aimux-providers/tests/runtime_decision_test.rs @@ -557,7 +557,7 @@ async fn unsupported_fields_and_bad_encodings_fail_before_http() { } assert!(matches!( decide(model(provider, &server).as_ref(), request).await, - Err(AiMuxError::InvalidArgument(_)) + Err(AiMuxError::InvalidArgument { .. }) )); } } @@ -715,7 +715,7 @@ async fn scoring_rejects_wrong_dimensions_and_inconsistent_probabilities() { .await; assert!(matches!( decide(model("vllm", &server).as_ref(), scoring_options("vllm")).await, - Err(AiMuxError::InvalidResponseData(_)) + Err(AiMuxError::InvalidResponseData { .. }) )); assert_eq!(server.received_requests().await.unwrap().len(), 7); } @@ -826,7 +826,7 @@ async fn runtime_limits_are_checked_before_http() { ); } else { assert!( - matches!(error, AiMuxError::InvalidArgument(_)), + matches!(error, AiMuxError::InvalidArgument { .. }), "{provider}: {error:?}" ); } diff --git a/aimux-providers/tests/vertex_upstream_e2e_test.rs b/aimux-providers/tests/vertex_upstream_e2e_test.rs index 62084ad5c..a0955fd59 100644 --- a/aimux-providers/tests/vertex_upstream_e2e_test.rs +++ b/aimux-providers/tests/vertex_upstream_e2e_test.rs @@ -482,7 +482,7 @@ async fn executable_credential_without_the_opt_in_fails_and_spawns_nothing() { .await .unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidArgument(message) + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message.contains("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1")), "{error:?}" ); diff --git a/aimux-providers/tests/xai_transcription_test.rs b/aimux-providers/tests/xai_transcription_test.rs index 74b27979b..749421d40 100644 --- a/aimux-providers/tests/xai_transcription_test.rs +++ b/aimux-providers/tests/xai_transcription_test.rs @@ -542,7 +542,7 @@ async fn should_require_channels_when_streaming_multichannel_audio() { .unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidArgument(message) if message.contains( + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message.contains( "providerOptions.xai.channels is required when providerOptions.xai.multichannel is true" )), "got {error:?}" @@ -719,7 +719,7 @@ async fn should_error_the_stream_with_the_server_message_on_error_events() { )); assert_eq!(run.parts.len(), 2, "parts: {:?}", run.parts); assert!( - matches!(&run.parts[1], Err(AiMuxError::Other(message)) if message == "invalid sample_rate"), + matches!(&run.parts[1], Err(AiMuxError::Other { message, .. }) if message == "invalid sample_rate"), "got {:?}", run.parts[1] ); diff --git a/aimux-providers/tests/xai_video_test.rs b/aimux-providers/tests/xai_video_test.rs index b1320caa6..97f4d36fd 100644 --- a/aimux-providers/tests/xai_video_test.rs +++ b/aimux-providers/tests/xai_video_test.rs @@ -315,7 +315,9 @@ async fn do_start_fails_without_request_id() { .await .unwrap_err(); match err { - AiMuxError::Other(message) => assert_eq!(message, "No request_id returned from xAI API."), + AiMuxError::Other { message, .. } => { + assert_eq!(message, "No request_id returned from xAI API.") + } other => panic!("unexpected error: {other:?}"), } } @@ -735,7 +737,7 @@ async fn rejects_invalid_reference_voice_ids() { }), ); match try_start(V15, &o).await { - Err(AiMuxError::InvalidArgument(_)) => {} + Err(AiMuxError::InvalidArgument { .. }) => {} Err(other) => panic!("unexpected error: {other:?}"), Ok(_) => panic!("expected InvalidArgument for {ids}"), } diff --git a/bindings/c/README.md b/bindings/c/README.md index b8cc0d5c6..e29d0fe2c 100644 --- a/bindings/c/README.md +++ b/bindings/c/README.md @@ -5,6 +5,8 @@ ABI primitives. Both examples run offline and print the manifest and runtime description. `aimux_raii.hpp` owns strings, errors, and handles; its stream trampoline copies borrowed frame bytes, catches every C++ exception, requests abort, and rethrows the first exception only after `aimux_stream` returns. +`aimux::check` throws `aimux::NativeError` (a `std::runtime_error`) carrying the +error code and the envelope JSON, whose `error.cause` holds the cause chain. A stream wrapper caller must supply a live Abort handle for cancellation. Callbacks must return promptly. diff --git a/bindings/c/aimux_raii.hpp b/bindings/c/aimux_raii.hpp index 77e6ae4c9..29626c69c 100644 --- a/bindings/c/aimux_raii.hpp +++ b/bindings/c/aimux_raii.hpp @@ -19,11 +19,26 @@ struct ErrorDeleter { using String = std::unique_ptr; using Error = std::unique_ptr; +/* A native error: `what()` is the envelope message, `json()` the whole + * envelope, whose `error.cause` carries the cause chain. */ +class NativeError : public std::runtime_error { +public: + NativeError(const char *message, std::int32_t code, std::string json) + : std::runtime_error(message), code_(code), json_(std::move(json)) {} + std::int32_t code() const noexcept { return code_; } + const std::string &json() const noexcept { return json_; } +private: + std::int32_t code_; + std::string json_; +}; + inline void check(aimux_error_t *raw) { Error error(raw); if (!error) return; String message(aimux_error_message(error.get())); - throw std::runtime_error(message ? message.get() : "aimux call failed"); + String json(aimux_error_json(error.get())); + throw NativeError(message ? message.get() : "aimux call failed", + aimux_error_code(error.get()), json ? json.get() : ""); } class Handle { public: diff --git a/bindings/flutter/README.md b/bindings/flutter/README.md index 515f1b6cf..fdaac174e 100644 --- a/bindings/flutter/README.md +++ b/bindings/flutter/README.md @@ -62,6 +62,8 @@ return. No callback exception crosses the C boundary. `OpsException` preserves the complete error envelope, including unknown payload names/fields and optional `retryAfterMs`. AI SDK payloads keep their `name`, flat fields and `isRetryable`; the binding does not invent HTTP status or retry policy. +`cause` is the payload's cause: a nested AI SDK error, or an OS, TLS or parser +error as `{message, cause?}`. `TranscriptionOperation` wraps the new push/next primitives and typed part/ended/timeout states. `ToolCallRepair` exposes the three pure DTO ops. `DecisionModel` exposes `decision.decide` and `decision.capabilities` over an diff --git a/bindings/flutter/lib/errors.dart b/bindings/flutter/lib/errors.dart index 279425277..6bc46ae6b 100644 --- a/bindings/flutter/lib/errors.dart +++ b/bindings/flutter/lib/errors.dart @@ -21,6 +21,13 @@ final class OpsException implements Exception { Map get error => Map.unmodifiable(envelope['error'] as Map); int? get retryAfterMs => envelope['retryAfterMs'] as int?; + + /// The payload's cause: a nested AI SDK error (with `name`) or an + /// underlying OS, TLS or parser error as `{message, cause?}`. + Map? get cause { + final cause = (envelope['error'] as Map)['cause']; + return cause is Map ? Map.unmodifiable(cause) : null; + } Map toJson() => Map.of(envelope); @override diff --git a/bindings/flutter/test/errors_test.dart b/bindings/flutter/test/errors_test.dart index 35ed39d3e..57f19df28 100644 --- a/bindings/flutter/test/errors_test.dart +++ b/bindings/flutter/test/errors_test.dart @@ -36,6 +36,24 @@ void main() { expect(error.error['statusCode'], 429); expect(error.toJson().containsKey('retryable'), isFalse); }); + test('cause is the payload cause chain', () { + final error = OpsException.fromJson({ + 'kind': 'aimux', + 'code': 11, + 'message': 'API call error: Cannot connect', + 'error': { + 'name': 'AI_APICallError', + 'message': 'Cannot connect', + 'cause': { + 'message': 'tcp connect error', + 'cause': {'message': 'Connection refused'}, + }, + }, + }); + expect(error.cause?['message'], 'tcp connect error'); + expect((error.cause?['cause'] as Map)['message'], 'Connection refused'); + expect(bindingFailure('x').cause, isNull); + }); test('malformed envelope is rejected', () { expect(() => OpsException.fromJson({'code': 11}), throwsFormatException); expect( diff --git a/bindings/go/README.md b/bindings/go/README.md index cd5b06cdd..2b908ffa1 100644 --- a/bindings/go/README.md +++ b/bindings/go/README.md @@ -59,7 +59,9 @@ abandoned stream. Native errors support `errors.As` with `*aimux.Error`. `Envelope` contains the domain, code, message, complete error payload and optional retry hint; -`RawEnvelope` also retains unknown envelope fields. The binding adds no retry +`RawEnvelope` also retains unknown envelope fields. `Unwrap` returns the +payload's cause as a `*aimux.Cause` chain (`Name` is empty for an OS, TLS or +parser error), so `errors.Unwrap` and `errors.As` reach it. The binding adds no retry policy. Handles support concurrent calls and idempotent Close. Use Close for normal cleanup; the fallback finalizer only enqueues native cleanup. diff --git a/bindings/go/error.go b/bindings/go/error.go index a483366e8..755bed60b 100644 --- a/bindings/go/error.go +++ b/bindings/go/error.go @@ -31,6 +31,50 @@ func (e *Error) Error() string { return fmt.Sprintf("aimux %s (%d): %s", e.Envelope.Kind, e.Envelope.Code, e.Envelope.Message) } +// Unwrap returns the payload's cause, so errors.Unwrap, errors.Is and +// errors.As walk the same chain as Rust's source(). +func (e *Error) Unwrap() error { + var payload struct { + Cause *Cause `json:"cause"` + } + if json.Unmarshal(e.Envelope.Error, &payload) != nil || payload.Cause == nil { + return nil + } + return payload.Cause +} + +// Cause is one link of an error's cause chain: a nested AI SDK error (Name +// set, Raw holding its whole payload) or an underlying OS, TLS or parser +// error (no Name). +type Cause struct { + Name string + Message string + Cause *Cause + Raw json.RawMessage +} + +func (c *Cause) Error() string { return c.Message } + +func (c *Cause) Unwrap() error { + if c.Cause == nil { + return nil + } + return c.Cause +} + +func (c *Cause) UnmarshalJSON(data []byte) error { + var fields struct { + Name string `json:"name"` + Message string `json:"message"` + Cause *Cause `json:"cause"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + *c = Cause{fields.Name, fields.Message, fields.Cause, append(json.RawMessage(nil), data...)} + return nil +} + func decodeError(data []byte) (*Error, error) { if err := validateJSON("error envelope", data); err != nil { return nil, err diff --git a/bindings/go/error_test.go b/bindings/go/error_test.go new file mode 100644 index 000000000..ec90876c7 --- /dev/null +++ b/bindings/go/error_test.go @@ -0,0 +1,32 @@ +package aimux + +import ( + "errors" + "testing" +) + +func TestErrorUnwrapWalksThePayloadCause(t *testing.T) { + err, decodeErr := decodeError([]byte(`{"kind":"aimux","code":11,"message":"API call error: Cannot connect", + "error":{"name":"AI_APICallError","message":"Cannot connect","cause": + {"name":"AI_JSONParseError","message":"tcp connect error","cause":{"message":"Connection refused"}}}}`)) + if decodeErr != nil { + t.Fatal(decodeErr) + } + var messages []string + for link := errors.Unwrap(err); link != nil; link = errors.Unwrap(link) { + messages = append(messages, link.Error()) + } + if len(messages) != 2 || messages[0] != "tcp connect error" || messages[1] != "Connection refused" { + t.Fatalf("chain %q", messages) + } + var cause *Cause + if !errors.As(err.Unwrap(), &cause) || cause.Name != "AI_JSONParseError" || len(cause.Raw) == 0 { + t.Fatalf("cause %+v", cause) + } + if cause.Cause.Name != "" { + t.Fatalf("an OS error has no name: %+v", cause.Cause) + } + if local := localError("protocol", 301, "InvalidParams", "bad"); local.Unwrap() != nil { + t.Fatal("no cause must unwrap to nil") + } +} diff --git a/bindings/java/README.md b/bindings/java/README.md index 3251e3b81..098cf371e 100644 --- a/bindings/java/README.md +++ b/bindings/java/README.md @@ -69,6 +69,8 @@ cause after native return. Exceptions never cross the C callback. optional unsigned `retryAfterMs` as `BigInteger`. Domain exceptions select subclasses by `error.name`; unfamiliar variants use the domain's `Unknown` subclass and retain their payload. Retryability comes from `error.isRetryable`. +The payload's `cause` is `getCause()`: an `ErrorCause` (`getErrorName()` is +empty for an OS, TLS or parser error) whose own cause continues the chain. A missing retry hint differs from zero. The Java adapter performs no retries. `TranscriptionSession` uses binary push, `operation.inputDone` and native polling. diff --git a/bindings/java/src/main/java/ai/arcships/aimux/ErrorCause.java b/bindings/java/src/main/java/ai/arcships/aimux/ErrorCause.java new file mode 100644 index 000000000..8677a4430 --- /dev/null +++ b/bindings/java/src/main/java/ai/arcships/aimux/ErrorCause.java @@ -0,0 +1,28 @@ +package ai.arcships.aimux; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * One link of a native error's {@code cause} chain: a nested AI SDK error + * ({@link #getErrorName()} is its name) or an underlying OS, TLS or parser + * error (no name). Its own cause continues the chain. + */ +public final class ErrorCause extends RuntimeException { + private final JsonNode error; + + private ErrorCause(JsonNode error) { + super(error.path("message").asText()); + this.error = error.deepCopy(); + ErrorCause cause = of(error); + if (cause != null) initCause(cause); + } + + /** The cause of {@code payload}, or null when it has none. */ + static ErrorCause of(JsonNode payload) { + JsonNode cause = payload.get("cause"); + return cause != null && cause.isObject() ? new ErrorCause(cause) : null; + } + + public String getErrorName() { return error.path("name").asText(); } + public JsonNode getError() { return error.deepCopy(); } +} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/NativeException.java b/bindings/java/src/main/java/ai/arcships/aimux/NativeException.java index 30e4fb80f..fea9cff0e 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/NativeException.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/NativeException.java @@ -3,13 +3,18 @@ import com.fasterxml.jackson.databind.JsonNode; import java.math.BigInteger; -/** Structured error envelope, retaining the complete AI SDK or domain payload. */ +/** + * Structured error envelope, retaining the complete AI SDK or domain payload. + * The payload's {@code cause} is {@link #getCause()}, as an {@link ErrorCause}. + */ public abstract class NativeException extends RuntimeException { private final JsonNode envelope; NativeException(JsonNode envelope) { super(envelope.path("message").asText()); this.envelope = envelope.deepCopy(); + ErrorCause cause = ErrorCause.of(envelope.path("error")); + if (cause != null) initCause(cause); } public final String getKind() { return envelope.path("kind").asText(); } diff --git a/bindings/java/src/test/java/ai/arcships/aimux/ErrorCauseTest.java b/bindings/java/src/test/java/ai/arcships/aimux/ErrorCauseTest.java new file mode 100644 index 000000000..aaacf73cf --- /dev/null +++ b/bindings/java/src/test/java/ai/arcships/aimux/ErrorCauseTest.java @@ -0,0 +1,45 @@ +package ai.arcships.aimux; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** The payload's {@code cause} chain becomes the exception's {@code getCause()} chain. */ +class ErrorCauseTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static NativeException decode(String error) throws Exception { + JsonNode envelope = MAPPER.readTree( + "{\"kind\":\"aimux\",\"code\":11,\"message\":\"API call error: Cannot connect\",\"error\":" + error + "}"); + return AimuxResult.decode(envelope); + } + + @Test void transportCauseChainIsTheCauseChain() throws Exception { + NativeException error = decode("{\"name\":\"AI_APICallError\",\"message\":\"Cannot connect\"," + + "\"cause\":{\"message\":\"tcp connect error\",\"cause\":{\"message\":\"Connection refused\"}}}"); + assertThat(error).isInstanceOf(AimuxException.APICallError.class) + .hasCauseInstanceOf(ErrorCause.class) + .hasRootCauseMessage("Connection refused"); + ErrorCause cause = (ErrorCause) error.getCause(); + assertThat(cause.getMessage()).isEqualTo("tcp connect error"); + assertThat(cause.getErrorName()).isEmpty(); + } + + @Test void nestedAimuxCauseKeepsItsName() throws Exception { + NativeException error = decode("{\"name\":\"AI_InvalidToolInputError\",\"toolName\":\"t\",\"toolInput\":\"{\"," + + "\"cause\":{\"name\":\"AI_JSONParseError\",\"message\":\"JSON parsing failed\"}}"); + ErrorCause cause = (ErrorCause) error.getCause(); + assertThat(cause.getErrorName()).isEqualTo("AI_JSONParseError"); + assertThat(cause.getError().path("message").asText()).isEqualTo("JSON parsing failed"); + assertThat(cause.getCause()).isNull(); + } + + @Test void noCauseLeavesTheCauseFree() throws Exception { + NativeException error = decode("{\"name\":\"TimeoutError\",\"message\":\"t\"}"); + assertThat(error.getCause()).isNull(); + assertThat(AimuxResult.callbackFailure(new IllegalStateException("x")).getCause()) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/bindings/kotlin/README.md b/bindings/kotlin/README.md index faee68cc0..daede53bb 100644 --- a/bindings/kotlin/README.md +++ b/bindings/kotlin/README.md @@ -59,9 +59,10 @@ The sealed `AimuxException` delegates `code`, `kind`, `errorName`, `error`, known core subtypes have corresponding Kotlin variants with typed Java `details`. `BindingError`, `ProtocolError` and `RecordingError` retain their Java domain errors. `UnknownError` preserves a future Java exception subtype. -`javaError` is also the cause. Kotlin performs no JSON error decoding. Java's -current decoder rejects unknown wire variants before a Kotlin projection can -occur; those decoder failures propagate unchanged. +`javaError` is also the cause, so the Java `ErrorCause` chain follows it. +Kotlin performs no JSON error decoding. Java's current decoder rejects unknown +wire variants before a Kotlin projection can occur; those decoder failures +propagate unchanged. ## Breaking changes diff --git a/bindings/node/__test__/facade.test.ts b/bindings/node/__test__/facade.test.ts index f569899d8..439a0e4fe 100644 --- a/bindings/node/__test__/facade.test.ts +++ b/bindings/node/__test__/facade.test.ts @@ -43,6 +43,22 @@ test('error properties and prototype match for both layers, including zero retry } }) +test('the payload cause is the standard Error.cause on raw and typed rejections', async () => { + const client = new Client() + for (const call of [() => client.raw.call('0', 'fixture.cause', '{}'), () => client.call('0', 'fixture.cause')]) { + await assert.rejects(call, (error: unknown) => { + assert.ok(error instanceof AimuxError) + assert.equal(error.cause, (error.error as { cause?: unknown }).cause) + assert.deepEqual(error.cause, { message: 'error sending request', cause: { message: 'Connection refused (os error 111)' } }) + assert.equal(Object.keys(error).includes('cause'), false) + return true + }) + } + const built = new AimuxError({ kind: 'aimux', code: 2, message: 'm', error: { name: 'AI_JSONParseError', message: 'm', text: '{', cause: { message: 'EOF' } } }) + assert.deepEqual(built.cause, { message: 'EOF' }) + assert.equal('cause' in new AimuxError({ kind: 'aimux', code: 12, message: 't', error: { name: 'TimeoutError', message: 't' } }), false) +}) + test('events retain recoverable error data and a unique explicit terminal', async () => { const client = new Client() const model = await (await client.provider({ package: 'openai' })).model('test') diff --git a/bindings/node/__test__/fixtures/native.cjs b/bindings/node/__test__/fixtures/native.cjs index 1dc9fa87b..92b72ab72 100644 --- a/bindings/node/__test__/fixtures/native.cjs +++ b/bindings/node/__test__/fixtures/native.cjs @@ -40,6 +40,7 @@ class Runtime { case ops.OP_TRANSCRIPTION_START: return JSON.stringify(this.create('Operation').handle) case ops.OP_TEXT_GENERATE: return JSON.stringify({ text: params.prompt, nested: params.options, raw: null }) case ops.OP_OPERATION_NEXT: return JSON.stringify({ type: 'ended' }) + case 'fixture.cause': throw envelope('aimux', 11, 'AI_APICallError', 'Cannot connect to API: error sending request', { cause: { message: 'error sending request', cause: { message: 'Connection refused (os error 111)' } } }) case 'fixture.error': throw Object.assign(envelope('aimux', 1, 'AI_APICallError', 'rate limit', { statusCode: 429, isRetryable: true, responseHeaders: { 'retry-after': '0' }, data: { unicode: '你好' } }), { retryAfterMs: 0 }) default: return JSON.stringify({ op, params }) } diff --git a/bindings/node/src/error.ts b/bindings/node/src/error.ts index d00aa3f6b..a696ad8d3 100644 --- a/bindings/node/src/error.ts +++ b/bindings/node/src/error.ts @@ -9,12 +9,17 @@ export interface ErrorEnvelope { retryAfterMs?: number } -/** The exact shared ErrorEnvelope, including the AI SDK payload in `error`. */ +/** + * The exact shared ErrorEnvelope, including the AI SDK payload in `error`. + * The payload's `cause` (a nested AI SDK error, or an underlying OS, TLS or + * parser error as `{ message, cause? }`) is also the standard `Error.cause`. + */ export class AimuxError extends Error implements ErrorEnvelope { readonly kind: ErrorDomain readonly code: number readonly error: ErrorEnvelope['error'] declare readonly retryAfterMs?: number + declare readonly cause?: unknown constructor(envelope: ErrorEnvelope) { super(envelope.message) @@ -23,6 +28,14 @@ export class AimuxError extends Error implements ErrorEnvelope { this.code = envelope.code this.error = envelope.error if (envelope.retryAfterMs !== undefined) this.retryAfterMs = envelope.retryAfterMs + attachCause(this) + } +} + +// Like `new Error(message, { cause })`: an own, non-enumerable property. +function attachCause(error: AimuxError): void { + if ('cause' in error.error) { + Object.defineProperty(error, 'cause', { value: error.error.cause, writable: true, configurable: true }) } } @@ -30,6 +43,7 @@ export function rethrow(error: unknown): never { if (isEnvelope(error)) { // Preserve the original native error's stack and all payload fields. Object.setPrototypeOf(error, AimuxError.prototype) + attachCause(error as AimuxError) } throw error } diff --git a/bindings/node/src/types.ts b/bindings/node/src/types.ts index 34750275b..ab2c0b123 100644 --- a/bindings/node/src/types.ts +++ b/bindings/node/src/types.ts @@ -44,6 +44,7 @@ export type * from './types/EmbeddingCallOptions' export type * from './types/EmbeddingResponse' export type * from './types/EmbeddingResult' export type * from './types/EmbeddingUsage' +export type * from './types/ErrorCause' export type * from './types/FileBytes' export type * from './types/FileData' export type * from './types/FilePart' @@ -142,6 +143,7 @@ export type * from './types/SessionView' export type * from './types/Size' export type * from './types/SkillFile' export type * from './types/Source' +export type * from './types/SourceError' export type * from './types/SpeechCallOptions' export type * from './types/SpeechRequest' export type * from './types/SpeechResponse' diff --git a/bindings/node/src/types/AiMuxError.ts b/bindings/node/src/types/AiMuxError.ts index 0b75e4fd9..132525818 100644 --- a/bindings/node/src/types/AiMuxError.ts +++ b/bindings/node/src/types/AiMuxError.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ApiCallError } from "./ApiCallError"; +import type { ErrorCause } from "./ErrorCause"; import type { RetryError } from "./RetryError"; import type { ToolChoiceViolationError } from "./ToolChoiceViolationError"; @@ -9,9 +10,13 @@ import type { ToolChoiceViolationError } from "./ToolChoiceViolationError"; * Variants are cut by *what the caller does about it*, not by where the * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. + * + * A variant has a `cause` exactly where its AI SDK class takes one; it is + * what `source()` returns. `Retry` reports its + * last attempt and `ToolCallRepair` its `cause` the same way. */ -export type AiMuxError = { "name": "AI_APICallError" } & ApiCallError | { "name": "AI_RetryError" } & RetryError | { "name": "AI_JSONParseError", message: string, } | { "name": "AI_NoOutputGeneratedError", message: string, } | { "name": "AI_InvalidResponseDataError", message: string, } | { "name": "AI_ToolCallNotFoundForApprovalError", toolCallId: string, approvalId: string, } | { "name": "AI_NoSuchToolError", toolName: string, availableTools?: Array, toolInput?: string, } | { "name": "AI_InvalidToolInputError", toolName: string, toolInput: string, cause: string, } | { "name": "AI_ToolCallRepairError", originalError: AiMuxError, cause: AiMuxError, } | { "name": "AI_InvalidArgumentError", message: string, } | { "name": "AI_InvalidPromptError", message: string, } | { "name": "AI_LoadAPIKeyError", envVar: string, description: string, } | { "name": "AI_LoadSettingError", envVar: string, +export type AiMuxError = { "name": "AI_APICallError" } & ApiCallError | { "name": "AI_RetryError" } & RetryError | { "name": "AI_JSONParseError", message: string, cause?: ErrorCause, } | { "name": "AI_NoOutputGeneratedError", message: string, cause?: ErrorCause, } | { "name": "AI_InvalidResponseDataError", message: string, cause?: ErrorCause, } | { "name": "AI_ToolCallNotFoundForApprovalError", toolCallId: string, approvalId: string, } | { "name": "AI_NoSuchToolError", toolName: string, availableTools?: Array, toolInput?: string, } | { "name": "AI_InvalidToolInputError", toolName: string, toolInput: string, cause: ErrorCause, } | { "name": "AI_ToolCallRepairError", originalError: AiMuxError, cause: AiMuxError, } | { "name": "AI_InvalidArgumentError", message: string, cause?: ErrorCause, } | { "name": "AI_InvalidPromptError", message: string, cause?: ErrorCause, } | { "name": "AI_LoadAPIKeyError", envVar: string, description: string, } | { "name": "AI_LoadSettingError", envVar: string, /** * `name` is the union's tag key, so the setting's name is `settingName`. */ -settingName: string, } | { "name": "AI_TokenExpiredError", message: string, } | { "name": "AI_UnsupportedFunctionalityError", message: string, } | { "name": "AI_NoSuchModelError", modelId: string, modelType: string, } | { "name": "AI_NoSuchProviderError", providerId: string, modelId: string, modelType: string, availableProviders: Array, } | { "name": "AI_MissingToolResultsError", toolCallIds: Array, } | { "name": "AI_ToolChoiceViolationError" } & ToolChoiceViolationError | { "name": "TimeoutError", message: string, } | { "name": "AbortError", message: string, } | { "name": "AI_Error", message: string, }; +settingName: string, } | { "name": "AI_TokenExpiredError", message: string, } | { "name": "AI_UnsupportedFunctionalityError", message: string, } | { "name": "AI_NoSuchModelError", modelId: string, modelType: string, } | { "name": "AI_NoSuchProviderError", providerId: string, modelId: string, modelType: string, availableProviders: Array, } | { "name": "AI_MissingToolResultsError", toolCallIds: Array, } | { "name": "AI_ToolChoiceViolationError" } & ToolChoiceViolationError | { "name": "TimeoutError", message: string, } | { "name": "AbortError", message: string, } | { "name": "AI_Error", message: string, cause?: ErrorCause, }; diff --git a/bindings/node/src/types/ApiCallError.ts b/bindings/node/src/types/ApiCallError.ts index 8b617eff1..668569983 100644 --- a/bindings/node/src/types/ApiCallError.ts +++ b/bindings/node/src/types/ApiCallError.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ErrorCause } from "./ErrorCause"; import type { JsonValue } from "./serde_json/JsonValue"; /** @@ -23,15 +24,15 @@ import type { JsonValue } from "./serde_json/JsonValue"; * This keeps transport and response failures self-contained and matches the * AI SDK's `APICallError` contract. */ -export type ApiCallError = { +export type ApiCallError = { /** * Sanitized request URL. Required for every API-derived failure. */ -url: string, +url: string, /** * Sanitized request values used to create the API request. */ -requestBodyValues: JsonValue, +requestBodyValues: JsonValue, /** * HTTP status of the response, when it came from one * (`APICallError.statusCode`). The HTTP layer fills the observed status @@ -41,37 +42,43 @@ requestBodyValues: JsonValue, * Anthropic from its error type); errors built without an HTTP exchange * leave it `None`. */ -statusCode?: number, +statusCode?: number, /** * Provider's machine-readable code (OpenAI's `code`/`type`, * e.g. `"rate_limit_exceeded"` vs `"insufficient_quota"`). Never an HTTP * status. Our normalized take on `APICallError.data`. */ -providerCode?: string, +providerCode?: string, /** * Human-readable failure text. Provider text is verbatim when available; * locally detected transport/parse failures include their source detail. * Never includes an HTTP status prefix. */ -message: string, +message: string, /** * The raw response body, verbatim (`APICallError.responseBody`) — the * lossless evidence when `message`/`provider_code` are extractions. * `None` when the error did not come from a response body. */ -responseBody?: string, +responseBody?: string, /** * Sanitized response headers. */ -responseHeaders?: { [key in string]: string }, +responseHeaders?: { [key in string]: string }, /** * Parsed provider error data. */ -data?: JsonValue, +data?: JsonValue, /** * Whether retrying can help (`APICallError.isRetryable`) — stored at * construction, exactly like the AI SDK: the response path computes it * from the status (408/409/429 or 5xx), and the transport path (no response, so * no status to compute from) sets it to `true` explicitly. */ -isRetryable: boolean, }; +isRetryable: boolean, +/** + * The error this failure was built from (`APICallError.cause`): the + * transport error with its source chain, the body parse error, or the + * aimux error a response handler returned. + */ +cause?: ErrorCause, }; diff --git a/bindings/node/src/types/ErrorCause.ts b/bindings/node/src/types/ErrorCause.ts new file mode 100644 index 000000000..8352decda --- /dev/null +++ b/bindings/node/src/types/ErrorCause.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AiMuxError } from "./AiMuxError"; +import type { SourceError } from "./SourceError"; + +/** + * The error another error was built from: the AI SDK's `cause`. + * + * An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an + * `APICallError`; any other error is a [`SourceError`]. The JSON form is the + * nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. + */ +export type ErrorCause = AiMuxError | SourceError; diff --git a/bindings/node/src/types/SourceError.ts b/bindings/node/src/types/SourceError.ts new file mode 100644 index 000000000..d19de3c04 --- /dev/null +++ b/bindings/node/src/types/SourceError.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * An error from outside aimux (a transport, parser or I/O error), kept as + * its message and the messages of its own sources, so it can be cloned, + * serialized and sent across the bindings with the error built from it. + * + * The JSON form is a JavaScript `Error`'s own `{ message, cause }`. + */ +export type SourceError = { +/** + * The error's `Display` text. + */ +message: string, +/** + * The error's own `source()`, recursively. + */ +cause?: SourceError, }; diff --git a/bindings/python/README.md b/bindings/python/README.md index aae234bef..ab2653eb0 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -70,7 +70,9 @@ PyO3 0.22 limits subinterpreter imports; this package does not extend support. `AimuxError` subclasses preserve the shared `envelope`, `error`, `kind`, `code` and optional `retry_after_ms`. Payload attributes use snake_case. Retry `errors` retain typed attempts in order; `last_error` is the last attempt. -Typed causes also appear as `__cause__`. Recording, binding and protocol errors +Every `cause` also appears as `__cause__`: an aimux cause as its typed +subclass, an underlying OS, TLS or parser error as `SourceError`, each with its +own `__cause__` down the chain. Recording, binding and protocol errors have separate bases. Python never recomputes retry hints or performs retries. `ABI_VERSION`, `OPS_VERSION`, `manifest` and `manifest_digest()` are public. diff --git a/bindings/python/python/aimux/_errors.py b/bindings/python/python/aimux/_errors.py index 13574f212..2c7eb2ef0 100644 --- a/bindings/python/python/aimux/_errors.py +++ b/bindings/python/python/aimux/_errors.py @@ -21,6 +21,10 @@ class ProtocolError(Exception): """Failure in the protocol domain.""" +class SourceError(Exception): + """An underlying error that is not an aimux error (OS, TLS, JSON parser).""" + + class APICallError(AimuxError): pass class RetryError(AimuxError): pass class JSONParseError(AimuxError): pass @@ -80,7 +84,12 @@ def _snake(name): def _payload_exception(payload, domain="aimux", message=None): - cls = _AIMUX_TYPES.get(payload.get("name"), AimuxError) if domain == "aimux" else _DOMAINS[domain] + if domain != "aimux": + cls = _DOMAINS[domain] + elif "name" in payload: + cls = _AIMUX_TYPES.get(payload["name"], AimuxError) + else: + cls = SourceError error = cls(message if message is not None else payload.get("message", payload.get("name", ""))) error.error = payload for field, value in payload.items(): @@ -107,5 +116,5 @@ def from_envelope(envelope): __all__ = [cls.__name__ for cls in _AIMUX_TYPES.values()] + [ - "AimuxError", "RecordingError", "BindingError", "ProtocolError", + "AimuxError", "RecordingError", "BindingError", "ProtocolError", "SourceError", ] diff --git a/bindings/python/tests/test_ops_errors.py b/bindings/python/tests/test_ops_errors.py index 881e95a8f..e8c45b673 100644 --- a/bindings/python/tests/test_ops_errors.py +++ b/bindings/python/tests/test_ops_errors.py @@ -68,10 +68,14 @@ def test_repair_preserves_cause_and_original(self): self.assertIs(error.__cause__, error.cause) self.assertIs(error.error['originalError'], API) - def test_string_cause_is_preserved(self): - error = e.from_envelope(envelope(dict(name='AI_InvalidToolInputError', cause='invalid input'))) - self.assertEqual(error.cause, 'invalid input') - self.assertIsNone(error.__cause__) + def test_transport_cause_chain_becomes_source_errors(self): + os_error = dict(message='Connection refused (os error 111)') + cause = dict(message='error sending request', cause=dict(message='tcp connect error', cause=os_error)) + error = e.from_envelope(envelope(dict(API, cause=cause))) + self.assertIsInstance(error.__cause__, e.SourceError) + self.assertEqual(str(error.__cause__), 'error sending request') + self.assertEqual(str(error.__cause__.__cause__.__cause__), 'Connection refused (os error 111)') + self.assertNotIsInstance(error.__cause__, e.AimuxError) def test_domains_are_separate(self): for domain, cls in [('recording', e.RecordingError), ('binding', e.BindingError), ('protocol', e.ProtocolError)]: diff --git a/bindings/swift/README.md b/bindings/swift/README.md index 0934fce86..95e473e8a 100644 --- a/bindings/swift/README.md +++ b/bindings/swift/README.md @@ -44,6 +44,8 @@ queue holds at most 256 frames / 8 MiB; each serialized frame is at most 1 MiB. `StreamLimits` allows smaller budgets. Full queues block native callbacks. Terminals have a separate slot; errors throw once after buffered events. `AimuxError` retains domain, code, message, retry hint and unknown JSON fields. +`cause` is the payload's cause: a nested AI SDK error, or an OS, TLS or parser +error as `{message, cause?}`. Task cancellation signals Abort. Exiting a loop releases its temporary iterator and cancels production even while the sequence remains alive. Stored iterators diff --git a/bindings/swift/Sources/Aimux/Aimux.swift b/bindings/swift/Sources/Aimux/Aimux.swift index 868182574..2c2c43378 100644 --- a/bindings/swift/Sources/Aimux/Aimux.swift +++ b/bindings/swift/Sources/Aimux/Aimux.swift @@ -9,6 +9,9 @@ public struct AimuxError: Error, Codable, Equatable, Sendable, LocalizedError { public let error: JSONValue public let retryAfterMs: UInt64? public var errorDescription: String? { message } + /// The payload's cause: a nested AI SDK error (with `name`) or an + /// underlying OS, TLS or parser error as `{message, cause?}`. + public var cause: JSONValue? { error["cause"] } static func binding(_ message: String, code: Int32 = 206) -> AimuxError { AimuxError(kind: "binding", code: code, message: message, diff --git a/bindings/swift/Tests/AimuxTests/WireTests.swift b/bindings/swift/Tests/AimuxTests/WireTests.swift index 72ad68af5..44caff5c4 100644 --- a/bindings/swift/Tests/AimuxTests/WireTests.swift +++ b/bindings/swift/Tests/AimuxTests/WireTests.swift @@ -18,6 +18,19 @@ struct WireTests { } } +#if !AIMUX_STANDALONE + @Test +#endif + static func testErrorCauseIsThePayloadCause() throws { + let error = try JSONDecoder().decode(AimuxError.self, from: Data(#""" + {"kind":"aimux","code":11,"message":"API call error: Cannot connect","error":{"name":"AI_APICallError", + "message":"Cannot connect","cause":{"message":"tcp connect error","cause":{"message":"Connection refused"}}}} + """#.utf8)) + try require(error.cause?["message"] == .string("tcp connect error")) + try require(error.cause?["cause"]?["message"] == .string("Connection refused")) + try require(AimuxError.binding("x").cause == nil) + } + #if !AIMUX_STANDALONE @Test #endif diff --git a/bindings/swift/Tests/Standalone/Runner.swift b/bindings/swift/Tests/Standalone/Runner.swift index 084ce4d09..e7db7199d 100644 --- a/bindings/swift/Tests/Standalone/Runner.swift +++ b/bindings/swift/Tests/Standalone/Runner.swift @@ -27,6 +27,7 @@ import Foundation ("operationPrimitives", { try await ContractTests.operationPrimitives() }), ("cancelBlockedPushAndPoll", { try await ContractTests.cancelBlockedPushAndPoll() }), ("handleResults", { try WireTests.handleResults() }), + ("testErrorCauseIsThePayloadCause", { try WireTests.testErrorCauseIsThePayloadCause() }), ("wireValues", { try ContractTests.wireValues() }), ("testToolCallProviderMetadataRoundTrip", { try WireTests.testToolCallProviderMetadataRoundTrip() }), ("testUnknownFieldsKeepNullApartFromMissing", { try WireTests.testUnknownFieldsKeepNullApartFromMissing() }), diff --git a/contract-tests/ops/errors/aimux-api-call.json b/contract-tests/ops/errors/aimux-api-call.json index 51ab4ee4d..b480a41e9 100644 --- a/contract-tests/ops/errors/aimux-api-call.json +++ b/contract-tests/ops/errors/aimux-api-call.json @@ -16,7 +16,16 @@ "retry-after-ms": "1500" }, "data": null, - "isRetryable": true + "isRetryable": true, + "cause": { + "message": "error sending request", + "cause": { + "message": "tcp connect error", + "cause": { + "message": "Connection refused (os error 111)" + } + } + } }, "retryAfterMs": 1500 } diff --git a/contract-tests/ops/errors/aimux-invalid-response-data.json b/contract-tests/ops/errors/aimux-invalid-response-data.json index f424cd1a5..14b2f88e1 100644 --- a/contract-tests/ops/errors/aimux-invalid-response-data.json +++ b/contract-tests/ops/errors/aimux-invalid-response-data.json @@ -4,6 +4,10 @@ "message": "invalid response data: eof", "error": { "name": "AI_InvalidResponseDataError", - "message": "eof" + "message": "eof", + "cause": { + "name": "AI_JSONParseError", + "message": "Unexpected end of JSON input" + } } } diff --git a/contract-tests/ops/errors/aimux-invalid-tool-input.json b/contract-tests/ops/errors/aimux-invalid-tool-input.json index abebbc314..48025b6f3 100644 --- a/contract-tests/ops/errors/aimux-invalid-tool-input.json +++ b/contract-tests/ops/errors/aimux-invalid-tool-input.json @@ -6,6 +6,9 @@ "name": "AI_InvalidToolInputError", "toolName": "weather", "toolInput": "{", - "cause": "JSON parsing failed" + "cause": { + "name": "AI_JSONParseError", + "message": "JSON parsing failed" + } } } diff --git a/docs/ai-sdk-request-pipeline.md b/docs/ai-sdk-request-pipeline.md index 1ffdc3f64..74aea6ea6 100644 --- a/docs/ai-sdk-request-pipeline.md +++ b/docs/ai-sdk-request-pipeline.md @@ -82,7 +82,7 @@ Core user operation | crate 依赖方向 | retry primitive 和 Core wrapper 都放进 `aimux-core::retry`,避免 `aimux-core` 反向依赖 `aimux-provider-utils`;函数边界和算法不变 | | Rust 错误类型 | AI SDK `RetryError.errors: unknown[]` 对应 `Vec`;这是同一错误历史的强类型表达 | | enum 尺寸 | `AiMuxError::ApiCall(Box)`;`Box` 只解决 Rust enum 尺寸,不改变序列化或 binding 语义 | -| `cause` | 本轮不加入;AI SDK 用 `cause` 保存的底层错误追加到 message(`{upstream message}: {source}`),或保存在 data/response body,不得静默丢失 | +| `cause` | 与 AI SDK 一致:凡 AI SDK 构造时接受 `cause` 的错误都带可选 `cause`(`InvalidToolInput`、`ToolCallRepair` 必有),不接受的保持没有;Rust 里它就是 `Error::source()`,`Retry` 的 source 是最后一次尝试(`lastError`)。差异:非 aimux 的底层错误(OS、TLS、解析器)存成可克隆、可序列化的 `{message, cause?}` 链;message 仍保留原来拼接的底层错误文字,因为 C ABI 的 message getter 只看 message;传输错误先去掉 URL(query 可能带 key);ops 的 binding/protocol/recording 域错误不带 `cause`,它们的原因只有一层,全文已在 message 和 payload 里 | | Gateway | 不增加 `GatewayError`;aimux 没有独立 AI Gateway 错误体系 | | 既有错误字段 | 保留 Aimux 的 `provider_code`;删除旧路径派生的 `request_id` / `retry_after_ms`,retry hint 的唯一事实源是 `response_headers` | | error context 安全 | AI SDK 传原始 request context;Aimux 写入 public error 前使用统一白名单/脱敏/大小限制,binary 与大型 data URL 只保留摘要 | @@ -225,8 +225,8 @@ non-2xx → failed_response_handler 2xx 解析失败由 successful handler 产生 `ApiCallError`,不得再调用 failed handler。 handler 返回的 ApiCall/Timeout/Aborted 原样透传;其他 handler failure 按 AI SDK 的 -`Failed to process successful/error response` 规则包装。由于本轮没有 `cause`,原始信息 -必须保存在 message、data 或受限长度的 response body 中。 +`Failed to process successful/error response` 规则包装,原错误作为 `cause` 保留,其文字 +同时留在 message 中。 标准 handler factory 直接移植 AI SDK `response-handler.ts` 的同名行为。Provider-specific 协议使用 custom `ResponseHandler`;例如 Bedrock binary event-stream 不是 SSE,不得套 @@ -1041,7 +1041,7 @@ handler 负责分帧和解析,peek 与"错误就 `Err` 返回"仍是 provider ## 14. 不做与拒绝方案 -本 RFC 不做:`cause`、`GatewayError`、mid-stream reconnect、stream 返回后的任何 retry、 +本 RFC 不做:`GatewayError`、mid-stream reconnect、stream 返回后的任何 retry、 circuit breaker、tool execution timeout、per-child Router retry policy。 拒绝: diff --git a/docs/error-model.md b/docs/error-model.md index 6a49bdbc7..5f24d6be6 100644 --- a/docs/error-model.md +++ b/docs/error-model.md @@ -25,6 +25,25 @@ 结构化字段只放在真正拥有它的错误上。例如,HTTP 状态和响应头属于 `APICallError`,不放进通用基类(retry hint 与 request id 从 `response_headers` 读取)。 +## 错误原因(cause) + +AI SDK 构造时接受 `cause` 的错误,在 aimux 里都有可选的 `cause`;它就是 Rust +的 `Error::source()`,序列化后在错误 payload 的 `cause` 字段。`cause` 有两种: +带 `name` 的嵌套 aimux 错误,或者没有 `name` 的底层错误 `{message, cause?}` +(OS、TLS、JSON 解析器等),后者的 `cause` 继续往下一层。 + +| 语言 | 原生入口 | +|---|---| +| Rust | `source()` 链,`anyhow` / `{:#}` 可直接打印 | +| Node.js | 标准 `error.cause` | +| Python | `__cause__`:嵌套错误是对应子类,底层错误是 `SourceError` | +| Java / Kotlin | `getCause()` 链上的 `ErrorCause` | +| Go | `Unwrap()` 返回 `*aimux.Cause`,`errors.Unwrap` / `errors.As` 可用 | +| Swift / Flutter | `cause` 属性 | +| C / C++ | 错误 JSON 的 `error.cause`;C++ 的 `aimux::NativeError::json()` | + +message 仍包含原来的底层错误文字,所以只看 message 的调用方不会少信息。 + ## 兼容性约定 1. 已有错误名称和错误码不改名、不改号、不复用。 diff --git a/tools/aimux-web/src/api/cache_probe.rs b/tools/aimux-web/src/api/cache_probe.rs index ad14a60ac..c791934ff 100644 --- a/tools/aimux-web/src/api/cache_probe.rs +++ b/tools/aimux-web/src/api/cache_probe.rs @@ -58,9 +58,7 @@ pub async fn run(State(state): State, Json(req): Json) - async fn run_inner(state: AppState, req: ProbeRequest) -> Result { if req.max_requests == 0 { - return Err(AiMuxError::InvalidArgument( - "max_requests must be >= 1".into(), - )); + return Err(AiMuxError::invalid_argument("max_requests must be >= 1")); } // Dry-run must not require an API key — it only reports the plan. diff --git a/tools/aimux-web/src/api/calls.rs b/tools/aimux-web/src/api/calls.rs index 4cfb55c23..1fa969029 100644 --- a/tools/aimux-web/src/api/calls.rs +++ b/tools/aimux-web/src/api/calls.rs @@ -50,7 +50,7 @@ async fn run(state: AppState, req: WireCallRequest) -> Result &'static str { /// Render an `AiMuxError` as a JSON error response with a sensible status. pub fn err_response(e: AiMuxError) -> Response { let status = match &e { - AiMuxError::InvalidArgument(_) - | AiMuxError::InvalidPrompt(_) + AiMuxError::InvalidArgument { .. } + | AiMuxError::InvalidPrompt { .. } | AiMuxError::LoadApiKey { .. } | AiMuxError::LoadSetting { .. } | AiMuxError::NoSuchProvider { .. } diff --git a/tools/aimux-web/src/api/replay.rs b/tools/aimux-web/src/api/replay.rs index 81c23555b..f7330b5db 100644 --- a/tools/aimux-web/src/api/replay.rs +++ b/tools/aimux-web/src/api/replay.rs @@ -59,7 +59,7 @@ pub async fn run(State(state): State, Json(req): Json) async fn run_inner(state: AppState, req: ReplayRequest) -> Result { let recording = state.recording(&req.call_id).ok_or_else(|| { - AiMuxError::InvalidArgument(format!("recording '{}' not found", req.call_id)) + AiMuxError::invalid_argument(format!("recording '{}' not found", req.call_id)) })?; // Resolve the key for provider rebuild: the request spec first, then the @@ -104,8 +104,8 @@ async fn run_inner(state: AppState, req: ReplayRequest) -> Result Result Response { match agents::execute(&name, &input) { Ok(result) => Json(json!({ "tool": name, "result": result })).into_response(), - Err(e) => err_response(AiMuxError::InvalidArgument(e)), + Err(e) => err_response(AiMuxError::invalid_argument(e)), } } diff --git a/tools/aimux-web/src/api/traces.rs b/tools/aimux-web/src/api/traces.rs index 53b0107b7..cac624ddd 100644 --- a/tools/aimux-web/src/api/traces.rs +++ b/tools/aimux-web/src/api/traces.rs @@ -86,9 +86,9 @@ pub async fn import_jsonl(State(state): State, body: String) -> Respon Err(e) => { let msg = format!("import line {}: {e}", idx + 1); return err_response(if e.classify() == serde_json::error::Category::Data { - AiMuxError::InvalidArgument(msg) + AiMuxError::invalid_argument(msg) } else { - AiMuxError::JsonParse(msg) + AiMuxError::json_parse(msg) }); } } diff --git a/tools/aimux-web/src/wire.rs b/tools/aimux-web/src/wire.rs index d8f58a29b..1c505ae8b 100644 --- a/tools/aimux-web/src/wire.rs +++ b/tools/aimux-web/src/wire.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use ts_rs::TS; +use aimux_core::ErrorCause; use aimux_core::content::{ AssistantContentPart, ToolContentPart, ToolResultOutput, ToolResultPart, UserContentPart, }; @@ -239,7 +240,8 @@ fn to_assistant_part(p: &WireContentPart) -> AssistantContentPart { pub fn to_model_prompt(messages: &[WireMessage]) -> Result { let mut out = Vec::with_capacity(messages.len()); for m in messages { - let invalid = |what: &str| AiMuxError::InvalidPrompt(format!("{} message: {what}", m.role)); + let invalid = + |what: &str| AiMuxError::invalid_prompt(format!("{} message: {what}", m.role)); let message = match m.role.as_str() { "system" => { let [WireContentPart::Text { text }] = m.content.as_slice() else { @@ -280,7 +282,7 @@ pub fn to_model_prompt(messages: &[WireMessage]) -> Result>()?, ), other => { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "unknown message role '{other}' (expected system/user/assistant/tool)" ))); } @@ -329,9 +331,10 @@ pub fn to_generate_options( description, }) => Some(Output::Json { name, description }), Err(e) => { - return Err(AiMuxError::InvalidArgument(format!( - "invalid responseFormat: {e}" - ))); + return Err(AiMuxError::InvalidArgument { + message: format!("invalid responseFormat: {e}"), + cause: Some(ErrorCause::source(&e)), + }); } }, }; @@ -383,7 +386,7 @@ pub fn resolve_api_key( custom_base_url: bool, ) -> Result, AiMuxError> { if custom_base_url && crate::model_builder::reads_ambient_credentials(provider) { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "provider '{provider}' reads credentials from the server environment, so the \ console does not call it at a custom base_url — leave base_url empty" ))); @@ -393,18 +396,16 @@ pub fn resolve_api_key( return Ok(Some(String::new())); } if custom_base_url && spec.is_some_and(|s| s.starts_with("env:")) { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "env: key references are not sent to a custom base_url — pass that endpoint's \ - own key in the request (loopback binds only) or leave api_key empty" - .into(), + own key in the request (loopback binds only) or leave api_key empty", )); } if custom_base_url && !allow_plaintext { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "a custom base_url only takes a key carried in the request, and plaintext keys \ are accepted only while the console is bound to a loopback address — leave \ - api_key empty to call that endpoint without a key" - .into(), + api_key empty to call that endpoint without a key", )); } match spec { @@ -415,8 +416,8 @@ pub fn resolve_api_key( Some(s) if s.starts_with("env:") => { let var = &s[4..]; if var.is_empty() { - return Err(AiMuxError::InvalidArgument( - "empty env: reference (use env:VAR_NAME)".into(), + return Err(AiMuxError::invalid_argument( + "empty env: reference (use env:VAR_NAME)", )); } // Only the provider's own key variable: any other name would let a @@ -425,13 +426,13 @@ pub fn resolve_api_key( match registered_key_env(provider) { Some(allowed) if allowed == var => {} Some(allowed) => { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "env:{var} is not allowed for provider '{provider}' — only its \ registered key variable can be referenced (env:{allowed})" ))); } None => { - return Err(AiMuxError::InvalidArgument(format!( + return Err(AiMuxError::invalid_argument(format!( "provider '{provider}' has no registered key variable, so env: \ references are not accepted for it" ))); @@ -439,17 +440,16 @@ pub fn resolve_api_key( } match std::env::var(var) { Ok(v) => Ok(Some(v)), - Err(_) => Err(AiMuxError::InvalidArgument(format!( + Err(_) => Err(AiMuxError::invalid_argument(format!( "environment variable `{var}` is not set" ))), } } Some(s) if allow_plaintext => Ok(Some(s.to_string())), - Some(_) => Err(AiMuxError::InvalidArgument( + Some(_) => Err(AiMuxError::invalid_argument( "plaintext API keys are only accepted while the console is bound to a loopback \ address — use env:VAR_NAME, save the key in Settings, or leave empty (the \ - provider's registered env var is read automatically)" - .into(), + provider's registered env var is read automatically)", )), } }