diff --git a/CHANGELOG.md b/CHANGELOG.md index 098f39e66..c0864efbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking -- `ApiCallError` keeps its cause, as the AI SDK's `APICallError.cause`: the - transport error with its whole source chain down to the OS error, the body - parse error, or the error a response handler returned (`.caused_by(..)`); - `invalid_response_api_call` takes that error instead of a message, its text - becoming the message. A cause is a nested `AiMuxError` or a `SourceError` chain of an OS, TLS or - parser error, and is what `source()` returns; `RetryError::source()` is the - last attempt. `FetchError` carries a `SourceError` without the request URL, - so transport error messages lose it too: `error sending request for url +- Errors keep their cause, as the AI SDK's `cause`. `ApiCallError` has + `cause` (`.caused_by(..)`): the transport error with its whole source chain + down to the OS error, the body parse error, or the error a response handler + returned; `invalid_response_api_call` takes that error instead of a + message, its text becoming the message. `JsonParse`, `NoOutputGenerated`, `InvalidResponseData`, + `InvalidArgument`, `InvalidPrompt` and `Other` are `{ message, cause }` + (build them with `AiMuxError::json_parse(..)`, `invalid_argument(..)`, ...), + and `InvalidToolInput.cause` is the parse or validation error itself instead + of its text. A value that fails its schema is the new `TypeValidation` + error (`AI_TypeValidationError`, with the value), the cause of an + `InvalidToolInput` or of a structured output that does not match. A cause + is a nested `AiMuxError` or a `SourceError` chain of an OS, TLS or parser + error, and is what `source()` returns; `RetryError::source()` is the last + attempt. `FetchError` and download errors carry a `SourceError` without the + request URL, so their messages lose it too: `error sending request for url (…)` is now `error sending request`. Other messages are unchanged. - aimux's public JSON equals the AI SDK's JSON. Struct fields serialize diff --git a/aimux-core/src/decision_model.rs b/aimux-core/src/decision_model.rs index 914824baa..7a4bcb13c 100644 --- a/aimux-core/src/decision_model.rs +++ b/aimux-core/src/decision_model.rs @@ -382,7 +382,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}" )) } @@ -429,7 +429,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..e8e14dc04 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)) @@ -261,7 +262,7 @@ async fn download_inner( .redirect(reqwest::redirect::Policy::none()) .resolve_to_addrs(host, &addresses) .build() - .map_err(download_error)?; + .map_err(transport_error)?; let response = client .get(parsed.clone()) .header( @@ -270,13 +271,13 @@ async fn download_inner( ) .send() .await - .map_err(download_error)?; + .map_err(transport_error)?; let status = response.status(); if matches!(status.as_u16(), 301 | 302 | 303 | 307 | 308) && 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}" ))); } @@ -302,7 +303,7 @@ async fn download_inner( let mut stream = response.bytes_stream(); let mut data = Vec::new(); while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(download_error)?; + let chunk = chunk.map_err(transport_error)?; if chunk.len() > max_bytes.saturating_sub(data.len()) { return Err(size_error(max_bytes)); } @@ -313,12 +314,21 @@ 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}")) +/// A reqwest failure without its URL: a signed download URL carries its +/// credentials in the query. +fn transport_error(error: reqwest::Error) -> AiMuxError { + download_error(error.without_url()) +} + +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" )) } @@ -327,6 +337,28 @@ fn size_error(max_bytes: usize) -> AiMuxError { mod tests { use super::*; + #[tokio::test] + async fn a_transport_failure_keeps_the_signed_query_out_of_message_and_cause() { + let error = reqwest::Client::new() + .get("http://127.0.0.1:1/file.png?X-Amz-Signature=SECRET") + .send() + .await + .unwrap_err(); + assert!(error.to_string().contains("SECRET"), "{error}"); + + let error = transport_error(error); + let AiMuxError::Other { message, cause } = &error else { + panic!("{error:?}"); + }; + assert!(message.starts_with("download failed: "), "{message}"); + assert!(cause.is_some()); + let json = serde_json::to_string(&error).unwrap(); + assert!( + !json.contains("SECRET") && !json.contains("127.0.0.1"), + "{json}" + ); + } + use std::sync::Mutex; fn recording_download(requests: Arc>>) -> Download { @@ -392,7 +424,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 +439,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 76bc84e9d..6d58b4c37 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -180,7 +180,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 @@ -388,8 +388,9 @@ impl std::error::Error for ErrorCause { /// failure came from. `ApiCallError` is boxed only to keep the Rust enum /// compact; serde and every binding still observe the same object shape. /// -/// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a -/// retry's last attempt and a repair's `cause`. +/// 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, JsonSchema, Error)] #[serde(from = "AiMuxErrorWire", into = "AiMuxErrorWire")] pub enum AiMuxError { @@ -399,14 +400,41 @@ pub enum AiMuxError { #[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, + }, + + /// A value that does not match the caller's schema: the AI SDK's + /// `TypeValidationError`, as the cause of an [`InvalidToolInput`] or of + /// structured output that does not match. + /// + /// [`InvalidToolInput`]: Self::InvalidToolInput + #[error("{message}")] + TypeValidation { + message: String, + value: Box, + #[source] + cause: Option, + }, #[error("Tool call \"{tool_call_id}\" not found for approval request \"{approval_id}\".")] ToolCallNotFoundForApproval { @@ -431,11 +459,14 @@ pub enum AiMuxError { }, /// 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. @@ -446,11 +477,19 @@ pub enum AiMuxError { 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. @@ -537,8 +576,85 @@ 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::TypeValidation { 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 @@ -552,11 +668,30 @@ pub enum AiMuxErrorWire { #[serde(rename = "AI_RetryError")] Retry(RetryError), #[serde(rename = "AI_JSONParseError")] - JsonParse { message: String }, + JsonParse { + message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, #[serde(rename = "AI_NoOutputGeneratedError")] - NoOutputGenerated { message: String }, + NoOutputGenerated { + message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, #[serde(rename = "AI_InvalidResponseDataError")] - InvalidResponseData { message: String }, + InvalidResponseData { + message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, + #[serde(rename = "AI_TypeValidationError")] + TypeValidation { + message: String, + value: Box, + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, #[serde(rename = "AI_ToolCallNotFoundForApprovalError")] ToolCallNotFoundForApproval { tool_call_id: String, @@ -574,7 +709,7 @@ pub enum AiMuxErrorWire { InvalidToolInput { tool_name: String, tool_input: String, - cause: String, + cause: ErrorCause, }, #[serde(rename = "AI_ToolCallRepairError")] ToolCallRepair { @@ -582,9 +717,17 @@ pub enum AiMuxErrorWire { cause: Box, }, #[serde(rename = "AI_InvalidArgumentError")] - InvalidArgument { message: String }, + InvalidArgument { + message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, #[serde(rename = "AI_InvalidPromptError")] - InvalidPrompt { message: String }, + InvalidPrompt { + message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, #[serde(rename = "AI_LoadAPIKeyError")] LoadApiKey { env_var: String, @@ -623,7 +766,11 @@ pub enum AiMuxErrorWire { #[serde(rename = "AbortError")] Aborted { message: String }, #[serde(rename = "AI_Error")] - Other { message: String }, + Other { + message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + cause: Option, + }, } /// Maps each `AiMuxError` variant to its `AiMuxErrorWire` twin and back: @@ -658,17 +805,23 @@ 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 }, + TypeValidation { message, value, 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 @@ -790,9 +943,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 } + } } } } @@ -824,7 +981,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. @@ -1006,8 +1166,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 — @@ -1023,7 +1183,7 @@ mod tests { })) .is_retryable() ); - assert!(!AiMuxError::JsonParse("parse".into()).is_retryable()); + assert!(!AiMuxError::json_parse("parse").is_retryable()); } #[test] @@ -1130,25 +1290,27 @@ mod tests { #[test] fn no_cause_stays_off_the_wire() { - let json = serde_json::to_value(AiMuxError::ApiCall(Box::new(api_error("bad")))).unwrap(); - assert!(json.get("cause").is_none(), "{json}"); + 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(std::error::Error::source(&back).unwrap()).is_none()); + 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::JsonParse("Unexpected end of JSON input".into()); - let err = AiMuxError::ApiCall(Box::new( - api_error("Invalid JSON response").caused_by(ErrorCause::source(&inner)), - )); + 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::ApiCall(api) if matches!( - &api.cause, - Some(ErrorCause::AiMux(cause)) if matches!(cause.as_ref(), AiMuxError::JsonParse(_)) - ) + 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"); @@ -1156,15 +1318,25 @@ mod tests { 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".into()), - AiMuxError::Other("second".into()), - ], + errors: vec![AiMuxError::other("first"), AiMuxError::other("second")], }); let source = std::error::Error::source(&err).unwrap(); assert_eq!(source.to_string(), "second"); diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index cae500f50..1ed513031 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -496,9 +496,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()), } } @@ -901,9 +899,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()); @@ -1350,7 +1346,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 ))); @@ -1912,8 +1908,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; } @@ -2312,9 +2308,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 fad552edc..e5b161d87 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 { .. }) )); } @@ -1477,7 +1477,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 ca6c8d3e1..869e8a4b8 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::{ @@ -310,9 +311,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()) @@ -377,7 +377,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}" ))), } @@ -627,8 +627,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", )); } }; @@ -686,7 +686,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" )) })?, @@ -776,8 +776,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()), @@ -949,10 +951,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)])) @@ -1523,8 +1524,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/moa.rs b/aimux-core/src/moa.rs index 06823840c..4376cbeb4 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 44671e36e..2c047589d 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -1388,6 +1388,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; @@ -1508,7 +1509,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, "{}"); @@ -1550,7 +1551,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 @@ -2187,7 +2188,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(), @@ -2208,7 +2209,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 9ca4e1085..e2594bbc2 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 schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; -use crate::error::AiMuxError; +use crate::error::{AiMuxError, ErrorCause, SourceError}; use crate::options::ResponseFormat; /// What the final text of a generation is parsed into. @@ -94,8 +94,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(()) @@ -186,8 +186,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: ErrorCause| AiMuxError::InvalidResponseData { + message: format!("{DID_NOT_MATCH} {cause}"), + cause: Some(cause), + }; match self { Self::Text {} => Ok(Value::String(text.to_owned())), Self::Json { .. } => parse_json(text), @@ -204,26 +206,38 @@ 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( - &value, - "response must be an object with an elements array", - ))); + return Err(schema_mismatch( + type_validation_error( + &value, + "response must be an object with an elements array", + None, + ) + .into(), + )); }; if let Some(min_items) = min_items && elements.len() < *min_items as usize { - return Err(schema_mismatch(type_validation_failed( - &Value::Array(elements.clone()), - &format!("elements array must contain at least {min_items} items"), - ))); + return Err(schema_mismatch( + type_validation_error( + &Value::Array(elements.clone()), + &format!("elements array must contain at least {min_items} items"), + None, + ) + .into(), + )); } if let Some(max_items) = max_items && elements.len() > *max_items as usize { - return Err(schema_mismatch(type_validation_failed( - &Value::Array(elements.clone()), - &format!("elements array must contain at most {max_items} items"), - ))); + return Err(schema_mismatch( + type_validation_error( + &Value::Array(elements.clone()), + &format!("elements array must contain at most {max_items} items"), + None, + ) + .into(), + )); } for item in elements { validate(item, element).map_err(schema_mismatch)?; @@ -236,10 +250,14 @@ impl Output { Some(result) if options.iter().any(|option| option == result) => { Ok(Value::String(result.to_owned())) } - _ => Err(schema_mismatch(type_validation_failed( - &value, - "response must be an object that contains a choice value.", - ))), + _ => Err(schema_mismatch( + type_validation_error( + &value, + "response must be an object that contains a choice value.", + None, + ) + .into(), + )), } } } @@ -247,37 +265,86 @@ 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. `Value` Display is compact +/// JSON, the `JSON.stringify` equivalent. +pub(crate) fn type_validation_error( + value: &Value, + message: &str, + source: Option, +) -> AiMuxError { + AiMuxError::TypeValidation { + message: type_validation_message(value, message), + value: Box::new(value.clone()), + cause: source.map(Into::into), + } +} + +fn type_validation_message(value: &Value, message: &str) -> String { + format!("Type validation failed: Value: {value}.\nError message: {message}") +} + +/// 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<(), ErrorCause> { + // An invalid schema is the caller's error, not the value's, so its cause + // is a plain `SourceError`, as in `parse_tool_call`. + let validator = jsonschema::validator_for(schema).map_err(|error| { + ErrorCause::from(SourceError { + message: type_validation_message(value, &format!("schema is invalid: {error}")), + cause: Some(Box::new(validator_error(&error))), + }) + })?; + validator.validate(value).map_err(|error| { + type_validation_error(value, &error.to_string(), Some(validator_error(&error))).into() + }) } #[cfg(test)] @@ -321,16 +388,48 @@ 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:?}" ); + // `NoObjectGeneratedError.cause` is the `TypeValidationError`. + assert!( + matches!(&error, AiMuxError::InvalidResponseData { + cause: Some(ErrorCause::AiMux(cause)), .. + } if matches!(cause.as_ref(), AiMuxError::TypeValidation { value, .. } + if **value == json!({"age": 3}))), + "{error:?}" + ); + } + + /// An invalid schema is the caller's error: the cause is a plain + /// `SourceError` with the validator's error, not a `TypeValidation` of + /// the model's value. + #[test] + fn an_invalid_schema_is_not_a_type_validation_cause() { + let output = Output::Object { + schema: json!({ "type": 12 }), + name: None, + description: None, + }; + let error = output + .parse_complete_output(r#"{"name":"Ada"}"#) + .unwrap_err(); + let AiMuxError::InvalidResponseData { + message, + cause: Some(ErrorCause::Source(cause)), + } = &error + else { + panic!("{error:?}") + }; + assert!(message.contains("schema is invalid"), "{message}"); + assert!(cause.cause.is_some(), "{cause:?}"); } /// output.ts `array()`: the element schema is wrapped in @@ -385,7 +484,7 @@ mod tests { assert!( matches!( output.parse_complete_output(text), - Err(AiMuxError::InvalidResponseData(_)) + Err(AiMuxError::InvalidResponseData { .. }) ), "{text}" ); @@ -404,7 +503,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" )); } @@ -431,7 +530,7 @@ mod tests { ); assert!(matches!( output.parse_complete_output(r#"{"result":"maybe"}"#), - Err(AiMuxError::InvalidResponseData(_)) + Err(AiMuxError::InvalidResponseData { .. }) )); } @@ -452,7 +551,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 f4b3055a5..895f10cc0 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -14,7 +14,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; 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,26 @@ 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(), + ) + })?; + // The cause is the AI SDK's `TypeValidationError`. + validator.validate(&input).map_err(|error| { + invalid( + type_validation_error(&input, &error.to_string(), Some(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}"), - })?; Ok((input, None)) } @@ -300,25 +305,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 +486,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 +529,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 +565,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 +606,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 +644,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 +678,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 +689,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 +702,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 +717,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 +813,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)] @@ -933,6 +943,81 @@ mod tests { .collect() } + /// `parse-tool-call.ts:259-262`: unparseable input has the + /// `JSONParseError` (with the parser's error) as its cause. + #[tokio::test] + async fn unparseable_input_is_caused_by_the_json_parse_error() { + let tools = weather_tools(); + let call = parse_tool_call(raw_call("call-1", "{"), Some(&tools), None, &[], None).await; + let Some(AiMuxError::InvalidToolInput { cause, .. }) = &call.error else { + panic!("{:?}", call.error); + }; + let ErrorCause::AiMux(parse_error) = cause else { + panic!("{cause:?}"); + }; + let AiMuxError::JsonParse { + message, + cause: Some(ErrorCause::Source(parser_error)), + } = parse_error.as_ref() + else { + panic!("{parse_error:?}"); + }; + assert!( + message.starts_with("JSON parsing failed: Text: {."), + "{message}" + ); + assert!(message.ends_with(&parser_error.message), "{message}"); + assert_eq!( + serde_json::to_value(cause).unwrap()["name"], + "AI_JSONParseError" + ); + } + + /// `validate-types.ts:79`: input of the wrong shape has the + /// `TypeValidationError`, with the value and the validator's error, as + /// its cause. + #[tokio::test] + async fn mismatched_input_is_caused_by_the_type_validation_error() { + let tools = weather_tools(); + let call = parse_tool_call( + raw_call("call-1", r#"{"town":"SG"}"#), + Some(&tools), + None, + &[], + None, + ) + .await; + let Some(AiMuxError::InvalidToolInput { cause, .. }) = &call.error else { + panic!("{:?}", call.error); + }; + let ErrorCause::AiMux(validation) = cause else { + panic!("{cause:?}"); + }; + let AiMuxError::TypeValidation { + message, + value, + cause: Some(ErrorCause::Source(validator_error)), + } = validation.as_ref() + else { + panic!("{validation:?}"); + }; + assert_eq!(**value, serde_json::json!({"town": "SG"})); + assert_eq!( + message, + &format!( + "Type validation failed: Value: {{\"town\":\"SG\"}}.\nError message: {}", + validator_error.message + ) + ); + assert!( + validator_error.message.contains("city"), + "{validator_error:?}" + ); + let json = serde_json::to_value(cause).unwrap(); + assert_eq!(json["name"], "AI_TypeValidationError"); + assert_eq!(json["value"], serde_json::json!({"town": "SG"})); + } + /// The AI SDK parses tool input with `JSON.parse` (`ai` /// `generate-text/parse-tool-call.ts:116`, `:256`; `provider-utils` /// `parse-json.ts:97`, `secure-json-parse.ts:31`), so an integral number diff --git a/aimux-core/src/recording.rs b/aimux-core/src/recording.rs index a1520a4f8..559771ac9 100644 --- a/aimux-core/src/recording.rs +++ b/aimux-core/src/recording.rs @@ -309,7 +309,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", @@ -2107,7 +2107,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"), }) } @@ -2195,16 +2195,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 e5a456dd2..97fe64abc 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 537c813aa..6bfe1502e 100644 --- a/aimux-core/src/transcription_model.rs +++ b/aimux-core/src/transcription_model.rs @@ -47,8 +47,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", ) } } @@ -647,7 +647,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 e5af01e9b..73d51e57f 100644 --- a/aimux-core/src/types.rs +++ b/aimux-core/src/types.rs @@ -324,14 +324,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 40547f2ce..be627fcb6 100644 --- a/aimux-core/src/video_model.rs +++ b/aimux-core/src/video_model.rs @@ -448,7 +448,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(), )); } @@ -1105,7 +1105,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:?}" ); @@ -1155,7 +1155,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..2073e26ee 100644 --- a/aimux-core/tests/error_value_golden_test.rs +++ b/aimux-core/tests/error_value_golden_test.rs @@ -6,7 +6,7 @@ //! cross-language contract: a field rename, a variant rename or a shape change //! breaks every binding, so it must break this test first. -use aimux_core::error::ToolChoiceViolationError; +use aimux_core::error::{SourceError, ToolChoiceViolationError}; use aimux_core::result::GenerateContent; use aimux_core::tool::LanguageModelV4ToolChoice; use aimux_core::types::FinishReasonUnified; @@ -103,13 +103,21 @@ 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"}"#, ), + ( + AiMuxError::TypeValidation { + message: "Type validation failed: Value: {}.\nError message: missing city".into(), + value: Box::new(serde_json::json!({})), + cause: Some(SourceError::from("missing city").into()), + }, + r#"{"name":"AI_TypeValidationError","message":"Type validation failed: Value: {}.\nError message: missing city","value":{},"cause":{"message":"missing city"}}"#, + ), ( AiMuxError::ToolCallNotFoundForApproval { tool_call_id: "call-1".into(), @@ -139,9 +147,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 +158,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 +238,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 +314,27 @@ fn variant_addition_breaks_this_match(error: &AiMuxError) { match error { AiMuxError::ApiCall(_) | AiMuxError::Retry(_) - | AiMuxError::JsonParse(_) - | AiMuxError::InvalidResponseData(_) + | AiMuxError::JsonParse { .. } + | AiMuxError::InvalidResponseData { .. } + | AiMuxError::TypeValidation { .. } | 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 +346,13 @@ 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::TypeValidation { + message: "x".into(), + value: Box::new(serde_json::Value::Null), + cause: None, + }, AiMuxError::ToolCallNotFoundForApproval { tool_call_id: "x".into(), approval_id: "x".into(), @@ -351,7 +365,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 +373,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 +395,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 +410,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 @@ -430,6 +444,7 @@ fn variant_set_is_exactly_the_wire_tag_list() { "AI_ToolCallNotFoundForApprovalError", "AI_ToolCallRepairError", "AI_ToolChoiceViolationError", + "AI_TypeValidationError", "AI_UnsupportedFunctionalityError", "AbortError", "TimeoutError", 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/include/aimux.h b/aimux-ffi/include/aimux.h index b9af8447b..d97ed2431 100644 --- a/aimux-ffi/include/aimux.h +++ b/aimux-ffi/include/aimux.h @@ -1,4 +1,4 @@ -/* Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 */ +/* Generated by aimux-codegen. Do not edit. Manifest SHA-256: ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89 */ #ifndef AIMUX_H #define AIMUX_H @@ -11,7 +11,7 @@ extern "C" { #define AIMUX_ABI_VERSION UINT32_C(65536) #define AIMUX_OPS_VERSION "1.0" -#define AIMUX_MANIFEST_DIGEST "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" +#define AIMUX_MANIFEST_DIGEST "ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89" #define AIMUX_OP_ABORT_CREATE "abort.create" #define AIMUX_OP_ABORT_SIGNAL "abort.signal" 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-ops/src/envelope.rs b/aimux-ops/src/envelope.rs index 08735dcc2..ccd6c36b1 100644 --- a/aimux-ops/src/envelope.rs +++ b/aimux-ops/src/envelope.rs @@ -148,14 +148,15 @@ 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::TypeValidation { .. } | 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, @@ -167,7 +168,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/manifest.rs b/aimux-ops/src/manifest.rs index 3fb0f8f2a..d88bb7acb 100644 --- a/aimux-ops/src/manifest.rs +++ b/aimux-ops/src/manifest.rs @@ -150,6 +150,7 @@ fn aliases(code: i32) -> &'static [&'static str] { "InvalidResponseData", "ToolCallNotFoundForApproval", "ToolChoiceViolation", + "TypeValidation", ], 6 => &["InvalidPrompt", "MissingToolResults"], _ => &[], diff --git a/aimux-ops/src/ops/misc.rs b/aimux-ops/src/ops/misc.rs index 58017f2ee..f845c1eae 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::{ @@ -239,9 +240,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)?; @@ -506,7 +508,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 { .. })) )); } @@ -574,7 +576,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 b3bdd666e..1eb2b78cf 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 befb73d9a..ba575e276 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 66fa3b5d0..71bb906b7 100644 --- a/aimux-ops/src/ops/text.rs +++ b/aimux-ops/src/ops/text.rs @@ -583,7 +583,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 { @@ -690,12 +690,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::>() @@ -717,7 +717,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 { @@ -726,7 +726,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()); @@ -752,7 +752,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") ); } } @@ -762,7 +762,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(), @@ -1076,11 +1076,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 13a510d44..dc6b21cd6 100644 --- a/aimux-ops/src/ops/trace.rs +++ b/aimux-ops/src/ops/trace.rs @@ -140,9 +140,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 4fb96a9de..b68bfa1d1 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 17307bf9c..863054a65 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..3a44dc283 100644 --- a/aimux-ops/tests/error_envelope_golden.rs +++ b/aimux-ops/tests/error_envelope_golden.rs @@ -42,6 +42,7 @@ const AIMUX: &[&str] = &[ "load-api-key", "load-setting", "tool-choice-violation", + "type-validation", ]; fn recording_errors() -> Vec<(&'static str, OpsError)> { @@ -226,6 +227,7 @@ fn codes_preserve_existing_groups_and_have_disjoint_ranges() { "invalid-response-data".into(), "tool-call-not-found-for-approval".into(), "tool-choice-violation".into(), + "type-validation".into(), ]) ), ( @@ -354,7 +356,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 80ea1bdac..9e94c6ca6 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/manifest_contract.rs b/aimux-ops/tests/manifest_contract.rs index 8f1abe487..88615332a 100644 --- a/aimux-ops/tests/manifest_contract.rs +++ b/aimux-ops/tests/manifest_contract.rs @@ -411,7 +411,8 @@ fn metadata_and_owned_control_schemas_are_complete() { serde_json::json!([ "InvalidResponseData", "ToolCallNotFoundForApproval", - "ToolChoiceViolation" + "ToolChoiceViolation", + "TypeValidation" ]) ); assert_eq!( 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 b0d3b374d..2534fa0cc 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 518d52b73..dc8deb368 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/Cargo.toml b/aimux-provider-utils/Cargo.toml index f920b499e..7eb3ccd81 100644 --- a/aimux-provider-utils/Cargo.toml +++ b/aimux-provider-utils/Cargo.toml @@ -50,7 +50,6 @@ ws = ["dep:tokio-tungstenite", "dep:rustls", "dep:webpki-roots", "dep:base64"] aimux-core = { workspace = true } tokio = { workspace = true, features = ["test-util", "macros", "rt", "time", "net", "io-util"] } serial_test = { workspace = true } -socket2 = "0.6" wiremock = "0.6" [lints] 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 2db03ea76..f89ed4b4e 100644 --- a/aimux-provider-utils/src/fetch.rs +++ b/aimux-provider-utils/src/fetch.rs @@ -576,14 +576,15 @@ mod tests { /// and drops the URL, whose query may carry an API key. #[tokio::test] async fn a_refused_connection_keeps_its_chain_without_the_url() { - // A bound socket that never listens refuses connections, and holding - // it keeps the port from being reused while the test runs. - let socket = - socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap(); - socket - .bind(&std::net::SocketAddr::from(([127, 0, 0, 1], 0)).into()) + // A socket that is only bound times out on macOS. An established + // client's local port refuses new connections and stays reserved + // while this connection and its peer are alive. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let connection = tokio::net::TcpStream::connect(listener.local_addr().unwrap()) + .await .unwrap(); - let address = socket.local_addr().unwrap().as_socket().unwrap(); + let (_peer, _) = listener.accept().await.unwrap(); + let address = connection.local_addr().unwrap(); // An explicit proxy turns off the environment's; NO_PROXY then sends // the loopback request directly. let direct = ProxyConfig { diff --git a/aimux-provider-utils/src/http.rs b/aimux-provider-utils/src/http.rs index 367826e8b..eaa269a38 100644 --- a/aimux-provider-utils/src/http.rs +++ b/aimux-provider-utils/src/http.rs @@ -482,8 +482,10 @@ async fn send_following_redirects(request: &PreparedRequest) -> 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 { @@ -575,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 694b9e376..19a4786b1 100644 --- a/aimux-provider-utils/src/post_to_api.rs +++ b/aimux-provider-utils/src/post_to_api.rs @@ -273,7 +273,7 @@ mod tests { ..Default::default() }; let handler = ResponseHandler::::new(|_| async { - Err(AiMuxError::JsonParse("Unexpected end of JSON input".into())) + Err(AiMuxError::json_parse("Unexpected end of JSON input")) }); let error = call_to_api_with_deadline( request.prepare(HttpMethod::Post, HttpBody::Json(serde_json::json!({}))), 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/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 ce32c6b71..c48202137 100644 --- a/aimux-provider-utils/src/ws.rs +++ b/aimux-provider-utils/src/ws.rs @@ -278,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" { @@ -298,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) )) @@ -520,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)), })?, ); } @@ -539,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)), + } })?, ); } @@ -556,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); 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 7ce5afbc3..3b92a9a11 100644 --- a/aimux-providers/src/azure/mod.rs +++ b/aimux-providers/src/azure/mod.rs @@ -26,12 +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::SourceError; +use aimux_core::ErrorCause; use aimux_core::embedding_model::EmbeddingModel; use aimux_core::error::AiMuxError; use aimux_core::image_model::ImageModel; @@ -152,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") @@ -171,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. @@ -184,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(), @@ -342,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(), @@ -364,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) { @@ -594,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(), ) })), @@ -610,7 +610,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 5bf9b1291..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), @@ -210,7 +214,7 @@ pub(crate) async fn post( } 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 74ad49ba9..8e0f7a04b 100644 --- a/aimux-providers/src/default_providers.rs +++ b/aimux-providers/src/default_providers.rs @@ -122,7 +122,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" ))) }; @@ -585,7 +585,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 2d1433f80..c6818eb42 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -1041,7 +1041,7 @@ pub(crate) 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.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 45caf9550..788226717 100644 --- a/aimux-providers/src/google/mod.rs +++ b/aimux-providers/src/google/mod.rs @@ -233,7 +233,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 d5e168b54..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" ))); } 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 0fc6c187b..b3182db6a 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 b85b466e3..a122e740b 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(), ), }); @@ -881,7 +881,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 6b9d09626..5f4089750 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; @@ -17,8 +18,9 @@ use std::sync::Arc; 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_core::{AiMuxError, SourceError}; use aimux_provider_utils::{ ExchangeContext, Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, HeaderMapOpt, HttpRequest, ProviderErrorParts, combine_headers, normalize_headers, @@ -252,9 +254,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. 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 daed7a72a..0e34cef56 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 0dd0c1717..b38836a71 100644 --- a/aimux-providers/src/preset.rs +++ b/aimux-providers/src/preset.rs @@ -410,7 +410,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(", ") @@ -510,7 +510,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 @@ -536,8 +536,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(); @@ -557,7 +559,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 @@ -569,7 +571,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 )) @@ -592,7 +594,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 df63a96d1..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, @@ -168,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( @@ -374,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 ee8dbc972..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, SourceError}; +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)?, ))) } 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 b93032010..8bcb36870 100644 --- a/aimux-providers/src/vertex/mod.rs +++ b/aimux-providers/src/vertex/mod.rs @@ -154,10 +154,9 @@ fn location_host(location: &str) -> String { /// (@ai-sdk/google-vertex@5.0.100 google-vertex-provider-base.ts:230-245). pub(crate) fn check_location(location: &str) -> Result<(), AiMuxError> { if !is_valid_hostname_part(location) { - return Err(AiMuxError::InvalidArgument( + return Err(AiMuxError::invalid_argument( "Invalid Google Vertex location. Expected a single DNS label (letters, digits, and hyphens). \ - Use `base_url` for custom endpoints." - .to_string(), + Use `base_url` for custom endpoints.", )); } Ok(()) @@ -189,7 +188,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 @@ -216,7 +215,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(), @@ -471,7 +470,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/partner_provider.rs b/aimux-providers/src/vertex/partner_provider.rs index b6bd4decb..c9b7781c5 100644 --- a/aimux-providers/src/vertex/partner_provider.rs +++ b/aimux-providers/src/vertex/partner_provider.rs @@ -207,7 +207,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( @@ -290,7 +290,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/transcription.rs b/aimux-providers/src/vertex/transcription.rs index 74d78a80f..5391a141e 100644 --- a/aimux-providers/src/vertex/transcription.rs +++ b/aimux-providers/src/vertex/transcription.rs @@ -198,9 +198,8 @@ impl TranscriptionModel for VertexTranscriptionModel { // google-vertex-transcription-model.ts:113-120 if !is_valid_hostname_part(®ion) { - return Err(AiMuxError::InvalidArgument( - "Invalid Google Cloud Speech-to-Text region. Expected a single DNS label (letters, digits, and hyphens)." - .to_string(), + return Err(AiMuxError::invalid_argument( + "Invalid Google Cloud Speech-to-Text region. Expected a single DNS label (letters, digits, and hyphens).", )); } 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/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 09bbbfa63..6e7cdf5af 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 1cc268b47..8e48d0e57 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 3179129dd..a5961fe35 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 { .. })); } #[tokio::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_location_validation_test.rs b/aimux-providers/tests/vertex_location_validation_test.rs index d7d162b9e..7f4c2bd50 100644 --- a/aimux-providers/tests/vertex_location_validation_test.rs +++ b/aimux-providers/tests/vertex_location_validation_test.rs @@ -104,7 +104,7 @@ async fn rejects(kind: Kind, location: Option<&str>, base_url: Option<&str>) -> .expect_err("the mock answers nothing"); let rejected = matches!( &error, - AiMuxError::InvalidArgument(message) if message.starts_with("Invalid Google Vertex location.") + AiMuxError::InvalidArgument { message, .. } if message.starts_with("Invalid Google Vertex location.") ); if rejected { assert!( @@ -191,7 +191,7 @@ async fn express_mode_ignores_the_location() { .await .expect_err("the mock answers nothing"); assert!( - !matches!(&error, AiMuxError::InvalidArgument(message) if message.starts_with("Invalid Google Vertex location.")), + !matches!(&error, AiMuxError::InvalidArgument { message, .. } if message.starts_with("Invalid Google Vertex location.")), "{error:?}" ); assert_eq!(fetch.seen().len(), 1); @@ -218,7 +218,7 @@ async fn transcription_validates_the_location_with_a_custom_url() { let options = TranscriptionCallOptions::new(AudioInput::Binary(vec![0, 1, 2]), "audio/wav"); let error = model.do_generate(&options).await.unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidArgument(message) if message.starts_with("Invalid Google Vertex location.")), + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message.starts_with("Invalid Google Vertex location.")), "{model_id}: {error:?}" ); assert!(fetch.seen().is_empty(), "{model_id}"); @@ -246,7 +246,7 @@ async fn transcription_validates_the_region_option() { ); let error = model.do_generate(&options).await.unwrap_err(); assert!( - matches!(&error, AiMuxError::InvalidArgument(message) if message.starts_with("Invalid Google Cloud Speech-to-Text region.")), + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message.starts_with("Invalid Google Cloud Speech-to-Text region.")), "{error:?}" ); assert!(fetch.seen().is_empty()); diff --git a/aimux-providers/tests/vertex_upstream_e2e_test.rs b/aimux-providers/tests/vertex_upstream_e2e_test.rs index 309b1a3c0..d26c82586 100644 --- a/aimux-providers/tests/vertex_upstream_e2e_test.rs +++ b/aimux-providers/tests/vertex_upstream_e2e_test.rs @@ -484,7 +484,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/flutter/lib/generated/ops.dart b/bindings/flutter/lib/generated/ops.dart index 079760f86..537a3029f 100644 --- a/bindings/flutter/lib/generated/ops.dart +++ b/bindings/flutter/lib/generated/ops.dart @@ -1,7 +1,7 @@ -// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89 const int ABI_VERSION = 65536; const String OPS_VERSION = "1.0"; -const String MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95"; +const String MANIFEST_DIGEST = "ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89"; const String OP_ABORT_CREATE = "abort.create"; const String OP_ABORT_SIGNAL = "abort.signal"; const String OP_AUTH_CODEX_REFRESH = "auth.codexRefresh"; diff --git a/bindings/flutter/lib/src/generated/types.dart b/bindings/flutter/lib/src/generated/types.dart index 741fcea75..5aea96427 100644 --- a/bindings/flutter/lib/src/generated/types.dart +++ b/bindings/flutter/lib/src/generated/types.dart @@ -46,8 +46,9 @@ String _timestamp(String text) { /// failure came from. `ApiCallError` is boxed only to keep the Rust enum /// compact; serde and every binding still observe the same object shape. /// -/// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a -/// retry's last attempt and a repair's `cause`. +/// 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. sealed class AiMuxError { const AiMuxError(); String get name; @@ -63,6 +64,8 @@ sealed class AiMuxError { return AiMuxErrorAINoOutputGeneratedError.fromJson(json); case 'AI_InvalidResponseDataError': return AiMuxErrorAIInvalidResponseDataError.fromJson(json); + case 'AI_TypeValidationError': + return AiMuxErrorAITypeValidationError.fromJson(json); case 'AI_ToolCallNotFoundForApprovalError': return AiMuxErrorAIToolCallNotFoundForApprovalError.fromJson(json); case 'AI_NoSuchToolError': @@ -226,27 +229,33 @@ final class AiMuxErrorAIRetryError extends AiMuxError { } final class AiMuxErrorAIJSONParseError extends AiMuxError { + final ErrorCause? cause; final String message; const AiMuxErrorAIJSONParseError({ + this.cause, required this.message, }); @override String get name => 'AI_JSONParseError'; factory AiMuxErrorAIJSONParseError.fromJson(Map json) { return AiMuxErrorAIJSONParseError( + cause: json['cause'] == null ? null : ErrorCause.fromJson(json['cause']), message: json['message'] as String, ); } Map toJson() => { 'name': 'AI_JSONParseError', + if (cause != null) 'cause': cause!.toJson(), 'message': message, }; } final class AiMuxErrorAINoOutputGeneratedError extends AiMuxError { + final ErrorCause? cause; final String message; const AiMuxErrorAINoOutputGeneratedError({ + this.cause, required this.message, }); @override @@ -254,19 +263,23 @@ final class AiMuxErrorAINoOutputGeneratedError extends AiMuxError { factory AiMuxErrorAINoOutputGeneratedError.fromJson( Map json) { return AiMuxErrorAINoOutputGeneratedError( + cause: json['cause'] == null ? null : ErrorCause.fromJson(json['cause']), message: json['message'] as String, ); } Map toJson() => { 'name': 'AI_NoOutputGeneratedError', + if (cause != null) 'cause': cause!.toJson(), 'message': message, }; } final class AiMuxErrorAIInvalidResponseDataError extends AiMuxError { + final ErrorCause? cause; final String message; const AiMuxErrorAIInvalidResponseDataError({ + this.cause, required this.message, }); @override @@ -274,13 +287,42 @@ final class AiMuxErrorAIInvalidResponseDataError extends AiMuxError { factory AiMuxErrorAIInvalidResponseDataError.fromJson( Map json) { return AiMuxErrorAIInvalidResponseDataError( + cause: json['cause'] == null ? null : ErrorCause.fromJson(json['cause']), message: json['message'] as String, ); } Map toJson() => { 'name': 'AI_InvalidResponseDataError', + if (cause != null) 'cause': cause!.toJson(), + 'message': message, + }; +} + +final class AiMuxErrorAITypeValidationError extends AiMuxError { + final ErrorCause? cause; + final String message; + final Object? value; + const AiMuxErrorAITypeValidationError({ + this.cause, + required this.message, + required this.value, + }); + @override + String get name => 'AI_TypeValidationError'; + factory AiMuxErrorAITypeValidationError.fromJson(Map json) { + return AiMuxErrorAITypeValidationError( + cause: json['cause'] == null ? null : ErrorCause.fromJson(json['cause']), + message: json['message'] as String, + value: json['value'], + ); + } + + Map toJson() => { + 'name': 'AI_TypeValidationError', + if (cause != null) 'cause': cause!.toJson(), 'message': message, + 'value': value, }; } @@ -338,7 +380,7 @@ final class AiMuxErrorAINoSuchToolError extends AiMuxError { } final class AiMuxErrorAIInvalidToolInputError extends AiMuxError { - final String cause; + final ErrorCause cause; final String toolInput; final String toolName; const AiMuxErrorAIInvalidToolInputError({ @@ -351,7 +393,7 @@ final class AiMuxErrorAIInvalidToolInputError extends AiMuxError { factory AiMuxErrorAIInvalidToolInputError.fromJson( Map json) { return AiMuxErrorAIInvalidToolInputError( - cause: json['cause'] as String, + cause: ErrorCause.fromJson(json['cause']), toolInput: json['toolInput'] as String, toolName: json['toolName'] as String, ); @@ -359,7 +401,7 @@ final class AiMuxErrorAIInvalidToolInputError extends AiMuxError { Map toJson() => { 'name': 'AI_InvalidToolInputError', - 'cause': cause, + 'cause': cause.toJson(), 'toolInput': toolInput, 'toolName': toolName, }; @@ -390,39 +432,47 @@ final class AiMuxErrorAIToolCallRepairError extends AiMuxError { } final class AiMuxErrorAIInvalidArgumentError extends AiMuxError { + final ErrorCause? cause; final String message; const AiMuxErrorAIInvalidArgumentError({ + this.cause, required this.message, }); @override String get name => 'AI_InvalidArgumentError'; factory AiMuxErrorAIInvalidArgumentError.fromJson(Map json) { return AiMuxErrorAIInvalidArgumentError( + cause: json['cause'] == null ? null : ErrorCause.fromJson(json['cause']), message: json['message'] as String, ); } Map toJson() => { 'name': 'AI_InvalidArgumentError', + if (cause != null) 'cause': cause!.toJson(), 'message': message, }; } final class AiMuxErrorAIInvalidPromptError extends AiMuxError { + final ErrorCause? cause; final String message; const AiMuxErrorAIInvalidPromptError({ + this.cause, required this.message, }); @override String get name => 'AI_InvalidPromptError'; factory AiMuxErrorAIInvalidPromptError.fromJson(Map json) { return AiMuxErrorAIInvalidPromptError( + cause: json['cause'] == null ? null : ErrorCause.fromJson(json['cause']), message: json['message'] as String, ); } Map toJson() => { 'name': 'AI_InvalidPromptError', + if (cause != null) 'cause': cause!.toJson(), 'message': message, }; } @@ -677,20 +727,24 @@ final class AiMuxErrorAbortError extends AiMuxError { } final class AiMuxErrorAIError extends AiMuxError { + final ErrorCause? cause; final String message; const AiMuxErrorAIError({ + this.cause, required this.message, }); @override String get name => 'AI_Error'; factory AiMuxErrorAIError.fromJson(Map json) { return AiMuxErrorAIError( + cause: json['cause'] == null ? null : ErrorCause.fromJson(json['cause']), message: json['message'] as String, ); } Map toJson() => { 'name': 'AI_Error', + if (cause != null) 'cause': cause!.toJson(), 'message': message, }; } @@ -701,8 +755,9 @@ final class AiMuxErrorAIError extends AiMuxError { /// failure came from. `ApiCallError` is boxed only to keep the Rust enum /// compact; serde and every binding still observe the same object shape. /// -/// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a -/// retry's last attempt and a repair's `cause`. +/// 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. sealed class AiMuxErrorInput { const AiMuxErrorInput(); String get name; @@ -718,6 +773,8 @@ sealed class AiMuxErrorInput { return AiMuxErrorInputAINoOutputGeneratedError.fromJson(json); case 'AI_InvalidResponseDataError': return AiMuxErrorInputAIInvalidResponseDataError.fromJson(json); + case 'AI_TypeValidationError': + return AiMuxErrorInputAITypeValidationError.fromJson(json); case 'AI_ToolCallNotFoundForApprovalError': return AiMuxErrorInputAIToolCallNotFoundForApprovalError.fromJson(json); case 'AI_NoSuchToolError': @@ -883,27 +940,35 @@ final class AiMuxErrorInputAIRetryError extends AiMuxErrorInput { } final class AiMuxErrorInputAIJSONParseError extends AiMuxErrorInput { + final ErrorCauseInput? cause; final String message; const AiMuxErrorInputAIJSONParseError({ + this.cause, required this.message, }); @override String get name => 'AI_JSONParseError'; factory AiMuxErrorInputAIJSONParseError.fromJson(Map json) { return AiMuxErrorInputAIJSONParseError( + cause: json['cause'] == null + ? null + : ErrorCauseInput.fromJson(json['cause']), message: json['message'] as String, ); } Map toJson() => { 'name': 'AI_JSONParseError', + if (cause != null) 'cause': cause!.toJson(), 'message': message, }; } final class AiMuxErrorInputAINoOutputGeneratedError extends AiMuxErrorInput { + final ErrorCauseInput? cause; final String message; const AiMuxErrorInputAINoOutputGeneratedError({ + this.cause, required this.message, }); @override @@ -911,19 +976,25 @@ final class AiMuxErrorInputAINoOutputGeneratedError extends AiMuxErrorInput { factory AiMuxErrorInputAINoOutputGeneratedError.fromJson( Map json) { return AiMuxErrorInputAINoOutputGeneratedError( + cause: json['cause'] == null + ? null + : ErrorCauseInput.fromJson(json['cause']), message: json['message'] as String, ); } Map toJson() => { 'name': 'AI_NoOutputGeneratedError', + if (cause != null) 'cause': cause!.toJson(), 'message': message, }; } final class AiMuxErrorInputAIInvalidResponseDataError extends AiMuxErrorInput { + final ErrorCauseInput? cause; final String message; const AiMuxErrorInputAIInvalidResponseDataError({ + this.cause, required this.message, }); @override @@ -931,16 +1002,50 @@ final class AiMuxErrorInputAIInvalidResponseDataError extends AiMuxErrorInput { factory AiMuxErrorInputAIInvalidResponseDataError.fromJson( Map json) { return AiMuxErrorInputAIInvalidResponseDataError( + cause: json['cause'] == null + ? null + : ErrorCauseInput.fromJson(json['cause']), message: json['message'] as String, ); } Map toJson() => { 'name': 'AI_InvalidResponseDataError', + if (cause != null) 'cause': cause!.toJson(), 'message': message, }; } +final class AiMuxErrorInputAITypeValidationError extends AiMuxErrorInput { + final ErrorCauseInput? cause; + final String message; + final Object? value; + const AiMuxErrorInputAITypeValidationError({ + this.cause, + required this.message, + required this.value, + }); + @override + String get name => 'AI_TypeValidationError'; + factory AiMuxErrorInputAITypeValidationError.fromJson( + Map json) { + return AiMuxErrorInputAITypeValidationError( + cause: json['cause'] == null + ? null + : ErrorCauseInput.fromJson(json['cause']), + message: json['message'] as String, + value: json['value'], + ); + } + + Map toJson() => { + 'name': 'AI_TypeValidationError', + if (cause != null) 'cause': cause!.toJson(), + 'message': message, + 'value': value, + }; +} + final class AiMuxErrorInputAIToolCallNotFoundForApprovalError extends AiMuxErrorInput { final String approvalId; @@ -996,7 +1101,7 @@ final class AiMuxErrorInputAINoSuchToolError extends AiMuxErrorInput { } final class AiMuxErrorInputAIInvalidToolInputError extends AiMuxErrorInput { - final String cause; + final ErrorCauseInput cause; final String toolInput; final String toolName; const AiMuxErrorInputAIInvalidToolInputError({ @@ -1009,7 +1114,7 @@ final class AiMuxErrorInputAIInvalidToolInputError extends AiMuxErrorInput { factory AiMuxErrorInputAIInvalidToolInputError.fromJson( Map json) { return AiMuxErrorInputAIInvalidToolInputError( - cause: json['cause'] as String, + cause: ErrorCauseInput.fromJson(json['cause']), toolInput: json['toolInput'] as String, toolName: json['toolName'] as String, ); @@ -1017,7 +1122,7 @@ final class AiMuxErrorInputAIInvalidToolInputError extends AiMuxErrorInput { Map toJson() => { 'name': 'AI_InvalidToolInputError', - 'cause': cause, + 'cause': cause.toJson(), 'toolInput': toolInput, 'toolName': toolName, }; @@ -1049,8 +1154,10 @@ final class AiMuxErrorInputAIToolCallRepairError extends AiMuxErrorInput { } final class AiMuxErrorInputAIInvalidArgumentError extends AiMuxErrorInput { + final ErrorCauseInput? cause; final String message; const AiMuxErrorInputAIInvalidArgumentError({ + this.cause, required this.message, }); @override @@ -1058,19 +1165,25 @@ final class AiMuxErrorInputAIInvalidArgumentError extends AiMuxErrorInput { factory AiMuxErrorInputAIInvalidArgumentError.fromJson( Map json) { return AiMuxErrorInputAIInvalidArgumentError( + cause: json['cause'] == null + ? null + : ErrorCauseInput.fromJson(json['cause']), message: json['message'] as String, ); } Map toJson() => { 'name': 'AI_InvalidArgumentError', + if (cause != null) 'cause': cause!.toJson(), 'message': message, }; } final class AiMuxErrorInputAIInvalidPromptError extends AiMuxErrorInput { + final ErrorCauseInput? cause; final String message; const AiMuxErrorInputAIInvalidPromptError({ + this.cause, required this.message, }); @override @@ -1078,12 +1191,16 @@ final class AiMuxErrorInputAIInvalidPromptError extends AiMuxErrorInput { factory AiMuxErrorInputAIInvalidPromptError.fromJson( Map json) { return AiMuxErrorInputAIInvalidPromptError( + cause: json['cause'] == null + ? null + : ErrorCauseInput.fromJson(json['cause']), message: json['message'] as String, ); } Map toJson() => { 'name': 'AI_InvalidPromptError', + if (cause != null) 'cause': cause!.toJson(), 'message': message, }; } @@ -1343,20 +1460,26 @@ final class AiMuxErrorInputAbortError extends AiMuxErrorInput { } final class AiMuxErrorInputAIError extends AiMuxErrorInput { + final ErrorCauseInput? cause; final String message; const AiMuxErrorInputAIError({ + this.cause, required this.message, }); @override String get name => 'AI_Error'; factory AiMuxErrorInputAIError.fromJson(Map json) { return AiMuxErrorInputAIError( + cause: json['cause'] == null + ? null + : ErrorCauseInput.fromJson(json['cause']), message: json['message'] as String, ); } Map toJson() => { 'name': 'AI_Error', + if (cause != null) 'cause': cause!.toJson(), 'message': message, }; } diff --git a/bindings/flutter/native/aimux_ops.h b/bindings/flutter/native/aimux_ops.h index b9af8447b..d97ed2431 100644 --- a/bindings/flutter/native/aimux_ops.h +++ b/bindings/flutter/native/aimux_ops.h @@ -1,4 +1,4 @@ -/* Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 */ +/* Generated by aimux-codegen. Do not edit. Manifest SHA-256: ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89 */ #ifndef AIMUX_H #define AIMUX_H @@ -11,7 +11,7 @@ extern "C" { #define AIMUX_ABI_VERSION UINT32_C(65536) #define AIMUX_OPS_VERSION "1.0" -#define AIMUX_MANIFEST_DIGEST "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" +#define AIMUX_MANIFEST_DIGEST "ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89" #define AIMUX_OP_ABORT_CREATE "abort.create" #define AIMUX_OP_ABORT_SIGNAL "abort.signal" diff --git a/bindings/go/generated/ops.go b/bindings/go/generated/ops.go index d8ea3ad92..73ac106d0 100644 --- a/bindings/go/generated/ops.go +++ b/bindings/go/generated/ops.go @@ -1,10 +1,10 @@ -// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89 package generated const ( ABI_VERSION = 65536 OPS_VERSION = "1.0" - MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" + MANIFEST_DIGEST = "ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89" OP_ABORT_CREATE = "abort.create" OP_ABORT_SIGNAL = "abort.signal" OP_AUTH_CODEX_REFRESH = "auth.codexRefresh" diff --git a/bindings/go/roundtrip_test.go b/bindings/go/roundtrip_test.go index 6cd59caa4..7a25a3278 100644 --- a/bindings/go/roundtrip_test.go +++ b/bindings/go/roundtrip_test.go @@ -79,7 +79,7 @@ func TestParseStreamPartRejectsInvalid(t *testing.T) { } func TestNamespaceMapsKeepValues(t *testing.T) { - roundTripDTO[TypedToolCall](t, `{"type":"tool-call","toolCallId":"call-1","toolName":"weather","input":{"town":"SG"},"dynamic":true,"invalid":true,"providerMetadata":{"zeta":{"z":1,"a":2},"alpha":{},"mid":{}},"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"town\":\"SG\"}","cause":"missing city"}}`) + roundTripDTO[TypedToolCall](t, `{"type":"tool-call","toolCallId":"call-1","toolName":"weather","input":{"town":"SG"},"dynamic":true,"invalid":true,"providerMetadata":{"zeta":{"z":1,"a":2},"alpha":{},"mid":{}},"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"town\":\"SG\"}","cause":{"message":"missing city"}}}`) roundTripDTO[UploadFileResult](t, `{"providerReference":{"openai":"file-abc123","anthropic":"file-xyz789","google":"files/1"},"warnings":[]}`) } diff --git a/bindings/go/types_gen.go b/bindings/go/types_gen.go index da5dcf33b..678d60756 100644 --- a/bindings/go/types_gen.go +++ b/bindings/go/types_gen.go @@ -88,8 +88,9 @@ func decodeUnionBranch[T any](data []byte, dst *T, nullable bool, required ...st // failure came from. `ApiCallError` is boxed only to keep the Rust enum // compact; serde and every binding still observe the same object shape. // -// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a -// retry's last attempt and a repair's `cause`. +// 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. type AiMuxErrorVariant interface{ isAiMuxError() } type AiMuxError struct { Value AiMuxErrorVariant @@ -240,6 +241,13 @@ func (v *AiMuxError) UnmarshalJSON(data []byte) error { } v.Value = next return nil + case "AI_TypeValidationError": + var next AiMuxErrorVariantAITypeValidationError + if err := json.Unmarshal(data, &next); err != nil { + return err + } + v.Value = next + return nil case "AI_UnsupportedFunctionalityError": var next AiMuxErrorVariantAIUnsupportedFunctionalityError if err := json.Unmarshal(data, &next); err != nil { @@ -331,7 +339,8 @@ func (v AiMuxErrorVariantAIRetryError) MarshalJSON() ([]byte, error) { } type AiMuxErrorVariantAIJSONParseError struct { - Message string `json:"message"` + Cause *ErrorCause `json:"cause,omitzero"` + Message string `json:"message"` } func (AiMuxErrorVariantAIJSONParseError) isAiMuxError() {} @@ -345,7 +354,8 @@ func (v AiMuxErrorVariantAIJSONParseError) MarshalJSON() ([]byte, error) { } type AiMuxErrorVariantAINoOutputGeneratedError struct { - Message string `json:"message"` + Cause *ErrorCause `json:"cause,omitzero"` + Message string `json:"message"` } func (AiMuxErrorVariantAINoOutputGeneratedError) isAiMuxError() {} @@ -359,7 +369,8 @@ func (v AiMuxErrorVariantAINoOutputGeneratedError) MarshalJSON() ([]byte, error) } type AiMuxErrorVariantAIInvalidResponseDataError struct { - Message string `json:"message"` + Cause *ErrorCause `json:"cause,omitzero"` + Message string `json:"message"` } func (AiMuxErrorVariantAIInvalidResponseDataError) isAiMuxError() {} @@ -372,6 +383,22 @@ func (v AiMuxErrorVariantAIInvalidResponseDataError) MarshalJSON() ([]byte, erro }{Name: "AI_InvalidResponseDataError", plain: plain(v)}) } +type AiMuxErrorVariantAITypeValidationError struct { + Cause *ErrorCause `json:"cause,omitzero"` + Message string `json:"message"` + Value json.RawMessage `json:"value"` +} + +func (AiMuxErrorVariantAITypeValidationError) isAiMuxError() {} + +func (v AiMuxErrorVariantAITypeValidationError) MarshalJSON() ([]byte, error) { + type plain AiMuxErrorVariantAITypeValidationError + return json.Marshal(struct { + Name string `json:"name"` + plain + }{Name: "AI_TypeValidationError", plain: plain(v)}) +} + type AiMuxErrorVariantAIToolCallNotFoundForApprovalError struct { ApprovalID string `json:"approvalId"` ToolCallID string `json:"toolCallId"` @@ -404,9 +431,9 @@ func (v AiMuxErrorVariantAINoSuchToolError) MarshalJSON() ([]byte, error) { } type AiMuxErrorVariantAIInvalidToolInputError struct { - Cause string `json:"cause"` - ToolInput string `json:"toolInput"` - ToolName string `json:"toolName"` + Cause ErrorCause `json:"cause"` + ToolInput string `json:"toolInput"` + ToolName string `json:"toolName"` } func (AiMuxErrorVariantAIInvalidToolInputError) isAiMuxError() {} @@ -435,7 +462,8 @@ func (v AiMuxErrorVariantAIToolCallRepairError) MarshalJSON() ([]byte, error) { } type AiMuxErrorVariantAIInvalidArgumentError struct { - Message string `json:"message"` + Cause *ErrorCause `json:"cause,omitzero"` + Message string `json:"message"` } func (AiMuxErrorVariantAIInvalidArgumentError) isAiMuxError() {} @@ -449,7 +477,8 @@ func (v AiMuxErrorVariantAIInvalidArgumentError) MarshalJSON() ([]byte, error) { } type AiMuxErrorVariantAIInvalidPromptError struct { - Message string `json:"message"` + Cause *ErrorCause `json:"cause,omitzero"` + Message string `json:"message"` } func (AiMuxErrorVariantAIInvalidPromptError) isAiMuxError() {} @@ -621,7 +650,8 @@ func (v AiMuxErrorVariantAbortError) MarshalJSON() ([]byte, error) { } type AiMuxErrorVariantAIError struct { - Message string `json:"message"` + Cause *ErrorCause `json:"cause,omitzero"` + Message string `json:"message"` } func (AiMuxErrorVariantAIError) isAiMuxError() {} @@ -640,8 +670,9 @@ func (v AiMuxErrorVariantAIError) MarshalJSON() ([]byte, error) { // failure came from. `ApiCallError` is boxed only to keep the Rust enum // compact; serde and every binding still observe the same object shape. // -// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a -// retry's last attempt and a repair's `cause`. +// 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. type AiMuxErrorInputVariant interface{ isAiMuxErrorInput() } type AiMuxErrorInput struct { Value AiMuxErrorInputVariant @@ -792,6 +823,13 @@ func (v *AiMuxErrorInput) UnmarshalJSON(data []byte) error { } v.Value = next return nil + case "AI_TypeValidationError": + var next AiMuxErrorInputVariantAITypeValidationError + if err := json.Unmarshal(data, &next); err != nil { + return err + } + v.Value = next + return nil case "AI_UnsupportedFunctionalityError": var next AiMuxErrorInputVariantAIUnsupportedFunctionalityError if err := json.Unmarshal(data, &next); err != nil { @@ -883,7 +921,8 @@ func (v AiMuxErrorInputVariantAIRetryError) MarshalJSON() ([]byte, error) { } type AiMuxErrorInputVariantAIJSONParseError struct { - Message string `json:"message"` + Cause *ErrorCauseInput `json:"cause,omitzero"` + Message string `json:"message"` } func (AiMuxErrorInputVariantAIJSONParseError) isAiMuxErrorInput() {} @@ -897,7 +936,8 @@ func (v AiMuxErrorInputVariantAIJSONParseError) MarshalJSON() ([]byte, error) { } type AiMuxErrorInputVariantAINoOutputGeneratedError struct { - Message string `json:"message"` + Cause *ErrorCauseInput `json:"cause,omitzero"` + Message string `json:"message"` } func (AiMuxErrorInputVariantAINoOutputGeneratedError) isAiMuxErrorInput() {} @@ -911,7 +951,8 @@ func (v AiMuxErrorInputVariantAINoOutputGeneratedError) MarshalJSON() ([]byte, e } type AiMuxErrorInputVariantAIInvalidResponseDataError struct { - Message string `json:"message"` + Cause *ErrorCauseInput `json:"cause,omitzero"` + Message string `json:"message"` } func (AiMuxErrorInputVariantAIInvalidResponseDataError) isAiMuxErrorInput() {} @@ -924,6 +965,22 @@ func (v AiMuxErrorInputVariantAIInvalidResponseDataError) MarshalJSON() ([]byte, }{Name: "AI_InvalidResponseDataError", plain: plain(v)}) } +type AiMuxErrorInputVariantAITypeValidationError struct { + Cause *ErrorCauseInput `json:"cause,omitzero"` + Message string `json:"message"` + Value json.RawMessage `json:"value"` +} + +func (AiMuxErrorInputVariantAITypeValidationError) isAiMuxErrorInput() {} + +func (v AiMuxErrorInputVariantAITypeValidationError) MarshalJSON() ([]byte, error) { + type plain AiMuxErrorInputVariantAITypeValidationError + return json.Marshal(struct { + Name string `json:"name"` + plain + }{Name: "AI_TypeValidationError", plain: plain(v)}) +} + type AiMuxErrorInputVariantAIToolCallNotFoundForApprovalError struct { ApprovalID string `json:"approvalId"` ToolCallID string `json:"toolCallId"` @@ -956,9 +1013,9 @@ func (v AiMuxErrorInputVariantAINoSuchToolError) MarshalJSON() ([]byte, error) { } type AiMuxErrorInputVariantAIInvalidToolInputError struct { - Cause string `json:"cause"` - ToolInput string `json:"toolInput"` - ToolName string `json:"toolName"` + Cause ErrorCauseInput `json:"cause"` + ToolInput string `json:"toolInput"` + ToolName string `json:"toolName"` } func (AiMuxErrorInputVariantAIInvalidToolInputError) isAiMuxErrorInput() {} @@ -987,7 +1044,8 @@ func (v AiMuxErrorInputVariantAIToolCallRepairError) MarshalJSON() ([]byte, erro } type AiMuxErrorInputVariantAIInvalidArgumentError struct { - Message string `json:"message"` + Cause *ErrorCauseInput `json:"cause,omitzero"` + Message string `json:"message"` } func (AiMuxErrorInputVariantAIInvalidArgumentError) isAiMuxErrorInput() {} @@ -1001,7 +1059,8 @@ func (v AiMuxErrorInputVariantAIInvalidArgumentError) MarshalJSON() ([]byte, err } type AiMuxErrorInputVariantAIInvalidPromptError struct { - Message string `json:"message"` + Cause *ErrorCauseInput `json:"cause,omitzero"` + Message string `json:"message"` } func (AiMuxErrorInputVariantAIInvalidPromptError) isAiMuxErrorInput() {} @@ -1173,7 +1232,8 @@ func (v AiMuxErrorInputVariantAbortError) MarshalJSON() ([]byte, error) { } type AiMuxErrorInputVariantAIError struct { - Message string `json:"message"` + Cause *ErrorCauseInput `json:"cause,omitzero"` + Message string `json:"message"` } func (AiMuxErrorInputVariantAIError) isAiMuxErrorInput() {} diff --git a/bindings/java/generated/AimuxOps.java b/bindings/java/generated/AimuxOps.java index 80ab02faf..61c4a69e9 100644 --- a/bindings/java/generated/AimuxOps.java +++ b/bindings/java/generated/AimuxOps.java @@ -1,11 +1,11 @@ -// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89 package ai.arcships.aimux.generated; public final class AimuxOps { private AimuxOps() {} public static final long ABI_VERSION = 65536L; public static final String OPS_VERSION = "1.0"; - public static final String MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95"; + public static final String MANIFEST_DIGEST = "ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89"; public static final String OP_ABORT_CREATE = "abort.create"; public static final String OP_ABORT_SIGNAL = "abort.signal"; public static final String OP_AUTH_CODEX_REFRESH = "auth.codexRefresh"; diff --git a/bindings/java/generated/ai/arcships/aimux/types/AiMuxError.java b/bindings/java/generated/ai/arcships/aimux/types/AiMuxError.java index 8f32455af..63c41ec2c 100644 --- a/bindings/java/generated/ai/arcships/aimux/types/AiMuxError.java +++ b/bindings/java/generated/ai/arcships/aimux/types/AiMuxError.java @@ -14,8 +14,9 @@ * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. * - * `source()` follows the AI SDK's `cause`: an API failure's `cause`, a - * retry's last attempt and a repair's `cause`. + * 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. */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "name") // clang-format off @@ -25,6 +26,7 @@ @JsonSubTypes.Type(value = AiMuxError.AIJSONParseError.class, name = "AI_JSONParseError"), @JsonSubTypes.Type(value = AiMuxError.AINoOutputGeneratedError.class, name = "AI_NoOutputGeneratedError"), @JsonSubTypes.Type(value = AiMuxError.AIInvalidResponseDataError.class, name = "AI_InvalidResponseDataError"), + @JsonSubTypes.Type(value = AiMuxError.AITypeValidationError.class, name = "AI_TypeValidationError"), @JsonSubTypes.Type(value = AiMuxError.AIToolCallNotFoundForApprovalError.class, name = "AI_ToolCallNotFoundForApprovalError"), @JsonSubTypes.Type(value = AiMuxError.AINoSuchToolError.class, name = "AI_NoSuchToolError"), @JsonSubTypes.Type(value = AiMuxError.AIInvalidToolInputError.class, name = "AI_InvalidToolInputError"), @@ -61,6 +63,7 @@ private AiMuxError() {} public String getUrl() { return null; } public List getErrors() { return null; } public RetryErrorReason getReason() { return null; } + public JsonNode getValue() { return null; } public String getApprovalId() { return null; } public String getToolCallId() { return null; } public List getAvailableTools() { return null; } @@ -97,6 +100,7 @@ private AiMuxError() {} public final boolean isAIToolCallNotFoundForApprovalError() { return "AI_ToolCallNotFoundForApprovalError".equals(getName()); } public final boolean isAIToolCallRepairError() { return "AI_ToolCallRepairError".equals(getName()); } public final boolean isAIToolChoiceViolationError() { return "AI_ToolChoiceViolationError".equals(getName()); } + public final boolean isAITypeValidationError() { return "AI_TypeValidationError".equals(getName()); } public final boolean isAIUnsupportedFunctionalityError() { return "AI_UnsupportedFunctionalityError".equals(getName()); } public final boolean isAbortError() { return "AbortError".equals(getName()); } public final boolean isTimeoutError() { return "TimeoutError".equals(getName()); } @@ -339,12 +343,16 @@ public Builder reason(RetryErrorReason value) { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIJSONParseError extends AiMuxError { + @JsonProperty("cause") + private final ErrorCause cause; @JsonProperty("message") private final String message; @JsonCreator public AIJSONParseError( + @JsonProperty(value = "cause") ErrorCause cause, @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; this.message = message; } @@ -353,17 +361,24 @@ public AIJSONParseError( @Override @JsonProperty("name") public String getName() { return "AI_JSONParseError"; } + public ErrorCause getCause() { return cause; } public String getMessage() { return message; } public static final class Builder { + private ErrorCause cause; private String message; + public Builder cause(ErrorCause value) { + this.cause = value; + return this; + } + public Builder message(String value) { this.message = value; return this; } - public AIJSONParseError build() { return new AIJSONParseError(message); } + public AIJSONParseError build() { return new AIJSONParseError(cause, message); } } } @@ -373,12 +388,16 @@ public Builder message(String value) { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AINoOutputGeneratedError extends AiMuxError { + @JsonProperty("cause") + private final ErrorCause cause; @JsonProperty("message") private final String message; @JsonCreator public AINoOutputGeneratedError( + @JsonProperty(value = "cause") ErrorCause cause, @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; this.message = message; } @@ -387,17 +406,24 @@ public AINoOutputGeneratedError( @Override @JsonProperty("name") public String getName() { return "AI_NoOutputGeneratedError"; } + public ErrorCause getCause() { return cause; } public String getMessage() { return message; } public static final class Builder { + private ErrorCause cause; private String message; + public Builder cause(ErrorCause value) { + this.cause = value; + return this; + } + public Builder message(String value) { this.message = value; return this; } - public AINoOutputGeneratedError build() { return new AINoOutputGeneratedError(message); } + public AINoOutputGeneratedError build() { return new AINoOutputGeneratedError(cause, message); } } } @@ -407,12 +433,16 @@ public Builder message(String value) { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIInvalidResponseDataError extends AiMuxError { + @JsonProperty("cause") + private final ErrorCause cause; @JsonProperty("message") private final String message; @JsonCreator public AIInvalidResponseDataError( + @JsonProperty(value = "cause") ErrorCause cause, @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; this.message = message; } @@ -421,17 +451,80 @@ public AIInvalidResponseDataError( @Override @JsonProperty("name") public String getName() { return "AI_InvalidResponseDataError"; } + public ErrorCause getCause() { return cause; } + public String getMessage() { return message; } + + public static final class Builder { + private ErrorCause cause; + private String message; + + public Builder cause(ErrorCause value) { + this.cause = value; + return this; + } + + public Builder message(String value) { + this.message = value; + return this; + } + + public AIInvalidResponseDataError build() { return new AIInvalidResponseDataError(cause, message); } + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE) + @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) + @JsonDeserialize(using = JsonDeserializer.None.class) + public static final class AITypeValidationError extends AiMuxError { + @JsonProperty("cause") + private final ErrorCause cause; + @JsonProperty("message") + private final String message; + @JsonProperty("value") + private final JsonNode value; + + @JsonCreator + public AITypeValidationError( + @JsonProperty(value = "cause") ErrorCause cause, + @JsonProperty(value = "message", required = true) String message, + @JsonProperty(value = "value", required = true) JsonNode value) { + this.cause = cause; + this.message = message; + this.value = value == null ? null : value.deepCopy(); + } + + public static Builder builder() { return new Builder(); } + + @Override + @JsonProperty("name") + public String getName() { return "AI_TypeValidationError"; } + public ErrorCause getCause() { return cause; } public String getMessage() { return message; } + public JsonNode getValue() { return value == null ? null : value.deepCopy(); } public static final class Builder { + private ErrorCause cause; private String message; + private JsonNode value; + + public Builder cause(ErrorCause value) { + this.cause = value; + return this; + } public Builder message(String value) { this.message = value; return this; } - public AIInvalidResponseDataError build() { return new AIInvalidResponseDataError(message); } + public Builder value(JsonNode value) { + this.value = value; + return this; + } + + public AITypeValidationError build() { return new AITypeValidationError(cause, message, value); } } } @@ -543,7 +636,7 @@ public Builder toolName(String value) { @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIInvalidToolInputError extends AiMuxError { @JsonProperty("cause") - private final String cause; + private final ErrorCause cause; @JsonProperty("toolInput") private final String toolInput; @JsonProperty("toolName") @@ -551,7 +644,7 @@ public static final class AIInvalidToolInputError extends AiMuxError { @JsonCreator public AIInvalidToolInputError( - @JsonProperty(value = "cause", required = true) String cause, + @JsonProperty(value = "cause", required = true) ErrorCause cause, @JsonProperty(value = "toolInput", required = true) String toolInput, @JsonProperty(value = "toolName", required = true) String toolName) { this.cause = cause; @@ -564,16 +657,16 @@ public AIInvalidToolInputError( @Override @JsonProperty("name") public String getName() { return "AI_InvalidToolInputError"; } - public String getCause() { return cause; } + public ErrorCause getCause() { return cause; } public String getToolInput() { return toolInput; } public String getToolName() { return toolName; } public static final class Builder { - private String cause; + private ErrorCause cause; private String toolInput; private String toolName; - public Builder cause(String value) { + public Builder cause(ErrorCause value) { this.cause = value; return this; } @@ -643,12 +736,16 @@ public Builder originalError(AiMuxError value) { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIInvalidArgumentError extends AiMuxError { + @JsonProperty("cause") + private final ErrorCause cause; @JsonProperty("message") private final String message; @JsonCreator public AIInvalidArgumentError( + @JsonProperty(value = "cause") ErrorCause cause, @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; this.message = message; } @@ -657,17 +754,24 @@ public AIInvalidArgumentError( @Override @JsonProperty("name") public String getName() { return "AI_InvalidArgumentError"; } + public ErrorCause getCause() { return cause; } public String getMessage() { return message; } public static final class Builder { + private ErrorCause cause; private String message; + public Builder cause(ErrorCause value) { + this.cause = value; + return this; + } + public Builder message(String value) { this.message = value; return this; } - public AIInvalidArgumentError build() { return new AIInvalidArgumentError(message); } + public AIInvalidArgumentError build() { return new AIInvalidArgumentError(cause, message); } } } @@ -677,12 +781,16 @@ public Builder message(String value) { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIInvalidPromptError extends AiMuxError { + @JsonProperty("cause") + private final ErrorCause cause; @JsonProperty("message") private final String message; @JsonCreator public AIInvalidPromptError( + @JsonProperty(value = "cause") ErrorCause cause, @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; this.message = message; } @@ -691,17 +799,24 @@ public AIInvalidPromptError( @Override @JsonProperty("name") public String getName() { return "AI_InvalidPromptError"; } + public ErrorCause getCause() { return cause; } public String getMessage() { return message; } public static final class Builder { + private ErrorCause cause; private String message; + public Builder cause(ErrorCause value) { + this.cause = value; + return this; + } + public Builder message(String value) { this.message = value; return this; } - public AIInvalidPromptError build() { return new AIInvalidPromptError(message); } + public AIInvalidPromptError build() { return new AIInvalidPromptError(cause, message); } } } @@ -1181,12 +1296,16 @@ public Builder message(String value) { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIError extends AiMuxError { + @JsonProperty("cause") + private final ErrorCause cause; @JsonProperty("message") private final String message; @JsonCreator public AIError( + @JsonProperty(value = "cause") ErrorCause cause, @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; this.message = message; } @@ -1195,17 +1314,24 @@ public AIError( @Override @JsonProperty("name") public String getName() { return "AI_Error"; } + public ErrorCause getCause() { return cause; } public String getMessage() { return message; } public static final class Builder { + private ErrorCause cause; private String message; + public Builder cause(ErrorCause value) { + this.cause = value; + return this; + } + public Builder message(String value) { this.message = value; return this; } - public AIError build() { return new AIError(message); } + public AIError build() { return new AIError(cause, message); } } } } diff --git a/bindings/java/generated/ai/arcships/aimux/types/AiMuxErrorInput.java b/bindings/java/generated/ai/arcships/aimux/types/AiMuxErrorInput.java index 8140636ed..35bee3a27 100644 --- a/bindings/java/generated/ai/arcships/aimux/types/AiMuxErrorInput.java +++ b/bindings/java/generated/ai/arcships/aimux/types/AiMuxErrorInput.java @@ -14,8 +14,9 @@ * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. * - * `source()` follows the AI SDK's `cause`: an API failure's `cause`, a - * retry's last attempt and a repair's `cause`. + * 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. */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "name") // clang-format off @@ -25,6 +26,7 @@ @JsonSubTypes.Type(value = AiMuxErrorInput.AIJSONParseError.class, name = "AI_JSONParseError"), @JsonSubTypes.Type(value = AiMuxErrorInput.AINoOutputGeneratedError.class, name = "AI_NoOutputGeneratedError"), @JsonSubTypes.Type(value = AiMuxErrorInput.AIInvalidResponseDataError.class, name = "AI_InvalidResponseDataError"), + @JsonSubTypes.Type(value = AiMuxErrorInput.AITypeValidationError.class, name = "AI_TypeValidationError"), @JsonSubTypes.Type(value = AiMuxErrorInput.AIToolCallNotFoundForApprovalError.class, name = "AI_ToolCallNotFoundForApprovalError"), @JsonSubTypes.Type(value = AiMuxErrorInput.AINoSuchToolError.class, name = "AI_NoSuchToolError"), @JsonSubTypes.Type(value = AiMuxErrorInput.AIInvalidToolInputError.class, name = "AI_InvalidToolInputError"), @@ -61,6 +63,7 @@ private AiMuxErrorInput() {} public String getUrl() { return null; } public List getErrors() { return null; } public RetryErrorReason getReason() { return null; } + public JsonNode getValue() { return null; } public String getApprovalId() { return null; } public String getToolCallId() { return null; } public List getAvailableTools() { return null; } @@ -97,6 +100,7 @@ private AiMuxErrorInput() {} public final boolean isAIToolCallNotFoundForApprovalError() { return "AI_ToolCallNotFoundForApprovalError".equals(getName()); } public final boolean isAIToolCallRepairError() { return "AI_ToolCallRepairError".equals(getName()); } public final boolean isAIToolChoiceViolationError() { return "AI_ToolChoiceViolationError".equals(getName()); } + public final boolean isAITypeValidationError() { return "AI_TypeValidationError".equals(getName()); } public final boolean isAIUnsupportedFunctionalityError() { return "AI_UnsupportedFunctionalityError".equals(getName()); } public final boolean isAbortError() { return "AbortError".equals(getName()); } public final boolean isTimeoutError() { return "TimeoutError".equals(getName()); } @@ -339,12 +343,16 @@ public Builder reason(RetryErrorReason value) { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIJSONParseError extends AiMuxErrorInput { + @JsonProperty("cause") + private final ErrorCauseInput cause; @JsonProperty("message") private final String message; @JsonCreator public AIJSONParseError( + @JsonProperty(value = "cause") ErrorCauseInput cause, @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; this.message = message; } @@ -353,17 +361,24 @@ public AIJSONParseError( @Override @JsonProperty("name") public String getName() { return "AI_JSONParseError"; } + public ErrorCauseInput getCause() { return cause; } public String getMessage() { return message; } public static final class Builder { + private ErrorCauseInput cause; private String message; + public Builder cause(ErrorCauseInput value) { + this.cause = value; + return this; + } + public Builder message(String value) { this.message = value; return this; } - public AIJSONParseError build() { return new AIJSONParseError(message); } + public AIJSONParseError build() { return new AIJSONParseError(cause, message); } } } @@ -373,12 +388,16 @@ public Builder message(String value) { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AINoOutputGeneratedError extends AiMuxErrorInput { + @JsonProperty("cause") + private final ErrorCauseInput cause; @JsonProperty("message") private final String message; @JsonCreator public AINoOutputGeneratedError( + @JsonProperty(value = "cause") ErrorCauseInput cause, @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; this.message = message; } @@ -387,17 +406,24 @@ public AINoOutputGeneratedError( @Override @JsonProperty("name") public String getName() { return "AI_NoOutputGeneratedError"; } + public ErrorCauseInput getCause() { return cause; } public String getMessage() { return message; } public static final class Builder { + private ErrorCauseInput cause; private String message; + public Builder cause(ErrorCauseInput value) { + this.cause = value; + return this; + } + public Builder message(String value) { this.message = value; return this; } - public AINoOutputGeneratedError build() { return new AINoOutputGeneratedError(message); } + public AINoOutputGeneratedError build() { return new AINoOutputGeneratedError(cause, message); } } } @@ -407,12 +433,16 @@ public Builder message(String value) { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIInvalidResponseDataError extends AiMuxErrorInput { + @JsonProperty("cause") + private final ErrorCauseInput cause; @JsonProperty("message") private final String message; @JsonCreator public AIInvalidResponseDataError( + @JsonProperty(value = "cause") ErrorCauseInput cause, @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; this.message = message; } @@ -421,17 +451,80 @@ public AIInvalidResponseDataError( @Override @JsonProperty("name") public String getName() { return "AI_InvalidResponseDataError"; } + public ErrorCauseInput getCause() { return cause; } + public String getMessage() { return message; } + + public static final class Builder { + private ErrorCauseInput cause; + private String message; + + public Builder cause(ErrorCauseInput value) { + this.cause = value; + return this; + } + + public Builder message(String value) { + this.message = value; + return this; + } + + public AIInvalidResponseDataError build() { return new AIInvalidResponseDataError(cause, message); } + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE) + @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) + @JsonDeserialize(using = JsonDeserializer.None.class) + public static final class AITypeValidationError extends AiMuxErrorInput { + @JsonProperty("cause") + private final ErrorCauseInput cause; + @JsonProperty("message") + private final String message; + @JsonProperty("value") + private final JsonNode value; + + @JsonCreator + public AITypeValidationError( + @JsonProperty(value = "cause") ErrorCauseInput cause, + @JsonProperty(value = "message", required = true) String message, + @JsonProperty(value = "value", required = true) JsonNode value) { + this.cause = cause; + this.message = message; + this.value = value == null ? null : value.deepCopy(); + } + + public static Builder builder() { return new Builder(); } + + @Override + @JsonProperty("name") + public String getName() { return "AI_TypeValidationError"; } + public ErrorCauseInput getCause() { return cause; } public String getMessage() { return message; } + public JsonNode getValue() { return value == null ? null : value.deepCopy(); } public static final class Builder { + private ErrorCauseInput cause; private String message; + private JsonNode value; + + public Builder cause(ErrorCauseInput value) { + this.cause = value; + return this; + } public Builder message(String value) { this.message = value; return this; } - public AIInvalidResponseDataError build() { return new AIInvalidResponseDataError(message); } + public Builder value(JsonNode value) { + this.value = value; + return this; + } + + public AITypeValidationError build() { return new AITypeValidationError(cause, message, value); } } } @@ -543,7 +636,7 @@ public Builder toolName(String value) { @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIInvalidToolInputError extends AiMuxErrorInput { @JsonProperty("cause") - private final String cause; + private final ErrorCauseInput cause; @JsonProperty("toolInput") private final String toolInput; @JsonProperty("toolName") @@ -551,7 +644,7 @@ public static final class AIInvalidToolInputError extends AiMuxErrorInput { @JsonCreator public AIInvalidToolInputError( - @JsonProperty(value = "cause", required = true) String cause, + @JsonProperty(value = "cause", required = true) ErrorCauseInput cause, @JsonProperty(value = "toolInput", required = true) String toolInput, @JsonProperty(value = "toolName", required = true) String toolName) { this.cause = cause; @@ -564,16 +657,16 @@ public AIInvalidToolInputError( @Override @JsonProperty("name") public String getName() { return "AI_InvalidToolInputError"; } - public String getCause() { return cause; } + public ErrorCauseInput getCause() { return cause; } public String getToolInput() { return toolInput; } public String getToolName() { return toolName; } public static final class Builder { - private String cause; + private ErrorCauseInput cause; private String toolInput; private String toolName; - public Builder cause(String value) { + public Builder cause(ErrorCauseInput value) { this.cause = value; return this; } @@ -643,12 +736,16 @@ public Builder originalError(AiMuxErrorInput value) { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIInvalidArgumentError extends AiMuxErrorInput { + @JsonProperty("cause") + private final ErrorCauseInput cause; @JsonProperty("message") private final String message; @JsonCreator public AIInvalidArgumentError( + @JsonProperty(value = "cause") ErrorCauseInput cause, @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; this.message = message; } @@ -657,17 +754,24 @@ public AIInvalidArgumentError( @Override @JsonProperty("name") public String getName() { return "AI_InvalidArgumentError"; } + public ErrorCauseInput getCause() { return cause; } public String getMessage() { return message; } public static final class Builder { + private ErrorCauseInput cause; private String message; + public Builder cause(ErrorCauseInput value) { + this.cause = value; + return this; + } + public Builder message(String value) { this.message = value; return this; } - public AIInvalidArgumentError build() { return new AIInvalidArgumentError(message); } + public AIInvalidArgumentError build() { return new AIInvalidArgumentError(cause, message); } } } @@ -677,12 +781,16 @@ public Builder message(String value) { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIInvalidPromptError extends AiMuxErrorInput { + @JsonProperty("cause") + private final ErrorCauseInput cause; @JsonProperty("message") private final String message; @JsonCreator public AIInvalidPromptError( + @JsonProperty(value = "cause") ErrorCauseInput cause, @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; this.message = message; } @@ -691,17 +799,24 @@ public AIInvalidPromptError( @Override @JsonProperty("name") public String getName() { return "AI_InvalidPromptError"; } + public ErrorCauseInput getCause() { return cause; } public String getMessage() { return message; } public static final class Builder { + private ErrorCauseInput cause; private String message; + public Builder cause(ErrorCauseInput value) { + this.cause = value; + return this; + } + public Builder message(String value) { this.message = value; return this; } - public AIInvalidPromptError build() { return new AIInvalidPromptError(message); } + public AIInvalidPromptError build() { return new AIInvalidPromptError(cause, message); } } } @@ -1181,12 +1296,16 @@ public Builder message(String value) { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIError extends AiMuxErrorInput { + @JsonProperty("cause") + private final ErrorCauseInput cause; @JsonProperty("message") private final String message; @JsonCreator public AIError( + @JsonProperty(value = "cause") ErrorCauseInput cause, @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; this.message = message; } @@ -1195,17 +1314,24 @@ public AIError( @Override @JsonProperty("name") public String getName() { return "AI_Error"; } + public ErrorCauseInput getCause() { return cause; } public String getMessage() { return message; } public static final class Builder { + private ErrorCauseInput cause; private String message; + public Builder cause(ErrorCauseInput value) { + this.cause = value; + return this; + } + public Builder message(String value) { this.message = value; return this; } - public AIError build() { return new AIError(message); } + public AIError build() { return new AIError(cause, message); } } } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java index 268ae860e..32dfe172a 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java @@ -15,6 +15,7 @@ static AimuxException decode(ErrorEnvelope envelope) { case "AI_Error": return new OtherError(envelope); case "AI_JSONParseError": return new JSONParseError(envelope); case "AI_InvalidResponseDataError": return new InvalidResponseDataError(envelope); + case "AI_TypeValidationError": return new TypeValidationError(envelope); case "AI_ToolCallNotFoundForApprovalError": return new ToolCallNotFoundForApprovalError(envelope); case "AI_InvalidArgumentError": return new InvalidArgumentError(envelope); case "AI_InvalidPromptError": return new InvalidPromptError(envelope); @@ -47,6 +48,9 @@ public static final class JSONParseError extends AimuxException { public static final class InvalidResponseDataError extends AimuxException { InvalidResponseDataError(ErrorEnvelope envelope) { super(envelope); } } + public static final class TypeValidationError extends AimuxException { + TypeValidationError(ErrorEnvelope envelope) { super(envelope); } + } public static final class ToolCallNotFoundForApprovalError extends AimuxException { ToolCallNotFoundForApprovalError(ErrorEnvelope envelope) { super(envelope); } } diff --git a/bindings/java/src/test/java/ai/arcships/aimux/AbiContractTest.java b/bindings/java/src/test/java/ai/arcships/aimux/AbiContractTest.java index 6f74fc074..7242c5958 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/AbiContractTest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/AbiContractTest.java @@ -180,7 +180,7 @@ private java.lang.ref.WeakReference abandon(int id, boolean close) { } @Test void exceptionSubtypesCoverEveryDomainVariantAndUseNameDiscriminator() { - String[] names = {"AI_Error", "AI_JSONParseError", "AI_InvalidResponseDataError", + String[] names = {"AI_Error", "AI_JSONParseError", "AI_InvalidResponseDataError", "AI_TypeValidationError", "AI_ToolCallNotFoundForApprovalError", "AI_InvalidArgumentError", "AI_InvalidPromptError", "AI_TokenExpiredError", "AI_UnsupportedFunctionalityError", "AI_NoSuchModelError", "AI_NoSuchProviderError", "AI_APICallError", "TimeoutError", "AbortError", "AI_RetryError", diff --git a/bindings/kotlin/generated/AimuxOps.kt b/bindings/kotlin/generated/AimuxOps.kt index e12815673..ad68f9646 100644 --- a/bindings/kotlin/generated/AimuxOps.kt +++ b/bindings/kotlin/generated/AimuxOps.kt @@ -1,10 +1,10 @@ -// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89 package ai.arcships.aimux.generated object AimuxKotlinOps { const val ABI_VERSION: Long = 65536 const val OPS_VERSION = "1.0" - const val MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" + const val MANIFEST_DIGEST = "ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89" const val OP_ABORT_CREATE = "abort.create" const val OP_ABORT_SIGNAL = "abort.signal" const val OP_AUTH_CODEX_REFRESH = "auth.codexRefresh" diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/kotlin/Errors.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/kotlin/Errors.kt index 83e9c662b..5233da4f0 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/kotlin/Errors.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/kotlin/Errors.kt @@ -22,6 +22,7 @@ sealed class AimuxException protected constructor(val javaError: NativeException is JavaAimuxException.NoOutputGeneratedError -> NoOutputGeneratedError(error) is JavaAimuxException.JSONParseError -> JSONParseError(error) is JavaAimuxException.InvalidResponseDataError -> InvalidResponseDataError(error) + is JavaAimuxException.TypeValidationError -> TypeValidationError(error) is JavaAimuxException.ToolCallNotFoundForApprovalError -> ToolCallNotFoundForApprovalError(error) is JavaAimuxException.InvalidArgumentError -> InvalidArgumentError(error) is JavaAimuxException.InvalidPromptError -> InvalidPromptError(error) @@ -52,6 +53,7 @@ sealed class AimuxException protected constructor(val javaError: NativeException class NoOutputGeneratedError internal constructor(val details: JavaAimuxException.NoOutputGeneratedError) : AimuxException(details) class JSONParseError internal constructor(val details: JavaAimuxException.JSONParseError) : AimuxException(details) class InvalidResponseDataError internal constructor(val details: JavaAimuxException.InvalidResponseDataError) : AimuxException(details) +class TypeValidationError internal constructor(val details: JavaAimuxException.TypeValidationError) : AimuxException(details) class ToolCallNotFoundForApprovalError internal constructor(val details: JavaAimuxException.ToolCallNotFoundForApprovalError) : AimuxException(details) class InvalidArgumentError internal constructor(val details: JavaAimuxException.InvalidArgumentError) : AimuxException(details) class InvalidPromptError internal constructor(val details: JavaAimuxException.InvalidPromptError) : AimuxException(details) diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/ErrorsTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/ErrorsTest.kt index 9c5112211..031b77b9e 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/ErrorsTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/ErrorsTest.kt @@ -10,6 +10,7 @@ class ErrorsTest { is NoOutputGeneratedError -> "NoOutputGeneratedError" is JSONParseError -> "JSONParseError" is InvalidResponseDataError -> "InvalidResponseDataError" + is TypeValidationError -> "TypeValidationError" is ToolCallNotFoundForApprovalError -> "ToolCallNotFoundForApprovalError" is InvalidArgumentError -> "InvalidArgumentError" is InvalidPromptError -> "InvalidPromptError" @@ -37,11 +38,12 @@ class ErrorsTest { @Test fun `every Java core subtype maps to an exhaustive sealed variant`() { - val codes = listOf(18, 2, 3, 3, 5, 6, 19, 20, 7, 8, 9, 10, 11, 14, 12, 13, 15, 16, 17, 6, 3, 1) + val codes = listOf(18, 2, 3, 3, 3, 5, 6, 19, 20, 7, 8, 9, 10, 11, 14, 12, 13, 15, 16, 17, 6, 3, 1) val variants = listOf( "NoOutputGeneratedError", "JSONParseError", "InvalidResponseDataError", + "TypeValidationError", "ToolCallNotFoundForApprovalError", "InvalidArgumentError", "InvalidPromptError", diff --git a/bindings/node/src/generated/dto_vectors.ts b/bindings/node/src/generated/dto_vectors.ts index 42b1764b0..a12e351a4 100644 --- a/bindings/node/src/generated/dto_vectors.ts +++ b/bindings/node/src/generated/dto_vectors.ts @@ -302,8 +302,8 @@ export interface DtoTypes { } export const dtoTags: Partial> = { - "AiMuxError": { tag: "name", values: ["AI_APICallError", "AI_RetryError", "AI_JSONParseError", "AI_NoOutputGeneratedError", "AI_InvalidResponseDataError", "AI_ToolCallNotFoundForApprovalError", "AI_NoSuchToolError", "AI_InvalidToolInputError", "AI_ToolCallRepairError", "AI_InvalidArgumentError", "AI_InvalidPromptError", "AI_LoadAPIKeyError", "AI_LoadSettingError", "AI_TokenExpiredError", "AI_UnsupportedFunctionalityError", "AI_NoSuchModelError", "AI_NoSuchProviderError", "AI_MissingToolResultsError", "AI_ToolChoiceViolationError", "TimeoutError", "AbortError", "AI_Error"] }, - "AiMuxErrorInput": { tag: "name", values: ["AI_APICallError", "AI_RetryError", "AI_JSONParseError", "AI_NoOutputGeneratedError", "AI_InvalidResponseDataError", "AI_ToolCallNotFoundForApprovalError", "AI_NoSuchToolError", "AI_InvalidToolInputError", "AI_ToolCallRepairError", "AI_InvalidArgumentError", "AI_InvalidPromptError", "AI_LoadAPIKeyError", "AI_LoadSettingError", "AI_TokenExpiredError", "AI_UnsupportedFunctionalityError", "AI_NoSuchModelError", "AI_NoSuchProviderError", "AI_MissingToolResultsError", "AI_ToolChoiceViolationError", "TimeoutError", "AbortError", "AI_Error"] }, + "AiMuxError": { tag: "name", values: ["AI_APICallError", "AI_RetryError", "AI_JSONParseError", "AI_NoOutputGeneratedError", "AI_InvalidResponseDataError", "AI_TypeValidationError", "AI_ToolCallNotFoundForApprovalError", "AI_NoSuchToolError", "AI_InvalidToolInputError", "AI_ToolCallRepairError", "AI_InvalidArgumentError", "AI_InvalidPromptError", "AI_LoadAPIKeyError", "AI_LoadSettingError", "AI_TokenExpiredError", "AI_UnsupportedFunctionalityError", "AI_NoSuchModelError", "AI_NoSuchProviderError", "AI_MissingToolResultsError", "AI_ToolChoiceViolationError", "TimeoutError", "AbortError", "AI_Error"] }, + "AiMuxErrorInput": { tag: "name", values: ["AI_APICallError", "AI_RetryError", "AI_JSONParseError", "AI_NoOutputGeneratedError", "AI_InvalidResponseDataError", "AI_TypeValidationError", "AI_ToolCallNotFoundForApprovalError", "AI_NoSuchToolError", "AI_InvalidToolInputError", "AI_ToolCallRepairError", "AI_InvalidArgumentError", "AI_InvalidPromptError", "AI_LoadAPIKeyError", "AI_LoadSettingError", "AI_TokenExpiredError", "AI_UnsupportedFunctionalityError", "AI_NoSuchModelError", "AI_NoSuchProviderError", "AI_MissingToolResultsError", "AI_ToolChoiceViolationError", "TimeoutError", "AbortError", "AI_Error"] }, "AssistantContentPart": { tag: "type", values: ["text", "custom", "file", "reasoning", "reasoning-file", "tool-call", "tool-result", "tool-approval-request"] }, "AssistantPart": { tag: "type", values: ["text", "file", "reasoning", "reasoning-file", "custom", "tool-call", "tool-result"] }, "BackendConfig": { tag: "type", values: ["ring", "jsonl"] }, diff --git a/bindings/node/src/generated/ops.ts b/bindings/node/src/generated/ops.ts index fb7399175..f17015cb8 100644 --- a/bindings/node/src/generated/ops.ts +++ b/bindings/node/src/generated/ops.ts @@ -1,7 +1,7 @@ -// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89 export const ABI_VERSION = 65536 as const; export const OPS_VERSION = "1.0" as const; -export const MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" as const; +export const MANIFEST_DIGEST = "ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89" as const; export const OP_ABORT_CREATE = "abort.create" as const; export const OP_ABORT_SIGNAL = "abort.signal" as const; export const OP_AUTH_CODEX_REFRESH = "auth.codexRefresh" as const; diff --git a/bindings/node/src/generated/types.ts b/bindings/node/src/generated/types.ts index 618e31108..91a618050 100644 --- a/bindings/node/src/generated/types.ts +++ b/bindings/node/src/generated/types.ts @@ -7,8 +7,9 @@ * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. * - * `source()` follows the AI SDK's `cause`: an API failure's `cause`, a - * retry's last attempt and a repair's `cause`. + * 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 = | { @@ -78,16 +79,25 @@ export type AiMuxError = } | { name: "AI_JSONParseError" + cause?: ErrorCause | null message: string } | { name: "AI_NoOutputGeneratedError" + cause?: ErrorCause | null message: string } | { name: "AI_InvalidResponseDataError" + cause?: ErrorCause | null message: string } + | { + name: "AI_TypeValidationError" + cause?: ErrorCause | null + message: string + value: unknown + } | { name: "AI_ToolCallNotFoundForApprovalError" approvalId: string @@ -101,7 +111,7 @@ export type AiMuxError = } | { name: "AI_InvalidToolInputError" - cause: string + cause: ErrorCause toolInput: string toolName: string } @@ -112,10 +122,12 @@ export type AiMuxError = } | { name: "AI_InvalidArgumentError" + cause?: ErrorCause | null message: string } | { name: "AI_InvalidPromptError" + cause?: ErrorCause | null message: string } | { @@ -190,6 +202,7 @@ export type AiMuxError = } | { name: "AI_Error" + cause?: ErrorCause | null message: string } @@ -200,8 +213,9 @@ export type AiMuxError = * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. * - * `source()` follows the AI SDK's `cause`: an API failure's `cause`, a - * retry's last attempt and a repair's `cause`. + * 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 AiMuxErrorInput = | { @@ -271,16 +285,25 @@ export type AiMuxErrorInput = } | { name: "AI_JSONParseError" + cause?: ErrorCauseInput | null message: string } | { name: "AI_NoOutputGeneratedError" + cause?: ErrorCauseInput | null message: string } | { name: "AI_InvalidResponseDataError" + cause?: ErrorCauseInput | null message: string } + | { + name: "AI_TypeValidationError" + cause?: ErrorCauseInput | null + message: string + value: unknown + } | { name: "AI_ToolCallNotFoundForApprovalError" approvalId: string @@ -294,7 +317,7 @@ export type AiMuxErrorInput = } | { name: "AI_InvalidToolInputError" - cause: string + cause: ErrorCauseInput toolInput: string toolName: string } @@ -305,10 +328,12 @@ export type AiMuxErrorInput = } | { name: "AI_InvalidArgumentError" + cause?: ErrorCauseInput | null message: string } | { name: "AI_InvalidPromptError" + cause?: ErrorCauseInput | null message: string } | { @@ -383,6 +408,7 @@ export type AiMuxErrorInput = } | { name: "AI_Error" + cause?: ErrorCauseInput | null message: string } diff --git a/bindings/python/python/aimux/_errors.py b/bindings/python/python/aimux/_errors.py index 0aef9b621..b8884139a 100644 --- a/bindings/python/python/aimux/_errors.py +++ b/bindings/python/python/aimux/_errors.py @@ -28,6 +28,7 @@ class RetryError(AimuxError): pass class JSONParseError(AimuxError): pass class NoOutputGeneratedError(AimuxError): pass class InvalidResponseDataError(AimuxError): pass +class TypeValidationError(AimuxError): pass class ToolCallNotFoundForApprovalError(AimuxError): pass class NoSuchToolError(AimuxError): pass class InvalidToolInputError(AimuxError): pass @@ -53,6 +54,7 @@ class OtherError(AimuxError): pass "AI_JSONParseError": JSONParseError, "AI_NoOutputGeneratedError": NoOutputGeneratedError, "AI_InvalidResponseDataError": InvalidResponseDataError, + "AI_TypeValidationError": TypeValidationError, "AI_ToolCallNotFoundForApprovalError": ToolCallNotFoundForApprovalError, "AI_NoSuchToolError": NoSuchToolError, "AI_InvalidToolInputError": InvalidToolInputError, diff --git a/bindings/python/python/aimux/_types.py b/bindings/python/python/aimux/_types.py index f4ec76412..28664e4d8 100644 --- a/bindings/python/python/aimux/_types.py +++ b/bindings/python/python/aimux/_types.py @@ -127,19 +127,29 @@ class AiMuxErrorAIRetryError(_Wire): class AiMuxErrorAIJSONParseError(_Wire): name: Literal["AI_JSONParseError"] = Field(default="AI_JSONParseError", alias="name") + cause: Optional["_dto.ErrorCause"] = Field(default=None, alias="cause") message: str = Field(alias="message") class AiMuxErrorAINoOutputGeneratedError(_Wire): name: Literal["AI_NoOutputGeneratedError"] = Field(default="AI_NoOutputGeneratedError", alias="name") + cause: Optional["_dto.ErrorCause"] = Field(default=None, alias="cause") message: str = Field(alias="message") class AiMuxErrorAIInvalidResponseDataError(_Wire): name: Literal["AI_InvalidResponseDataError"] = Field(default="AI_InvalidResponseDataError", alias="name") + cause: Optional["_dto.ErrorCause"] = Field(default=None, alias="cause") message: str = Field(alias="message") +class AiMuxErrorAITypeValidationError(_Wire): + name: Literal["AI_TypeValidationError"] = Field(default="AI_TypeValidationError", alias="name") + cause: Optional["_dto.ErrorCause"] = Field(default=None, alias="cause") + message: str = Field(alias="message") + value: Any = Field(alias="value") + + class AiMuxErrorAIToolCallNotFoundForApprovalError(_Wire): name: Literal["AI_ToolCallNotFoundForApprovalError"] = Field(default="AI_ToolCallNotFoundForApprovalError", alias="name") approval_id: str = Field(alias="approvalId") @@ -155,7 +165,7 @@ class AiMuxErrorAINoSuchToolError(_Wire): class AiMuxErrorAIInvalidToolInputError(_Wire): name: Literal["AI_InvalidToolInputError"] = Field(default="AI_InvalidToolInputError", alias="name") - cause: str = Field(alias="cause") + cause: "_dto.ErrorCause" = Field(alias="cause") tool_input: str = Field(alias="toolInput") tool_name: str = Field(alias="toolName") @@ -168,11 +178,13 @@ class AiMuxErrorAIToolCallRepairError(_Wire): class AiMuxErrorAIInvalidArgumentError(_Wire): name: Literal["AI_InvalidArgumentError"] = Field(default="AI_InvalidArgumentError", alias="name") + cause: Optional["_dto.ErrorCause"] = Field(default=None, alias="cause") message: str = Field(alias="message") class AiMuxErrorAIInvalidPromptError(_Wire): name: Literal["AI_InvalidPromptError"] = Field(default="AI_InvalidPromptError", alias="name") + cause: Optional["_dto.ErrorCause"] = Field(default=None, alias="cause") message: str = Field(alias="message") @@ -246,6 +258,7 @@ class AiMuxErrorAbortError(_Wire): class AiMuxErrorAIError(_Wire): name: Literal["AI_Error"] = Field(default="AI_Error", alias="name") + cause: Optional["_dto.ErrorCause"] = Field(default=None, alias="cause") message: str = Field(alias="message") @@ -255,9 +268,10 @@ class AiMuxErrorAIError(_Wire): #: failure came from. `ApiCallError` is boxed only to keep the Rust enum #: compact; serde and every binding still observe the same object shape. #: -#: `source()` follows the AI SDK's `cause`: an API failure's `cause`, a -#: retry's last attempt and a repair's `cause`. -AiMuxError = TypeAliasType("AiMuxError", Annotated[Union["AiMuxErrorAIAPICallError", "AiMuxErrorAIRetryError", "AiMuxErrorAIJSONParseError", "AiMuxErrorAINoOutputGeneratedError", "AiMuxErrorAIInvalidResponseDataError", "AiMuxErrorAIToolCallNotFoundForApprovalError", "AiMuxErrorAINoSuchToolError", "AiMuxErrorAIInvalidToolInputError", "AiMuxErrorAIToolCallRepairError", "AiMuxErrorAIInvalidArgumentError", "AiMuxErrorAIInvalidPromptError", "AiMuxErrorAILoadAPIKeyError", "AiMuxErrorAILoadSettingError", "AiMuxErrorAITokenExpiredError", "AiMuxErrorAIUnsupportedFunctionalityError", "AiMuxErrorAINoSuchModelError", "AiMuxErrorAINoSuchProviderError", "AiMuxErrorAIMissingToolResultsError", "AiMuxErrorAIToolChoiceViolationError", "AiMuxErrorTimeoutError", "AiMuxErrorAbortError", "AiMuxErrorAIError"], Field(discriminator="name")]) +#: 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. +AiMuxError = TypeAliasType("AiMuxError", Annotated[Union["AiMuxErrorAIAPICallError", "AiMuxErrorAIRetryError", "AiMuxErrorAIJSONParseError", "AiMuxErrorAINoOutputGeneratedError", "AiMuxErrorAIInvalidResponseDataError", "AiMuxErrorAITypeValidationError", "AiMuxErrorAIToolCallNotFoundForApprovalError", "AiMuxErrorAINoSuchToolError", "AiMuxErrorAIInvalidToolInputError", "AiMuxErrorAIToolCallRepairError", "AiMuxErrorAIInvalidArgumentError", "AiMuxErrorAIInvalidPromptError", "AiMuxErrorAILoadAPIKeyError", "AiMuxErrorAILoadSettingError", "AiMuxErrorAITokenExpiredError", "AiMuxErrorAIUnsupportedFunctionalityError", "AiMuxErrorAINoSuchModelError", "AiMuxErrorAINoSuchProviderError", "AiMuxErrorAIMissingToolResultsError", "AiMuxErrorAIToolChoiceViolationError", "AiMuxErrorTimeoutError", "AiMuxErrorAbortError", "AiMuxErrorAIError"], Field(discriminator="name")]) class AiMuxErrorInputAIAPICallError(_Wire): name: Literal["AI_APICallError"] = Field(default="AI_APICallError", alias="name") @@ -309,19 +323,29 @@ class AiMuxErrorInputAIRetryError(_Wire): class AiMuxErrorInputAIJSONParseError(_Wire): name: Literal["AI_JSONParseError"] = Field(default="AI_JSONParseError", alias="name") + cause: Optional["_dto.ErrorCauseInput"] = Field(default=None, alias="cause") message: str = Field(alias="message") class AiMuxErrorInputAINoOutputGeneratedError(_Wire): name: Literal["AI_NoOutputGeneratedError"] = Field(default="AI_NoOutputGeneratedError", alias="name") + cause: Optional["_dto.ErrorCauseInput"] = Field(default=None, alias="cause") message: str = Field(alias="message") class AiMuxErrorInputAIInvalidResponseDataError(_Wire): name: Literal["AI_InvalidResponseDataError"] = Field(default="AI_InvalidResponseDataError", alias="name") + cause: Optional["_dto.ErrorCauseInput"] = Field(default=None, alias="cause") message: str = Field(alias="message") +class AiMuxErrorInputAITypeValidationError(_Wire): + name: Literal["AI_TypeValidationError"] = Field(default="AI_TypeValidationError", alias="name") + cause: Optional["_dto.ErrorCauseInput"] = Field(default=None, alias="cause") + message: str = Field(alias="message") + value: Any = Field(alias="value") + + class AiMuxErrorInputAIToolCallNotFoundForApprovalError(_Wire): name: Literal["AI_ToolCallNotFoundForApprovalError"] = Field(default="AI_ToolCallNotFoundForApprovalError", alias="name") approval_id: str = Field(alias="approvalId") @@ -337,7 +361,7 @@ class AiMuxErrorInputAINoSuchToolError(_Wire): class AiMuxErrorInputAIInvalidToolInputError(_Wire): name: Literal["AI_InvalidToolInputError"] = Field(default="AI_InvalidToolInputError", alias="name") - cause: str = Field(alias="cause") + cause: "_dto.ErrorCauseInput" = Field(alias="cause") tool_input: str = Field(alias="toolInput") tool_name: str = Field(alias="toolName") @@ -350,11 +374,13 @@ class AiMuxErrorInputAIToolCallRepairError(_Wire): class AiMuxErrorInputAIInvalidArgumentError(_Wire): name: Literal["AI_InvalidArgumentError"] = Field(default="AI_InvalidArgumentError", alias="name") + cause: Optional["_dto.ErrorCauseInput"] = Field(default=None, alias="cause") message: str = Field(alias="message") class AiMuxErrorInputAIInvalidPromptError(_Wire): name: Literal["AI_InvalidPromptError"] = Field(default="AI_InvalidPromptError", alias="name") + cause: Optional["_dto.ErrorCauseInput"] = Field(default=None, alias="cause") message: str = Field(alias="message") @@ -429,6 +455,7 @@ class AiMuxErrorInputAbortError(_Wire): class AiMuxErrorInputAIError(_Wire): name: Literal["AI_Error"] = Field(default="AI_Error", alias="name") + cause: Optional["_dto.ErrorCauseInput"] = Field(default=None, alias="cause") message: str = Field(alias="message") @@ -438,9 +465,10 @@ class AiMuxErrorInputAIError(_Wire): #: failure came from. `ApiCallError` is boxed only to keep the Rust enum #: compact; serde and every binding still observe the same object shape. #: -#: `source()` follows the AI SDK's `cause`: an API failure's `cause`, a -#: retry's last attempt and a repair's `cause`. -AiMuxErrorInput = TypeAliasType("AiMuxErrorInput", Annotated[Union["AiMuxErrorInputAIAPICallError", "AiMuxErrorInputAIRetryError", "AiMuxErrorInputAIJSONParseError", "AiMuxErrorInputAINoOutputGeneratedError", "AiMuxErrorInputAIInvalidResponseDataError", "AiMuxErrorInputAIToolCallNotFoundForApprovalError", "AiMuxErrorInputAINoSuchToolError", "AiMuxErrorInputAIInvalidToolInputError", "AiMuxErrorInputAIToolCallRepairError", "AiMuxErrorInputAIInvalidArgumentError", "AiMuxErrorInputAIInvalidPromptError", "AiMuxErrorInputAILoadAPIKeyError", "AiMuxErrorInputAILoadSettingError", "AiMuxErrorInputAITokenExpiredError", "AiMuxErrorInputAIUnsupportedFunctionalityError", "AiMuxErrorInputAINoSuchModelError", "AiMuxErrorInputAINoSuchProviderError", "AiMuxErrorInputAIMissingToolResultsError", "AiMuxErrorInputAIToolChoiceViolationError", "AiMuxErrorInputTimeoutError", "AiMuxErrorInputAbortError", "AiMuxErrorInputAIError"], Field(discriminator="name")]) +#: 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. +AiMuxErrorInput = TypeAliasType("AiMuxErrorInput", Annotated[Union["AiMuxErrorInputAIAPICallError", "AiMuxErrorInputAIRetryError", "AiMuxErrorInputAIJSONParseError", "AiMuxErrorInputAINoOutputGeneratedError", "AiMuxErrorInputAIInvalidResponseDataError", "AiMuxErrorInputAITypeValidationError", "AiMuxErrorInputAIToolCallNotFoundForApprovalError", "AiMuxErrorInputAINoSuchToolError", "AiMuxErrorInputAIInvalidToolInputError", "AiMuxErrorInputAIToolCallRepairError", "AiMuxErrorInputAIInvalidArgumentError", "AiMuxErrorInputAIInvalidPromptError", "AiMuxErrorInputAILoadAPIKeyError", "AiMuxErrorInputAILoadSettingError", "AiMuxErrorInputAITokenExpiredError", "AiMuxErrorInputAIUnsupportedFunctionalityError", "AiMuxErrorInputAINoSuchModelError", "AiMuxErrorInputAINoSuchProviderError", "AiMuxErrorInputAIMissingToolResultsError", "AiMuxErrorInputAIToolChoiceViolationError", "AiMuxErrorInputTimeoutError", "AiMuxErrorInputAbortError", "AiMuxErrorInputAIError"], Field(discriminator="name")]) #: What a failed API call observed — the field set of the AI SDK's #: `APICallError` (`statusCode` / `responseBody`), which this project's error @@ -4925,6 +4953,7 @@ class WarningOther(_Wire): AiMuxErrorAIJSONParseError.model_rebuild(_types_namespace=globals()) AiMuxErrorAINoOutputGeneratedError.model_rebuild(_types_namespace=globals()) AiMuxErrorAIInvalidResponseDataError.model_rebuild(_types_namespace=globals()) +AiMuxErrorAITypeValidationError.model_rebuild(_types_namespace=globals()) AiMuxErrorAIToolCallNotFoundForApprovalError.model_rebuild(_types_namespace=globals()) AiMuxErrorAINoSuchToolError.model_rebuild(_types_namespace=globals()) AiMuxErrorAIInvalidToolInputError.model_rebuild(_types_namespace=globals()) @@ -4947,6 +4976,7 @@ class WarningOther(_Wire): AiMuxErrorInputAIJSONParseError.model_rebuild(_types_namespace=globals()) AiMuxErrorInputAINoOutputGeneratedError.model_rebuild(_types_namespace=globals()) AiMuxErrorInputAIInvalidResponseDataError.model_rebuild(_types_namespace=globals()) +AiMuxErrorInputAITypeValidationError.model_rebuild(_types_namespace=globals()) AiMuxErrorInputAIToolCallNotFoundForApprovalError.model_rebuild(_types_namespace=globals()) AiMuxErrorInputAINoSuchToolError.model_rebuild(_types_namespace=globals()) AiMuxErrorInputAIInvalidToolInputError.model_rebuild(_types_namespace=globals()) @@ -5666,6 +5696,7 @@ class WarningOther(_Wire): "AiMuxErrorAIJSONParseError", "AiMuxErrorAINoOutputGeneratedError", "AiMuxErrorAIInvalidResponseDataError", + "AiMuxErrorAITypeValidationError", "AiMuxErrorAIToolCallNotFoundForApprovalError", "AiMuxErrorAINoSuchToolError", "AiMuxErrorAIInvalidToolInputError", @@ -5689,6 +5720,7 @@ class WarningOther(_Wire): "AiMuxErrorInputAIJSONParseError", "AiMuxErrorInputAINoOutputGeneratedError", "AiMuxErrorInputAIInvalidResponseDataError", + "AiMuxErrorInputAITypeValidationError", "AiMuxErrorInputAIToolCallNotFoundForApprovalError", "AiMuxErrorInputAINoSuchToolError", "AiMuxErrorInputAIInvalidToolInputError", diff --git a/bindings/python/python/aimux/ops.py b/bindings/python/python/aimux/ops.py index ee0dd9677..dcba7366f 100644 --- a/bindings/python/python/aimux/ops.py +++ b/bindings/python/python/aimux/ops.py @@ -1,7 +1,7 @@ -# Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 +# Generated by aimux-codegen. Do not edit. Manifest SHA-256: ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89 ABI_VERSION = 65536 OPS_VERSION = "1.0" -MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" +MANIFEST_DIGEST = "ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89" OP_ABORT_CREATE = "abort.create" OP_ABORT_SIGNAL = "abort.signal" OP_AUTH_CODEX_REFRESH = "auth.codexRefresh" diff --git a/bindings/swift/generated/AimuxOps.swift b/bindings/swift/generated/AimuxOps.swift index ed4483e51..8b07c7fac 100644 --- a/bindings/swift/generated/AimuxOps.swift +++ b/bindings/swift/generated/AimuxOps.swift @@ -1,8 +1,8 @@ -// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89 public enum AimuxOps { public static let ABI_VERSION: UInt32 = 65536 public static let OPS_VERSION = "1.0" - public static let MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" + public static let MANIFEST_DIGEST = "ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89" public static let OP_ABORT_CREATE = "abort.create" public static let OP_ABORT_SIGNAL = "abort.signal" public static let OP_AUTH_CODEX_REFRESH = "auth.codexRefresh" diff --git a/bindings/swift/generated/Types.swift b/bindings/swift/generated/Types.swift index a92fc54a5..0772abc65 100644 --- a/bindings/swift/generated/Types.swift +++ b/bindings/swift/generated/Types.swift @@ -151,8 +151,9 @@ private struct WireKey: CodingKey { /// failure came from. `ApiCallError` is boxed only to keep the Rust enum /// compact; serde and every binding still observe the same object shape. /// -/// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a -/// retry's last attempt and a repair's `cause`. +/// 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. public indirect enum AiMuxError: Codable, Equatable, Sendable { /// cause: The error this failure was built from (`APICallError.cause`): the /// transport error with its source chain, the body parse error, or the @@ -183,15 +184,16 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { /// url: Sanitized request URL. Required for every API-derived failure. case aiapiCallError(cause: ErrorCause?, data: FieldPresence, isRetryable: Bool, message: String, providerCode: String?, requestBodyValues: JSONValue, responseBody: String?, responseHeaders: [String: String]?, statusCode: UInt16?, url: String) case aiRetryError(errors: [AiMuxError], reason: RetryErrorReason) - case aijsonParseError(message: String) - case aiNoOutputGeneratedError(message: String) - case aiInvalidResponseDataError(message: String) + case aijsonParseError(cause: ErrorCause?, message: String) + case aiNoOutputGeneratedError(cause: ErrorCause?, message: String) + case aiInvalidResponseDataError(cause: ErrorCause?, message: String) + case aiTypeValidationError(cause: ErrorCause?, message: String, value: JSONValue) case aiToolCallNotFoundForApprovalError(approvalId: String, toolCallId: String) case aiNoSuchToolError(availableTools: [String]?, toolInput: String?, toolName: String) - case aiInvalidToolInputError(cause: String, toolInput: String, toolName: String) + case aiInvalidToolInputError(cause: ErrorCause, toolInput: String, toolName: String) case aiToolCallRepairError(cause: AiMuxError, originalError: AiMuxError) - case aiInvalidArgumentError(message: String) - case aiInvalidPromptError(message: String) + case aiInvalidArgumentError(cause: ErrorCause?, message: String) + case aiInvalidPromptError(cause: ErrorCause?, message: String) case aiLoadAPIKeyError(description: String, envVar: String) /// settingName: `name` is the union's tag key, so the setting's name is `settingName`. case aiLoadSettingError(envVar: String, settingName: String) @@ -210,7 +212,7 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { case aiToolChoiceViolationError(content: [GenerateContent], finishReason: FinishReasonUnified, modelId: String, provider: String, toolChoice: LanguageModelV4ToolChoice) case timeoutError(message: String) case abortError(message: String) - case aiError(message: String) + case aiError(cause: ErrorCause?, message: String) private enum CodingKeys: String, CodingKey { case approvalId = "approvalId" case availableProviders = "availableProviders" @@ -243,6 +245,7 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { case toolInput = "toolInput" case toolName = "toolName" case url = "url" + case value = "value" } public init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) @@ -252,22 +255,22 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { self = .aiapiCallError(cause: try c.decodeIfPresent(ErrorCause.self, forKey: .cause), data: try c.decodePresence(JSONValue.self, forKey: .data), isRetryable: try c.decode(Bool.self, forKey: .isRetryable), message: try c.decode(String.self, forKey: .message), providerCode: try c.decodeIfPresent(String.self, forKey: .providerCode), requestBodyValues: try c.decode(JSONValue.self, forKey: .requestBodyValues), responseBody: try c.decodeIfPresent(String.self, forKey: .responseBody), responseHeaders: try c.decodeIfPresent([String: String].self, forKey: .responseHeaders), statusCode: try c.decodeIfPresent(UInt16.self, forKey: .statusCode), url: try c.decode(String.self, forKey: .url)) return case "AI_Error": - self = .aiError(message: try c.decode(String.self, forKey: .message)) + self = .aiError(cause: try c.decodeIfPresent(ErrorCause.self, forKey: .cause), message: try c.decode(String.self, forKey: .message)) return case "AI_InvalidArgumentError": - self = .aiInvalidArgumentError(message: try c.decode(String.self, forKey: .message)) + self = .aiInvalidArgumentError(cause: try c.decodeIfPresent(ErrorCause.self, forKey: .cause), message: try c.decode(String.self, forKey: .message)) return case "AI_InvalidPromptError": - self = .aiInvalidPromptError(message: try c.decode(String.self, forKey: .message)) + self = .aiInvalidPromptError(cause: try c.decodeIfPresent(ErrorCause.self, forKey: .cause), message: try c.decode(String.self, forKey: .message)) return case "AI_InvalidResponseDataError": - self = .aiInvalidResponseDataError(message: try c.decode(String.self, forKey: .message)) + self = .aiInvalidResponseDataError(cause: try c.decodeIfPresent(ErrorCause.self, forKey: .cause), message: try c.decode(String.self, forKey: .message)) return case "AI_InvalidToolInputError": - self = .aiInvalidToolInputError(cause: try c.decode(String.self, forKey: .cause), toolInput: try c.decode(String.self, forKey: .toolInput), toolName: try c.decode(String.self, forKey: .toolName)) + self = .aiInvalidToolInputError(cause: try c.decode(ErrorCause.self, forKey: .cause), toolInput: try c.decode(String.self, forKey: .toolInput), toolName: try c.decode(String.self, forKey: .toolName)) return case "AI_JSONParseError": - self = .aijsonParseError(message: try c.decode(String.self, forKey: .message)) + self = .aijsonParseError(cause: try c.decodeIfPresent(ErrorCause.self, forKey: .cause), message: try c.decode(String.self, forKey: .message)) return case "AI_LoadAPIKeyError": self = .aiLoadAPIKeyError(description: try c.decode(String.self, forKey: .description), envVar: try c.decode(String.self, forKey: .envVar)) @@ -279,7 +282,7 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { self = .aiMissingToolResultsError(toolCallIds: try c.decode([String].self, forKey: .toolCallIds)) return case "AI_NoOutputGeneratedError": - self = .aiNoOutputGeneratedError(message: try c.decode(String.self, forKey: .message)) + self = .aiNoOutputGeneratedError(cause: try c.decodeIfPresent(ErrorCause.self, forKey: .cause), message: try c.decode(String.self, forKey: .message)) return case "AI_NoSuchModelError": self = .aiNoSuchModelError(modelId: try c.decode(String.self, forKey: .modelId), modelType: try c.decode(String.self, forKey: .modelType)) @@ -305,6 +308,9 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { case "AI_ToolChoiceViolationError": self = .aiToolChoiceViolationError(content: try c.decode([GenerateContent].self, forKey: .content), finishReason: try c.decode(FinishReasonUnified.self, forKey: .finishReason), modelId: try c.decode(String.self, forKey: .modelId), provider: try c.decode(String.self, forKey: .provider), toolChoice: try c.decode(LanguageModelV4ToolChoice.self, forKey: .toolChoice)) return + case "AI_TypeValidationError": + self = .aiTypeValidationError(cause: try c.decodeIfPresent(ErrorCause.self, forKey: .cause), message: try c.decode(String.self, forKey: .message), value: try c.decode(JSONValue.self, forKey: .value)) + return case "AI_UnsupportedFunctionalityError": self = .aiUnsupportedFunctionalityError(message: try c.decode(String.self, forKey: .message)) return @@ -336,15 +342,23 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { try c.encode("AI_RetryError", forKey: .name) try c.encode(v0, forKey: .errors) try c.encode(v1, forKey: .reason) - case .aijsonParseError(let v0): + case .aijsonParseError(let v0, let v1): try c.encode("AI_JSONParseError", forKey: .name) - try c.encode(v0, forKey: .message) - case .aiNoOutputGeneratedError(let v0): + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) + case .aiNoOutputGeneratedError(let v0, let v1): try c.encode("AI_NoOutputGeneratedError", forKey: .name) - try c.encode(v0, forKey: .message) - case .aiInvalidResponseDataError(let v0): + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) + case .aiInvalidResponseDataError(let v0, let v1): try c.encode("AI_InvalidResponseDataError", forKey: .name) - try c.encode(v0, forKey: .message) + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) + case .aiTypeValidationError(let v0, let v1, let v2): + try c.encode("AI_TypeValidationError", forKey: .name) + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) + try c.encode(v2, forKey: .value) case .aiToolCallNotFoundForApprovalError(let v0, let v1): try c.encode("AI_ToolCallNotFoundForApprovalError", forKey: .name) try c.encode(v0, forKey: .approvalId) @@ -363,12 +377,14 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { try c.encode("AI_ToolCallRepairError", forKey: .name) try c.encode(v0, forKey: .cause) try c.encode(v1, forKey: .originalError) - case .aiInvalidArgumentError(let v0): + case .aiInvalidArgumentError(let v0, let v1): try c.encode("AI_InvalidArgumentError", forKey: .name) - try c.encode(v0, forKey: .message) - case .aiInvalidPromptError(let v0): + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) + case .aiInvalidPromptError(let v0, let v1): try c.encode("AI_InvalidPromptError", forKey: .name) - try c.encode(v0, forKey: .message) + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) case .aiLoadAPIKeyError(let v0, let v1): try c.encode("AI_LoadAPIKeyError", forKey: .name) try c.encode(v0, forKey: .description) @@ -409,9 +425,10 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { case .abortError(let v0): try c.encode("AbortError", forKey: .name) try c.encode(v0, forKey: .message) - case .aiError(let v0): + case .aiError(let v0, let v1): try c.encode("AI_Error", forKey: .name) - try c.encode(v0, forKey: .message) + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) } } } @@ -422,8 +439,9 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { /// failure came from. `ApiCallError` is boxed only to keep the Rust enum /// compact; serde and every binding still observe the same object shape. /// -/// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a -/// retry's last attempt and a repair's `cause`. +/// 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. public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { /// cause: The error this failure was built from (`APICallError.cause`): the /// transport error with its source chain, the body parse error, or the @@ -454,15 +472,16 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { /// url: Sanitized request URL. Required for every API-derived failure. case aiapiCallError(cause: ErrorCauseInput?, data: FieldPresence, isRetryable: Bool?, message: String, providerCode: String?, requestBodyValues: JSONValue, responseBody: String?, responseHeaders: [String: String]?, statusCode: UInt16?, url: String) case aiRetryError(errors: [AiMuxErrorInput], reason: RetryErrorReason) - case aijsonParseError(message: String) - case aiNoOutputGeneratedError(message: String) - case aiInvalidResponseDataError(message: String) + case aijsonParseError(cause: ErrorCauseInput?, message: String) + case aiNoOutputGeneratedError(cause: ErrorCauseInput?, message: String) + case aiInvalidResponseDataError(cause: ErrorCauseInput?, message: String) + case aiTypeValidationError(cause: ErrorCauseInput?, message: String, value: JSONValue) case aiToolCallNotFoundForApprovalError(approvalId: String, toolCallId: String) case aiNoSuchToolError(availableTools: [String]?, toolInput: String?, toolName: String) - case aiInvalidToolInputError(cause: String, toolInput: String, toolName: String) + case aiInvalidToolInputError(cause: ErrorCauseInput, toolInput: String, toolName: String) case aiToolCallRepairError(cause: AiMuxErrorInput, originalError: AiMuxErrorInput) - case aiInvalidArgumentError(message: String) - case aiInvalidPromptError(message: String) + case aiInvalidArgumentError(cause: ErrorCauseInput?, message: String) + case aiInvalidPromptError(cause: ErrorCauseInput?, message: String) case aiLoadAPIKeyError(description: String, envVar: String) /// settingName: `name` is the union's tag key, so the setting's name is `settingName`. case aiLoadSettingError(envVar: String, settingName: String) @@ -481,7 +500,7 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { case aiToolChoiceViolationError(content: [GenerateContent], finishReason: FinishReasonUnified, modelId: String, provider: String, toolChoice: LanguageModelV4ToolChoice) case timeoutError(message: String) case abortError(message: String) - case aiError(message: String) + case aiError(cause: ErrorCauseInput?, message: String) private enum CodingKeys: String, CodingKey { case approvalId = "approvalId" case availableProviders = "availableProviders" @@ -514,6 +533,7 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { case toolInput = "toolInput" case toolName = "toolName" case url = "url" + case value = "value" } public init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) @@ -523,22 +543,22 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { self = .aiapiCallError(cause: try c.decodeIfPresent(ErrorCauseInput.self, forKey: .cause), data: try c.decodePresence(JSONValue.self, forKey: .data), isRetryable: try c.decodeIfPresent(Bool.self, forKey: .isRetryable), message: try c.decode(String.self, forKey: .message), providerCode: try c.decodeIfPresent(String.self, forKey: .providerCode), requestBodyValues: try c.decode(JSONValue.self, forKey: .requestBodyValues), responseBody: try c.decodeIfPresent(String.self, forKey: .responseBody), responseHeaders: try c.decodeIfPresent([String: String].self, forKey: .responseHeaders), statusCode: try c.decodeIfPresent(UInt16.self, forKey: .statusCode), url: try c.decode(String.self, forKey: .url)) return case "AI_Error": - self = .aiError(message: try c.decode(String.self, forKey: .message)) + self = .aiError(cause: try c.decodeIfPresent(ErrorCauseInput.self, forKey: .cause), message: try c.decode(String.self, forKey: .message)) return case "AI_InvalidArgumentError": - self = .aiInvalidArgumentError(message: try c.decode(String.self, forKey: .message)) + self = .aiInvalidArgumentError(cause: try c.decodeIfPresent(ErrorCauseInput.self, forKey: .cause), message: try c.decode(String.self, forKey: .message)) return case "AI_InvalidPromptError": - self = .aiInvalidPromptError(message: try c.decode(String.self, forKey: .message)) + self = .aiInvalidPromptError(cause: try c.decodeIfPresent(ErrorCauseInput.self, forKey: .cause), message: try c.decode(String.self, forKey: .message)) return case "AI_InvalidResponseDataError": - self = .aiInvalidResponseDataError(message: try c.decode(String.self, forKey: .message)) + self = .aiInvalidResponseDataError(cause: try c.decodeIfPresent(ErrorCauseInput.self, forKey: .cause), message: try c.decode(String.self, forKey: .message)) return case "AI_InvalidToolInputError": - self = .aiInvalidToolInputError(cause: try c.decode(String.self, forKey: .cause), toolInput: try c.decode(String.self, forKey: .toolInput), toolName: try c.decode(String.self, forKey: .toolName)) + self = .aiInvalidToolInputError(cause: try c.decode(ErrorCauseInput.self, forKey: .cause), toolInput: try c.decode(String.self, forKey: .toolInput), toolName: try c.decode(String.self, forKey: .toolName)) return case "AI_JSONParseError": - self = .aijsonParseError(message: try c.decode(String.self, forKey: .message)) + self = .aijsonParseError(cause: try c.decodeIfPresent(ErrorCauseInput.self, forKey: .cause), message: try c.decode(String.self, forKey: .message)) return case "AI_LoadAPIKeyError": self = .aiLoadAPIKeyError(description: try c.decode(String.self, forKey: .description), envVar: try c.decode(String.self, forKey: .envVar)) @@ -550,7 +570,7 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { self = .aiMissingToolResultsError(toolCallIds: try c.decode([String].self, forKey: .toolCallIds)) return case "AI_NoOutputGeneratedError": - self = .aiNoOutputGeneratedError(message: try c.decode(String.self, forKey: .message)) + self = .aiNoOutputGeneratedError(cause: try c.decodeIfPresent(ErrorCauseInput.self, forKey: .cause), message: try c.decode(String.self, forKey: .message)) return case "AI_NoSuchModelError": self = .aiNoSuchModelError(modelId: try c.decode(String.self, forKey: .modelId), modelType: try c.decodeIfPresent(String.self, forKey: .modelType)) @@ -576,6 +596,9 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { case "AI_ToolChoiceViolationError": self = .aiToolChoiceViolationError(content: try c.decode([GenerateContent].self, forKey: .content), finishReason: try c.decode(FinishReasonUnified.self, forKey: .finishReason), modelId: try c.decode(String.self, forKey: .modelId), provider: try c.decode(String.self, forKey: .provider), toolChoice: try c.decode(LanguageModelV4ToolChoice.self, forKey: .toolChoice)) return + case "AI_TypeValidationError": + self = .aiTypeValidationError(cause: try c.decodeIfPresent(ErrorCauseInput.self, forKey: .cause), message: try c.decode(String.self, forKey: .message), value: try c.decode(JSONValue.self, forKey: .value)) + return case "AI_UnsupportedFunctionalityError": self = .aiUnsupportedFunctionalityError(message: try c.decode(String.self, forKey: .message)) return @@ -607,15 +630,23 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { try c.encode("AI_RetryError", forKey: .name) try c.encode(v0, forKey: .errors) try c.encode(v1, forKey: .reason) - case .aijsonParseError(let v0): + case .aijsonParseError(let v0, let v1): try c.encode("AI_JSONParseError", forKey: .name) - try c.encode(v0, forKey: .message) - case .aiNoOutputGeneratedError(let v0): + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) + case .aiNoOutputGeneratedError(let v0, let v1): try c.encode("AI_NoOutputGeneratedError", forKey: .name) - try c.encode(v0, forKey: .message) - case .aiInvalidResponseDataError(let v0): + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) + case .aiInvalidResponseDataError(let v0, let v1): try c.encode("AI_InvalidResponseDataError", forKey: .name) - try c.encode(v0, forKey: .message) + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) + case .aiTypeValidationError(let v0, let v1, let v2): + try c.encode("AI_TypeValidationError", forKey: .name) + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) + try c.encode(v2, forKey: .value) case .aiToolCallNotFoundForApprovalError(let v0, let v1): try c.encode("AI_ToolCallNotFoundForApprovalError", forKey: .name) try c.encode(v0, forKey: .approvalId) @@ -634,12 +665,14 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { try c.encode("AI_ToolCallRepairError", forKey: .name) try c.encode(v0, forKey: .cause) try c.encode(v1, forKey: .originalError) - case .aiInvalidArgumentError(let v0): + case .aiInvalidArgumentError(let v0, let v1): try c.encode("AI_InvalidArgumentError", forKey: .name) - try c.encode(v0, forKey: .message) - case .aiInvalidPromptError(let v0): + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) + case .aiInvalidPromptError(let v0, let v1): try c.encode("AI_InvalidPromptError", forKey: .name) - try c.encode(v0, forKey: .message) + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) case .aiLoadAPIKeyError(let v0, let v1): try c.encode("AI_LoadAPIKeyError", forKey: .name) try c.encode(v0, forKey: .description) @@ -680,9 +713,10 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { case .abortError(let v0): try c.encode("AbortError", forKey: .name) try c.encode(v0, forKey: .message) - case .aiError(let v0): + case .aiError(let v0, let v1): try c.encode("AI_Error", forKey: .name) - try c.encode(v0, forKey: .message) + try c.encodeIfPresent(v0, forKey: .cause) + try c.encode(v1, forKey: .message) } } } diff --git a/contract-tests/dto/AiMuxError/006.json b/contract-tests/dto/AiMuxError/006.json new file mode 100644 index 000000000..f349f6106 --- /dev/null +++ b/contract-tests/dto/AiMuxError/006.json @@ -0,0 +1,9 @@ +{ + "name": "AI_InvalidToolInputError", + "toolName": "weather", + "toolInput": "{", + "cause": { + "name": "AI_JSONParseError", + "message": "JSON parsing failed" + } +} diff --git a/contract-tests/dto/AiMuxError/007.json b/contract-tests/dto/AiMuxError/007.json new file mode 100644 index 000000000..c7c840c55 --- /dev/null +++ b/contract-tests/dto/AiMuxError/007.json @@ -0,0 +1,8 @@ +{ + "name": "AI_InvalidResponseDataError", + "message": "eof", + "cause": { + "name": "AI_JSONParseError", + "message": "Unexpected end of JSON input" + } +} diff --git a/contract-tests/dto/AiMuxError/008.json b/contract-tests/dto/AiMuxError/008.json new file mode 100644 index 000000000..55d2536d5 --- /dev/null +++ b/contract-tests/dto/AiMuxError/008.json @@ -0,0 +1,10 @@ +{ + "name": "AI_TypeValidationError", + "message": "Type validation failed: Value: {\"town\":\"SG\"}.\nError message: \"city\" is a required property", + "value": { + "town": "SG" + }, + "cause": { + "message": "\"city\" is a required property" + } +} diff --git a/contract-tests/dto/AiMuxError/009.json b/contract-tests/dto/AiMuxError/009.json new file mode 100644 index 000000000..59759da78 --- /dev/null +++ b/contract-tests/dto/AiMuxError/009.json @@ -0,0 +1,5 @@ +{ + "name": "AI_TypeValidationError", + "message": "x", + "value": null +} diff --git a/contract-tests/dto/AiMuxErrorInput/006.json b/contract-tests/dto/AiMuxErrorInput/006.json new file mode 100644 index 000000000..f349f6106 --- /dev/null +++ b/contract-tests/dto/AiMuxErrorInput/006.json @@ -0,0 +1,9 @@ +{ + "name": "AI_InvalidToolInputError", + "toolName": "weather", + "toolInput": "{", + "cause": { + "name": "AI_JSONParseError", + "message": "JSON parsing failed" + } +} diff --git a/contract-tests/dto/AiMuxErrorInput/007.json b/contract-tests/dto/AiMuxErrorInput/007.json new file mode 100644 index 000000000..c7c840c55 --- /dev/null +++ b/contract-tests/dto/AiMuxErrorInput/007.json @@ -0,0 +1,8 @@ +{ + "name": "AI_InvalidResponseDataError", + "message": "eof", + "cause": { + "name": "AI_JSONParseError", + "message": "Unexpected end of JSON input" + } +} diff --git a/contract-tests/dto/AiMuxErrorInput/008.json b/contract-tests/dto/AiMuxErrorInput/008.json new file mode 100644 index 000000000..55d2536d5 --- /dev/null +++ b/contract-tests/dto/AiMuxErrorInput/008.json @@ -0,0 +1,10 @@ +{ + "name": "AI_TypeValidationError", + "message": "Type validation failed: Value: {\"town\":\"SG\"}.\nError message: \"city\" is a required property", + "value": { + "town": "SG" + }, + "cause": { + "message": "\"city\" is a required property" + } +} diff --git a/contract-tests/dto/AiMuxErrorInput/009.json b/contract-tests/dto/AiMuxErrorInput/009.json new file mode 100644 index 000000000..59759da78 --- /dev/null +++ b/contract-tests/dto/AiMuxErrorInput/009.json @@ -0,0 +1,5 @@ +{ + "name": "AI_TypeValidationError", + "message": "x", + "value": null +} diff --git a/contract-tests/dto/index.json b/contract-tests/dto/index.json index ea6aa206f..e72f13087 100644 --- a/contract-tests/dto/index.json +++ b/contract-tests/dto/index.json @@ -19,6 +19,22 @@ "type": "AiMuxError", "file": "AiMuxError/005.json" }, + { + "type": "AiMuxError", + "file": "AiMuxError/006.json" + }, + { + "type": "AiMuxError", + "file": "AiMuxError/007.json" + }, + { + "type": "AiMuxError", + "file": "AiMuxError/008.json" + }, + { + "type": "AiMuxError", + "file": "AiMuxError/009.json" + }, { "type": "AiMuxErrorInput", "file": "AiMuxErrorInput/001.json" @@ -39,6 +55,22 @@ "type": "AiMuxErrorInput", "file": "AiMuxErrorInput/005.json" }, + { + "type": "AiMuxErrorInput", + "file": "AiMuxErrorInput/006.json" + }, + { + "type": "AiMuxErrorInput", + "file": "AiMuxErrorInput/007.json" + }, + { + "type": "AiMuxErrorInput", + "file": "AiMuxErrorInput/008.json" + }, + { + "type": "AiMuxErrorInput", + "file": "AiMuxErrorInput/009.json" + }, { "type": "AssistantContent", "file": "AssistantContent/001.json" diff --git a/contract-tests/dto/sources.json b/contract-tests/dto/sources.json index 80a7ba23c..714499ee9 100644 --- a/contract-tests/dto/sources.json +++ b/contract-tests/dto/sources.json @@ -24,6 +24,26 @@ "source": "aimux-provider-utils/src/post_to_api.rs", "pointer": "line 265" }, + { + "file": "AiMuxError/006.json", + "source": "contract-tests/ops/errors/aimux-invalid-tool-input.json", + "pointer": "/error" + }, + { + "file": "AiMuxError/007.json", + "source": "contract-tests/ops/errors/aimux-invalid-response-data.json", + "pointer": "/error" + }, + { + "file": "AiMuxError/008.json", + "source": "contract-tests/ops/errors/aimux-type-validation.json", + "pointer": "/error" + }, + { + "file": "AiMuxError/009.json", + "source": "aimux-core/tests/error_value_golden_test.rs", + "pointer": "line 351" + }, { "file": "AiMuxErrorInput/001.json", "source": "bindings/flutter/test/typed_round_trip_test.dart", @@ -49,6 +69,26 @@ "source": "aimux-provider-utils/src/post_to_api.rs", "pointer": "line 265" }, + { + "file": "AiMuxErrorInput/006.json", + "source": "contract-tests/ops/errors/aimux-invalid-tool-input.json", + "pointer": "/error" + }, + { + "file": "AiMuxErrorInput/007.json", + "source": "contract-tests/ops/errors/aimux-invalid-response-data.json", + "pointer": "/error" + }, + { + "file": "AiMuxErrorInput/008.json", + "source": "contract-tests/ops/errors/aimux-type-validation.json", + "pointer": "/error" + }, + { + "file": "AiMuxErrorInput/009.json", + "source": "aimux-core/tests/error_value_golden_test.rs", + "pointer": "line 351" + }, { "file": "AnyValue/001.json", "source": "bindings/flutter/test/typed_round_trip_test.dart", 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/contract-tests/ops/errors/aimux-type-validation.json b/contract-tests/ops/errors/aimux-type-validation.json new file mode 100644 index 000000000..16ea19647 --- /dev/null +++ b/contract-tests/ops/errors/aimux-type-validation.json @@ -0,0 +1,15 @@ +{ + "kind": "aimux", + "code": 3, + "message": "Type validation failed: Value: {\"town\":\"SG\"}.\nError message: \"city\" is a required property", + "error": { + "name": "AI_TypeValidationError", + "message": "Type validation failed: Value: {\"town\":\"SG\"}.\nError message: \"city\" is a required property", + "value": { + "town": "SG" + }, + "cause": { + "message": "\"city\" is a required property" + } + } +} diff --git a/docs/ai-sdk-request-pipeline.md b/docs/ai-sdk-request-pipeline.md index 33ace76e7..a8d461e73 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` | `APICallError` 与 AI SDK 一致带可选 `cause`:传输错误连同它的 source 链、2xx/非 2xx handler 自己失败的错误;Rust 里它就是 `Error::source()`,`Retry` 的 source 是最后一次尝试(`lastError`)。差异:非 aimux 的底层错误(OS、TLS、解析器)存成可克隆、可序列化的 `{message, cause?}` 链;message 仍保留原来拼接的底层错误文字,因为 C ABI 的 message getter 只看 message;传输错误先去掉 URL(query 可能带 key)。其他错误类的 `cause` 按 RFC-0047 分步加入,加入前底层错误仍追加到 message | +| `cause` | 与 AI SDK 一致:凡 AI SDK 构造时接受 `cause` 的错误都带可选 `cause`(`APICallError` 是传输错误连同它的 source 链、2xx/非 2xx handler 自己失败的错误;`InvalidToolInput`、`ToolCallRepair` 必有),不接受的保持没有;Rust 里它就是 `Error::source()`,`Retry` 的 source 是最后一次尝试(`lastError`)。差异:非 aimux 的底层错误(OS、TLS、解析器)存成可克隆、可序列化的 `{message, cause?}` 链;message 仍保留原来拼接的底层错误文字,因为 C ABI 的 message getter 只看 message;传输错误先去掉 URL(query 可能带 key) | | 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 只保留摘要 | diff --git a/docs/error-model.md b/docs/error-model.md index 6a49bdbc7..5e528ef0f 100644 --- a/docs/error-model.md +++ b/docs/error-model.md @@ -25,6 +25,20 @@ 结构化字段只放在真正拥有它的错误上。例如,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` 继续往下一层。message 仍包含原来的 +底层错误文字,所以只看 message 的调用方不会少信息。 + +值不符合调用方给的 schema 时,cause 是 `TypeValidation`(`AI_TypeValidationError`, +带 `value`),和上游一样:工具输入不符合 `inputSchema` 时是 +`InvalidToolInput.cause`,结构化输出不符合 schema 时是 `InvalidResponseData.cause`。 +它和 `InvalidResponseData` 共用错误码 3。响应体本身形状不对仍是 +`InvalidResponseData`。 + ## 兼容性约定 1. 已有错误名称和错误码不改名、不改号、不复用。 diff --git a/docs/rfc-0039-conformance.md b/docs/rfc-0039-conformance.md index ad284062e..7436e3d0a 100644 --- a/docs/rfc-0039-conformance.md +++ b/docs/rfc-0039-conformance.md @@ -258,7 +258,7 @@ unless explicitly labelled as a file-level reference. | R118 | §7.2 | Unconsumed package/catalogue metadata changes need not change consumed type hash; only explicit future stdio hello offers minor projection. | gen | gap — [G-h](rfc-0039-implementation.md#registered-gaps): No per-consumed-business-type hashes exist; metadata independence is not testable yet. | | R119 | §7.3 | Generate JSON Schema 2020-12 from manifest with stable resolving $id/type-ID refs and disjoint oneOf discriminators. | schema, controls, typeschemas | met — [D17](rfc-0039-implementation.md#registered-differences-from-rfc-0039): Generation and resolution are met; untagged unions are ordered rather than disjoint by design. `aimux-codegen` emits `schema/ops/*.json` and one `schema/types/.json` per business type (300 files, listed in `tools/aimux-codegen/generated-files.json`), each with a stable `$id` under `https://aimux.dev/schema/` and relative `$ref`s that resolve locally; drift fails the CI step "Ops manifest and generated artifact drift". Tagged unions are `oneOf` branches with a literal string `type` tag, and the generator rejects a tagged union whose branches lack one (`tools/aimux-codegen/src/model.rs`). Untagged unions are `anyOf` marked `x-aimux-codec: ordered-untagged` and decode by first match (`TestUntaggedObjectBranchSelection`), so their branches are ordered, not disjoint. | | R120 | §7.3 | Controls set additionalProperties:false; explicitly open providerOptions/metadata/JSON values; never globally strip private provider data. | schema, controls | met | -| R121 | §7.3 | Provide schemas and positive/negative vectors for requests/results/events/end/errors, all handles/options missing/null, integer ranges, base64 bytes, time strings and modalities. | schema, controls, dtovectors, dtocheck | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Schemas exist for all 300 business types and the control schemas. `contract-tests/dto` holds 230 positive round-trip vectors for 101 types, covering request, result, event, end and error shapes (for example `StreamFrame`, `ErrorEnvelope`, the modality results, `HandleId`, `Timestamp`, base64 bytes). The other types have no vector, and the corpus has no negative vectors; rejections are covered by per-binding unit tests and the schema tests in `tools/aimux-codegen/tests/generation.rs`. | +| R121 | §7.3 | Provide schemas and positive/negative vectors for requests/results/events/end/errors, all handles/options missing/null, integer ranges, base64 bytes, time strings and modalities. | schema, controls, dtovectors, dtocheck | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Schemas exist for all 300 business types and the control schemas. `contract-tests/dto` holds 238 positive round-trip vectors for 101 types, covering request, result, event, end and error shapes (for example `StreamFrame`, `ErrorEnvelope`, the modality results, `HandleId`, `Timestamp`, base64 bytes). The other types have no vector, and the corpus has no negative vectors; rejections are covered by per-binding unit tests and the schema tests in `tools/aimux-codegen/tests/generation.rs`. | | R122 | §7.3 | Schema validation does not replace live-handle, capability, state-machine or reentrancy runtime validation; duplicate request-ID checks belong to future request transport. | matrix | met | | R123 | §7.3 | Unknown business unions fail explicitly rather than decode to empty content; unknown-error projections preserve raw values without executing unknown behavior. | unknown, schema, pydto, dartdto | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Generated Go decoders (`bindings/go/types_gen.go`), Python (`test_dto_types.py`), Dart (`dto_codec_test.dart`) reject unknown union tags, Swift rejects retired ones (`WireTests.swift`), and shared unknown errors survive. Node exposes declarations without a codec, so it does not reject them ([D16](rfc-0039-implementation.md#registered-differences-from-rfc-0039)). No common unknown-error class or per-language evidence for every business union is supplied, and the vectors contain no unknown-tag cases. | | R124 | §7.3 | Different major, unsupported type capability or changed meaning of the same op must fail compatibility tests. | unknown, schema | gap — [G-h](rfc-0039-implementation.md#registered-gaps): ABI/ops mismatch tests exist; unsupported business-type capability checks cannot run without metadata. | @@ -302,7 +302,7 @@ unless explicitly labelled as a file-level reference. | R162 | §10.1 | EndFrame is transport control, separate from RFC-0042 business stream event types; no fifth StreamPart type system. | stream | met | | R163 | §10.2 | aimux-codegen --check regenerates in temporary directory and compares complete output sets; missing/stale/differing/format/digest/ref/doc-table drift fails; no divergent build.rs generation. | gencheck, gen | met — [D19](rfc-0039-implementation.md#registered-differences-from-rfc-0039): `cargo run -p aimux-codegen -- --check` (CI step "Ops manifest and generated artifact drift") regenerates into a temporary directory and compares the complete managed set, which now includes `schema/types/*.json`, every binding's DTO files and the web wire types; missing, stale, differing and digest drift fail (`check_detects_missing_stale_and_changed_files_without_rewriting`, `digest_and_previous_constant_drift_are_detected`). No build script generates outputs (`bindings/python/build.rs` only pins checked-in constants), and ts-rs and `scripts/gen_ts_types.py` are removed. Doc-table drift is not covered: no manifest-derived document table is generated, and the separate CI step "Providers doc generator drift" checks only `docs/api/providers.md` ([D19](rfc-0039-implementation.md#registered-differences-from-rfc-0039)). | | R164 | §10.2 | Generate op × kind × error-state matrix: allowed reaches correct probe handler; rejected never invokes provider; include 0/unknown/dropped/other runtime/exhausted. | matrix, exhaust | met | -| R165 | §10.2 | Each DTO vector goes Rust encode → eight-language decode/encode → Rust decode; ignore object-key order only; preserve array order/precision/missing/null/metadata/bytes and unknown raw values where permitted. | gowire, schema, dtovectors, dtocheck | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Each of the 230 vectors round-trips through the Rust oracle (`aimux-ops/src/dto_vectors.rs`) and then through the generated codecs of Node, Python, Go, Java, Dart and Swift via `scripts/check_dto_vectors.py`, which ignores key order only and parses floats as decimals; Kotlin reuses Java. Language output is compared with the Rust-encoded form rather than decoded again by Rust, only 101 of 300 types have vectors, and [D13](rfc-0039-implementation.md#registered-differences-from-rfc-0039), [D15](rfc-0039-implementation.md#registered-differences-from-rfc-0039) and [D16](rfc-0039-implementation.md#registered-differences-from-rfc-0039) relax Node and Java/Dart comparisons. | +| R165 | §10.2 | Each DTO vector goes Rust encode → eight-language decode/encode → Rust decode; ignore object-key order only; preserve array order/precision/missing/null/metadata/bytes and unknown raw values where permitted. | gowire, schema, dtovectors, dtocheck | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Each of the 238 vectors round-trips through the Rust oracle (`aimux-ops/src/dto_vectors.rs`) and then through the generated codecs of Node, Python, Go, Java, Dart and Swift via `scripts/check_dto_vectors.py`, which ignores key order only and parses floats as decimals; Kotlin reuses Java. Language output is compared with the Rust-encoded form rather than decoded again by Rust, only 101 of 300 types have vectors, and [D13](rfc-0039-implementation.md#registered-differences-from-rfc-0039), [D15](rfc-0039-implementation.md#registered-differences-from-rfc-0039) and [D16](rfc-0039-implementation.md#registered-differences-from-rfc-0039) relax Node and Java/Dart comparisons. | | R166 | §10.2 | Dispatch and every binding share semantic records for accepted, preflight failure, finish, recoverable/terminal error, cancel/late cancel, reader close, duplicate finish, serialization failure and full queue. | stream | gap — [G-i](rfc-0039-implementation.md#registered-gaps): All native shared vectors run; the external binding-driver hook remains ignored and full adapter execution is incomplete. | | R167 | §10.2 | Compare export allowlist and generated header both ways; old constructors/payload getters/header references must scan to zero. | exports, retired | met | | R168 | §10.2 | Node/Python Rust dependency trees never reintroduce retired provider entry points. | exports, retired | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Retired files are checked, but this test does not audit both adapters full dependency trees. | diff --git a/docs/rfc-0039-implementation.md b/docs/rfc-0039-implementation.md index c810599b6..8229f9da1 100644 --- a/docs/rfc-0039-implementation.md +++ b/docs/rfc-0039-implementation.md @@ -249,8 +249,9 @@ retry; generation policy stays in core and bindings add no retry loop. | HandleExhausted | 306 | AiMux uses 1–20 with 4 reserved; Recording uses 100–105 and Binding 200–206. -Code 3 aliases InvalidResponseData, ToolCallNotFoundForApproval and -ToolChoiceViolation; code 6 aliases InvalidPrompt and MissingToolResults. +Code 3 aliases InvalidResponseData, ToolCallNotFoundForApproval, +ToolChoiceViolation and TypeValidation; code 6 aliases InvalidPrompt and +MissingToolResults. ## Streams, Operation and limits diff --git a/rfc/0035-host-side-tool-call-repair.md b/rfc/0035-host-side-tool-call-repair.md index bfb1e2093..e7fbae7e9 100644 --- a/rfc/0035-host-side-tool-call-repair.md +++ b/rfc/0035-host-side-tool-call-repair.md @@ -60,7 +60,7 @@ AI SDK 的规则是"没给工具集的调用从不修复",宿主看到 `null` { "toolCall": { "type": "tool-call", "toolCallId": "call-1", "toolName": "weather", "input": "{\"town\":\"Singapore\"}", "dynamic": true }, "error": { "name": "AI_InvalidToolInputError", "toolName": "weather", - "toolInput": "…", "cause": "…" }, + "toolInput": "…", "cause": { "message": "…" } }, "inputSchema": { "type": "object", "required": ["city"], "…": "…" }, "tools": [ … ], "messages": [ … ], "instructions": null } ``` diff --git a/schema/types/AiMuxError.json b/schema/types/AiMuxError.json index 60bb36cf0..c992a18e3 100644 --- a/schema/types/AiMuxError.json +++ b/schema/types/AiMuxError.json @@ -2,7 +2,7 @@ "$comment": "Generated by aimux-codegen. Do not edit.", "$id": "https://aimux.dev/schema/types/AiMuxError.json", "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Unified error type for all aimux operations.\n\nVariants are cut by *what the caller does about it*, not by where the\nfailure came from. `ApiCallError` is boxed only to keep the Rust enum\ncompact; serde and every binding still observe the same object shape.\n\n`source()` follows the AI SDK's `cause`: an API failure's `cause`, a\nretry's last attempt and a repair's `cause`.", + "description": "Unified error type for all aimux operations.\n\nVariants are cut by *what the caller does about it*, not by where the\nfailure came from. `ApiCallError` is boxed only to keep the Rust enum\ncompact; serde and every binding still observe the same object shape.\n\nA variant has a `cause` exactly where its AI SDK class takes one; it is\nwhat `source()` returns. `Retry` reports its\nlast attempt and `ToolCallRepair` its `cause` the same way.", "oneOf": [ { "$ref": "ApiCallError.json", @@ -32,6 +32,16 @@ }, { "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCause.json" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" }, @@ -48,6 +58,16 @@ }, { "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCause.json" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" }, @@ -64,6 +84,16 @@ }, { "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCause.json" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" }, @@ -78,6 +108,34 @@ ], "type": "object" }, + { + "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCause.json" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "name": { + "const": "AI_TypeValidationError", + "type": "string" + }, + "value": true + }, + "required": [ + "name", + "message", + "value" + ], + "type": "object" + }, { "properties": { "approvalId": { @@ -132,7 +190,7 @@ { "properties": { "cause": { - "type": "string" + "$ref": "ErrorCause.json" }, "name": { "const": "AI_InvalidToolInputError", @@ -175,6 +233,16 @@ }, { "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCause.json" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" }, @@ -191,6 +259,16 @@ }, { "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCause.json" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" }, @@ -396,6 +474,16 @@ }, { "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCause.json" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" }, diff --git a/schema/types/AiMuxErrorInput.json b/schema/types/AiMuxErrorInput.json index 15f53a62e..59c1acf82 100644 --- a/schema/types/AiMuxErrorInput.json +++ b/schema/types/AiMuxErrorInput.json @@ -2,7 +2,7 @@ "$comment": "Generated by aimux-codegen. Do not edit.", "$id": "https://aimux.dev/schema/types/AiMuxErrorInput.json", "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Unified error type for all aimux operations.\n\nVariants are cut by *what the caller does about it*, not by where the\nfailure came from. `ApiCallError` is boxed only to keep the Rust enum\ncompact; serde and every binding still observe the same object shape.\n\n`source()` follows the AI SDK's `cause`: an API failure's `cause`, a\nretry's last attempt and a repair's `cause`.", + "description": "Unified error type for all aimux operations.\n\nVariants are cut by *what the caller does about it*, not by where the\nfailure came from. `ApiCallError` is boxed only to keep the Rust enum\ncompact; serde and every binding still observe the same object shape.\n\nA variant has a `cause` exactly where its AI SDK class takes one; it is\nwhat `source()` returns. `Retry` reports its\nlast attempt and `ToolCallRepair` its `cause` the same way.", "oneOf": [ { "$ref": "ApiCallErrorInput.json", @@ -32,6 +32,16 @@ }, { "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCauseInput.json" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" }, @@ -48,6 +58,16 @@ }, { "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCauseInput.json" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" }, @@ -64,6 +84,16 @@ }, { "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCauseInput.json" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" }, @@ -78,6 +108,34 @@ ], "type": "object" }, + { + "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCauseInput.json" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "name": { + "const": "AI_TypeValidationError", + "type": "string" + }, + "value": true + }, + "required": [ + "name", + "message", + "value" + ], + "type": "object" + }, { "properties": { "approvalId": { @@ -132,7 +190,7 @@ { "properties": { "cause": { - "type": "string" + "$ref": "ErrorCauseInput.json" }, "name": { "const": "AI_InvalidToolInputError", @@ -175,6 +233,16 @@ }, { "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCauseInput.json" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" }, @@ -191,6 +259,16 @@ }, { "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCauseInput.json" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" }, @@ -395,6 +473,16 @@ }, { "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCauseInput.json" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" }, diff --git a/tools/aimux-codegen/generated-files.json b/tools/aimux-codegen/generated-files.json index e724b2caf..22348fc12 100644 --- a/tools/aimux-codegen/generated-files.json +++ b/tools/aimux-codegen/generated-files.json @@ -632,5 +632,5 @@ "tools/aimux-web/web/src/types/generated/WireOptions.ts", "tools/aimux-web/web/src/types/generated/WireTool.ts" ], - "manifestDigest": "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" + "manifestDigest": "ea0b3f034cbe16915bc0dff64291bebea9c3bd08e62ef1e284a5b3317c9f1d89" } 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..c4055bed0 100644 --- a/tools/aimux-web/src/api/replay.rs +++ b/tools/aimux-web/src/api/replay.rs @@ -8,7 +8,7 @@ use axum::response::{IntoResponse, Response}; use serde::Deserialize; use serde_json::json; -use aimux_core::error::AiMuxError; +use aimux_core::error::{AiMuxError, ErrorCause}; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::convert_to_language_model_prompt; use aimux_core::message::ModelPrompt; @@ -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..17f729f02 100644 --- a/tools/aimux-web/src/api/traces.rs +++ b/tools/aimux-web/src/api/traces.rs @@ -8,7 +8,7 @@ use axum::response::{IntoResponse, Response}; use serde::Deserialize; use serde_json::json; -use aimux_core::error::AiMuxError; +use aimux_core::error::{AiMuxError, ErrorCause}; use aimux_core::recording::Recording; use crate::api::err_response; @@ -84,11 +84,12 @@ pub async fn import_jsonl(State(state): State, body: String) -> Respon count += 1; } Err(e) => { - let msg = format!("import line {}: {e}", idx + 1); + let message = format!("import line {}: {e}", idx + 1); + let cause = Some(ErrorCause::source(&e)); return err_response(if e.classify() == serde_json::error::Category::Data { - AiMuxError::InvalidArgument(msg) + AiMuxError::InvalidArgument { message, cause } } else { - AiMuxError::JsonParse(msg) + AiMuxError::JsonParse { message, cause } }); } } diff --git a/tools/aimux-web/src/wire.rs b/tools/aimux-web/src/wire.rs index a19bf3bf6..804404f1f 100644 --- a/tools/aimux-web/src/wire.rs +++ b/tools/aimux-web/src/wire.rs @@ -5,6 +5,7 @@ //! DTO declarations live in the `aimux-web` lib. The TypeScript emitter writes //! them to `web/src/types/generated/`. Never edit those files by hand. +use aimux_core::ErrorCause; use aimux_core::content::{ AssistantContentPart, ToolContentPart, ToolResultOutput, ToolResultPart, UserContentPart, }; @@ -69,7 +70,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 { @@ -110,7 +112,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)" ))); } @@ -159,9 +161,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)), + }); } }, }; @@ -213,7 +216,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" ))); @@ -223,18 +226,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 { @@ -245,8 +246,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 @@ -255,13 +256,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" ))); @@ -269,17 +270,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)", )), } }