From fa4fb9bcddcc412eca23e2d27badfc85a2eab5f0 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:19 +0000 Subject: [PATCH 01/14] feat(errors): add ErrorCause, SourceError and ApiCallError.cause ErrorCause is a nested AiMuxError or a SourceError chain copied from an external error's source() chain. It is what source() returns and serializes as an optional `cause`. ApiCallError gains cause and caused_by(..); RetryError's source is its last attempt. Part 1 of RFC-0047. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C --- aimux-core/src/error.rs | 240 +++++++++++++++++++++++++++++++++++++++- aimux-core/src/lib.rs | 5 +- 2 files changed, 242 insertions(+), 3 deletions(-) diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs index d2603d031..76bc84e9d 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -123,6 +123,11 @@ pub struct ApiCallError { /// no status to compute from) sets it to `true` explicitly. #[serde(default)] pub is_retryable: bool, + /// The error this failure was built from (`APICallError.cause`): the + /// transport error with its source chain, the body parse error, or the + /// aimux error a response handler returned. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cause: Option, } impl ApiCallError { @@ -142,6 +147,16 @@ impl ApiCallError { response_headers: None, data: None, is_retryable: false, + cause: None, + } + } + + /// Set the error this failure was built from. + #[must_use] + pub fn caused_by(self, cause: impl Into) -> Self { + Self { + cause: Some(cause.into()), + ..self } } } @@ -219,6 +234,19 @@ impl std::fmt::Display for RetryError { } } +/// The last attempt, like the AI SDK's `RetryError.lastError`. +impl std::error::Error for RetryError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.errors.last().map(|error| error as _) + } +} + +impl std::error::Error for ApiCallError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.cause.as_ref().map(|cause| cause as _) + } +} + impl std::fmt::Display for ApiCallError { /// `HTTP {status}: {message}` when a status is known (just `HTTP {status}` /// if the response had no body), else `{message}`. `provider_code` and @@ -232,18 +260,143 @@ impl std::fmt::Display for ApiCallError { } } +/// An error from outside aimux (a transport, parser or I/O error), kept as +/// its message and the messages of its own sources, so it can be cloned, +/// serialized and sent across the bindings with the error built from it. +/// +/// The JSON form is a JavaScript `Error`'s own `{ message, cause }`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SourceError { + /// The error's `Display` text. + pub message: String, + /// The error's own `source()`, recursively. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cause: Option>, +} + +impl SourceError { + /// Record `error` and its whole `source()` chain. + #[must_use] + pub fn new(error: &(dyn std::error::Error + 'static)) -> Self { + Self { + message: error.to_string(), + cause: error.source().map(|source| Box::new(Self::new(source))), + } + } + + /// A failure described by `message`, caused by `source` and its chain. + #[must_use] + pub fn with_cause( + message: impl Into, + source: &(dyn std::error::Error + 'static), + ) -> Self { + Self { + message: message.into(), + cause: Some(Box::new(Self::new(source))), + } + } +} + +/// A failure detected in place, with nothing underneath it. +impl From for SourceError { + fn from(message: String) -> Self { + Self { + message, + cause: None, + } + } +} + +impl From<&str> for SourceError { + fn from(message: &str) -> Self { + message.to_owned().into() + } +} + +impl std::fmt::Display for SourceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for SourceError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.cause.as_deref().map(|cause| cause as _) + } +} + +/// The error another error was built from: the AI SDK's `cause`. +/// +/// An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an +/// `APICallError`; any other error is a [`SourceError`]. The JSON form is the +/// nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +#[schemars(extend("x-aimux-codec" = "ordered-untagged"))] +pub enum ErrorCause { + AiMux(Box), + Source(SourceError), +} + +impl ErrorCause { + /// The cause for `error`: an aimux error is kept whole, any other error + /// is recorded with its whole `source()` chain. + #[must_use] + pub fn source(error: &(dyn std::error::Error + 'static)) -> Self { + match error.downcast_ref::() { + Some(error) => error.clone().into(), + None => Self::Source(SourceError::new(error)), + } + } + + fn as_error(&self) -> &(dyn std::error::Error + 'static) { + match self { + Self::AiMux(error) => error.as_ref(), + Self::Source(error) => error, + } + } +} + +impl From for ErrorCause { + fn from(error: AiMuxError) -> Self { + Self::AiMux(Box::new(error)) + } +} + +impl From for ErrorCause { + fn from(error: SourceError) -> Self { + Self::Source(error) + } +} + +impl std::fmt::Display for ErrorCause { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(self.as_error(), f) + } +} + +impl std::error::Error for ErrorCause { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.as_error().source() + } +} + /// Unified error type for all aimux operations. /// /// Variants are cut by *what the caller does about it*, not by where the /// failure came from. `ApiCallError` is boxed only to keep the Rust enum /// compact; serde and every binding still observe the same object shape. +/// +/// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a +/// retry's last attempt and a repair's `cause`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Error)] #[serde(from = "AiMuxErrorWire", into = "AiMuxErrorWire")] pub enum AiMuxError { #[error("API call error: {0}")] - ApiCall(Box), + ApiCall(#[source] Box), - #[error("{0}")] + #[error(transparent)] Retry(RetryError), #[error("JSON parse error: {0}")] @@ -289,6 +442,7 @@ pub enum AiMuxError { #[error("Error repairing tool call: {cause}")] ToolCallRepair { original_error: Box, + #[source] cause: Box, }, @@ -937,4 +1091,86 @@ mod tests { assert_eq!(back.retry_after_hint(), Some(7)); assert_eq!(back.status_code(), Some(429)); } + + fn chain(error: &(dyn std::error::Error + 'static)) -> Vec { + std::iter::successors(Some(error), |error| error.source()) + .map(ToString::to_string) + .collect() + } + + /// A transport chain survives as `cause`: `source()` walks it in Rust and + /// the wire form carries the same chain to every binding. + #[test] + fn api_call_cause_is_its_source_chain_and_round_trips() { + let os = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "Connection refused"); + let transport = SourceError::with_cause("tcp connect error", &os); + let err = AiMuxError::ApiCall(Box::new( + api_error("Cannot connect to API: tcp connect error").caused_by(transport), + )); + assert_eq!( + chain(&err), + [ + "API call error: Cannot connect to API: tcp connect error", + "Cannot connect to API: tcp connect error", + "tcp connect error", + "Connection refused", + ] + ); + let json = serde_json::to_value(&err).unwrap(); + assert_eq!( + json["cause"], + serde_json::json!({ + "message": "tcp connect error", + "cause": { "message": "Connection refused" }, + }) + ); + let back: AiMuxError = serde_json::from_value(json).unwrap(); + assert_eq!(chain(&back), chain(&err)); + } + + #[test] + fn no_cause_stays_off_the_wire() { + let json = serde_json::to_value(AiMuxError::ApiCall(Box::new(api_error("bad")))).unwrap(); + assert!(json.get("cause").is_none(), "{json}"); + let back: AiMuxError = serde_json::from_value(json).unwrap(); + assert!(std::error::Error::source(std::error::Error::source(&back).unwrap()).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)), + )); + assert!(matches!( + &err, + AiMuxError::ApiCall(api) if matches!( + &api.cause, + Some(ErrorCause::AiMux(cause)) if matches!(cause.as_ref(), AiMuxError::JsonParse(_)) + ) + )); + let json = serde_json::to_value(&err).unwrap(); + assert_eq!(json["cause"]["name"], "AI_JSONParseError"); + let back: AiMuxError = serde_json::from_value(json).unwrap(); + assert_eq!(chain(&back), chain(&err)); + } + + /// 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()), + ], + }); + let source = std::error::Error::source(&err).unwrap(); + assert_eq!(source.to_string(), "second"); + assert_eq!( + err.to_string(), + "Failed after 2 attempts. Last error: second" + ); + } } diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index feb3bc5f1..03c1175b5 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -184,7 +184,10 @@ pub use abort_signal::AbortSignal; pub use custom_provider::{CustomProvider, CustomProviderOptions, custom_provider}; pub use decision_model::DecisionModel; pub use embedding_model::EmbeddingModel; -pub use error::{AiMuxError, ApiCallError, RetryError, RetryErrorReason, ToolChoiceViolationError}; +pub use error::{ + AiMuxError, ApiCallError, ErrorCause, RetryError, RetryErrorReason, SourceError, + ToolChoiceViolationError, +}; pub use files_model::Files; pub use image_model::ImageModel; pub use language_model::LanguageModel; From b034155103a4cafa3c8811b8f1f18a01eb1691ca Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:20 +0000 Subject: [PATCH 02/14] feat(transport): keep the transport error as ApiCallError.cause FetchError carries a SourceError built from reqwest's error after without_url(), and fetch, redirects, body reads, the JSON and SSE handlers and WebSocket keep the original error as the cause. Transport messages lose the URL: 'error sending request for url (...)' is now 'error sending request'. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C --- aimux-provider-utils/Cargo.toml | 1 + aimux-provider-utils/src/fetch.rs | 105 ++++++++++++++---- .../src/handle_fetch_error.rs | 7 +- aimux-provider-utils/src/http.rs | 29 +++-- aimux-provider-utils/src/post_to_api.rs | 41 +++++++ .../src/read_response_with_size_limit.rs | 3 +- aimux-provider-utils/src/response_handler.rs | 13 ++- aimux-provider-utils/src/sigv4_fetch.rs | 26 ++++- aimux-provider-utils/src/ws.rs | 37 ++++-- 9 files changed, 214 insertions(+), 48 deletions(-) diff --git a/aimux-provider-utils/Cargo.toml b/aimux-provider-utils/Cargo.toml index 7eb3ccd81..f920b499e 100644 --- a/aimux-provider-utils/Cargo.toml +++ b/aimux-provider-utils/Cargo.toml @@ -50,6 +50,7 @@ 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/fetch.rs b/aimux-provider-utils/src/fetch.rs index 231eebc90..2db03ea76 100644 --- a/aimux-provider-utils/src/fetch.rs +++ b/aimux-provider-utils/src/fetch.rs @@ -31,7 +31,7 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; use url::Url; -use aimux_core::AbortSignal; +use aimux_core::{AbortSignal, SourceError}; /// Proxy routing of one transport: a [`ReqwestFetch`], a [`PinnedFetch`] /// hop, or the WebSocket connector. With every field empty, HTTP follows the @@ -161,11 +161,15 @@ impl fmt::Debug for FetchResponse { /// A transport-level failure: no HTTP response was produced, or the body /// stream broke. HTTP error statuses are *not* errors here — they come back /// as a [`FetchResponse`] and are interpreted by the response handlers. +/// +/// Each failure keeps the transport's error with its whole `source()` chain +/// (for a refused connection, the OS error at the bottom of reqwest's chain), +/// which becomes the `cause` of the resulting `ApiCallError`. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum FetchError { /// The connection could not be established. - #[error("{0}")] - Connect(String), + #[error(transparent)] + Connect(SourceError), /// The transport's own timeout fired. #[error("request timed out")] Timeout, @@ -173,11 +177,11 @@ pub enum FetchError { #[error("request aborted")] Aborted, /// The connection failed while sending the request or reading the body. - #[error("{0}")] - Io(String), + #[error(transparent)] + Io(SourceError), /// Anything else, including requests the transport refuses to build. - #[error("{0}")] - Other(String), + #[error(transparent)] + Other(SourceError), } impl FetchError { @@ -333,7 +337,10 @@ impl ReqwestFetch { return Ok(client.clone()); } let client = build_client(&self.proxy).map_err(|error| { - FetchError::Other(format!("HTTP client initialization failed: {error}")) + FetchError::Other(SourceError::with_cause( + format!("HTTP client initialization failed: {error}"), + &error, + )) })?; if clients.len() >= CLIENTS_PER_TRANSPORT { // Which entries are dead is unknowable, so evict them all; @@ -386,12 +393,15 @@ impl PinnedFetch { let builder = apply_proxy(builder, &self.proxy); if self.addresses.is_empty() { return builder.build().map_err(|error| { - FetchError::Other(format!("download client initialization failed: {error}")) + FetchError::Other(SourceError::with_cause( + format!("download client initialization failed: {error}"), + &error, + )) }); } let host = url .host_str() - .ok_or_else(|| FetchError::Other("download url has no host".to_string()))?; + .ok_or_else(|| FetchError::Other("download url has no host".into()))?; let port = url.port_or_known_default().unwrap_or(80); let socket_addresses: Vec<_> = self .addresses @@ -402,8 +412,9 @@ impl PinnedFetch { .resolve_to_addrs(host, &socket_addresses) .build() .map_err(|error| { - FetchError::Other(format!( - "pinned download client initialization failed: {error}" + FetchError::Other(SourceError::with_cause( + format!("pinned download client initialization failed: {error}"), + &error, )) }) } @@ -442,15 +453,18 @@ async fn send(client: &Client, request: FetchRequest) -> Result FetchError { + let error = error.without_url(); if error.is_builder() { - FetchError::Other(error.to_string()) + FetchError::Other(SourceError::new(&error)) } else if error.is_connect() { - FetchError::Connect(error.to_string()) + FetchError::Connect(SourceError::new(&error)) } else if error.is_timeout() { FetchError::Timeout } else { - FetchError::Io(error.to_string()) + FetchError::Io(SourceError::new(&error)) } } @@ -461,16 +475,14 @@ fn map_reqwest_error(error: reqwest::Error) -> FetchError { // client on its next request. const CLIENTS_PER_TRANSPORT: usize = 8; -fn build_client(proxy: &ProxyConfig) -> Result { +fn build_client(proxy: &ProxyConfig) -> Result { let builder = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .connect_timeout(Duration::from_millis(10_000)) .pool_max_idle_per_host(10) .pool_idle_timeout(Some(Duration::from_secs(30))) .tcp_keepalive(Some(Duration::from_secs(20))); - apply_proxy(builder, proxy) - .build() - .map_err(|error| error.to_string()) + apply_proxy(builder, proxy).build() } fn apply_proxy(mut builder: reqwest::ClientBuilder, proxy: &ProxyConfig) -> reqwest::ClientBuilder { @@ -560,6 +572,61 @@ mod tests { ); } + /// A refused connection keeps reqwest's whole chain down to the OS error, + /// and drops the URL, whose query may carry an API key. + #[tokio::test] + async fn a_refused_connection_keeps_its_chain_without_the_url() { + // 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()) + .unwrap(); + let address = socket.local_addr().unwrap().as_socket().unwrap(); + // An explicit proxy turns off the environment's; NO_PROXY then sends + // the loopback request directly. + let direct = ProxyConfig { + all_url: Some("http://127.0.0.1:9".into()), + no_proxy: Some("127.0.0.1".into()), + ..ProxyConfig::default() + }; + let error = ReqwestFetch::new(direct) + .fetch(get(&format!("http://{address}/v1?key=SECRET"))) + .await + .unwrap_err(); + let FetchError::Connect(source) = &error else { + panic!("{error:?}") + }; + let chain: Vec<&str> = std::iter::successors(Some(source), |error| error.cause.as_deref()) + .map(|error| error.message.as_str()) + .collect(); + assert!(chain.len() > 1, "{chain:?}"); + assert!( + chain.iter().all(|message| !message.contains("SECRET")), + "{chain:?}" + ); + assert!( + chain.last().unwrap().to_lowercase().contains("refused"), + "{chain:?}" + ); + + let api = crate::handle_fetch_error::fetch_error_to_ai_mux_error( + error.clone(), + "https://api.example/v1", + &serde_json::Value::Null, + ); + let aimux_core::AiMuxError::ApiCall(api) = api else { + panic!("{api:?}") + }; + assert!(api.is_retryable); + assert!( + matches!(&api.cause, Some(aimux_core::ErrorCause::Source(cause)) if cause == source), + "{:?}", + api.cause + ); + } + /// `default_fetch` is read per request: replacing the default transport /// reroutes a transport handle taken later, and the WebSocket connector /// and download hops read the same settings. diff --git a/aimux-provider-utils/src/handle_fetch_error.rs b/aimux-provider-utils/src/handle_fetch_error.rs index 76b7a1160..88ee26196 100644 --- a/aimux-provider-utils/src/handle_fetch_error.rs +++ b/aimux-provider-utils/src/handle_fetch_error.rs @@ -1,6 +1,6 @@ //! Fetch error normalization. -use aimux_core::{AiMuxError, ApiCallError}; +use aimux_core::{AiMuxError, ApiCallError, ErrorCause}; use crate::fetch::FetchError; @@ -24,7 +24,9 @@ pub fn handle_fetch_error( /// Convert a transport failure into the public error model: an abort stays an /// abort; everything else is an `ApiCall` error with no status (no response -/// arrived), retryable exactly when [`FetchError::is_retryable`] says so. +/// arrived), retryable exactly when [`FetchError::is_retryable`] says so, with +/// the transport error and its source chain as its `cause` (the AI SDK's +/// `handleFetchError`, `@ai-sdk/provider-utils` `handle-fetch-error.ts`). #[must_use] pub(crate) fn fetch_error_to_ai_mux_error( error: FetchError, @@ -35,6 +37,7 @@ pub(crate) fn fetch_error_to_ai_mux_error( FetchError::Aborted => AiMuxError::Aborted("request aborted".into()), other => AiMuxError::ApiCall(Box::new(ApiCallError { is_retryable: other.is_retryable(), + cause: Some(ErrorCause::source(&other)), ..ApiCallError::new(other.to_string(), url, request_body_values.clone()) })), } diff --git a/aimux-provider-utils/src/http.rs b/aimux-provider-utils/src/http.rs index 7af4117c2..367826e8b 100644 --- a/aimux-provider-utils/src/http.rs +++ b/aimux-provider-utils/src/http.rs @@ -12,6 +12,7 @@ use std::time::{Duration, Instant}; use bytes::Bytes; use futures::{Stream, StreamExt, stream::BoxStream}; +use aimux_core::ErrorCause; use aimux_core::recording::{ HttpExchange, HttpRecord, RecordingContext, ResponseRecord, TimingRecord, }; @@ -401,9 +402,11 @@ fn redirect_error( request: &PreparedRequest, message: impl Into, status: http::StatusCode, + cause: Option, ) -> AiMuxError { AiMuxError::ApiCall(Box::new(ApiCallError { status_code: Some(status.as_u16()), + cause, ..api_call_error(request, message) })) } @@ -471,15 +474,24 @@ async fn send_following_redirects(request: &PreparedRequest) -> Result Result( url, request_body_values, ) + .caused_by(error) }))), }; } @@ -198,6 +199,7 @@ async fn execute_exchange( url, request_body_values, ) + .caused_by(error) }))), } } @@ -257,6 +259,45 @@ mod tests { } } + /// A handler error on a 2xx response stays whole as the `ApiCallError` + /// cause: a nested aimux error, as `contract-tests/dto/AiMuxError/005.json`. + #[tokio::test] + async fn a_failed_success_handler_keeps_its_error_as_the_cause() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_string("{")) + .mount(&server) + .await; + let request = HttpRequest { + url: server.uri(), + ..Default::default() + }; + let handler = ResponseHandler::::new(|_| async { + Err(AiMuxError::JsonParse("Unexpected end of JSON input".into())) + }); + let error = call_to_api_with_deadline( + request.prepare(HttpMethod::Post, HttpBody::Json(serde_json::json!({}))), + serde_json::json!({}), + handler, + create_status_code_error_response_handler(), + Duration::from_secs(5), + ) + .await + .unwrap_err(); + + let vector: serde_json::Value = + serde_json::from_str(include_str!("../../contract-tests/dto/AiMuxError/005.json")) + .unwrap(); + let json = serde_json::to_value(&error).unwrap(); + assert_eq!(json["message"], vector["message"]); + assert_eq!(json["cause"], vector["cause"]); + assert!(matches!( + &error, + AiMuxError::ApiCall(detail) + if matches!(&detail.cause, Some(aimux_core::ErrorCause::AiMux(_))) + )); + } + /// Streaming handlers are exempt from the exchange response timeout. #[test] fn event_source_handler_is_marked_streaming() { diff --git a/aimux-provider-utils/src/read_response_with_size_limit.rs b/aimux-provider-utils/src/read_response_with_size_limit.rs index 794accf6f..0dc8d9f59 100644 --- a/aimux-provider-utils/src/read_response_with_size_limit.rs +++ b/aimux-provider-utils/src/read_response_with_size_limit.rs @@ -3,7 +3,7 @@ use bytes::Bytes; use futures::StreamExt; -use aimux_core::{AiMuxError, ApiCallError}; +use aimux_core::{AiMuxError, ApiCallError, ErrorCause}; use crate::fetch::FetchResponse; @@ -74,6 +74,7 @@ pub async fn read_response_with_size_limit( status_code: None, response_headers: Some(response_headers.clone()), is_retryable: true, + cause: Some(ErrorCause::source(&error)), ..ApiCallError::new( format!("Failed to read response body: {error}"), url, diff --git a/aimux-provider-utils/src/response_handler.rs b/aimux-provider-utils/src/response_handler.rs index bcafb46b3..8e22f2904 100644 --- a/aimux-provider-utils/src/response_handler.rs +++ b/aimux-provider-utils/src/response_handler.rs @@ -7,7 +7,7 @@ use bytes::Bytes; use futures::{StreamExt, stream::BoxStream}; use serde::de::DeserializeOwned; -use aimux_core::{AiMuxError, ApiCallError}; +use aimux_core::{AiMuxError, ApiCallError, ErrorCause}; use crate::extract_response_headers::extract_response_headers; use crate::fetch::FetchResponse; @@ -132,6 +132,7 @@ where status_code: Some(status), response_body: Some(String::from_utf8_lossy(&body).into_owned()), response_headers: Some(headers.clone()), + cause: Some(ErrorCause::source(&error)), ..ApiCallError::new( format!("Invalid JSON response: {error}"), input.url.clone(), @@ -190,9 +191,11 @@ fn error_body_string(body: &[u8], read_truncated: bool) -> String { /// Callers may hold the original request after the transport has returned. /// Apply the same URL sanitization and structured-context bounds as transport /// errors; keep bounded raw response evidence and the observed HTTP status. +/// `error` is the validation failure: its text is the message and the error +/// itself the `cause`. #[must_use] pub fn invalid_response_api_call( - message: impl Into, + error: &(dyn std::error::Error + 'static), status_code: u16, url: &str, request_body_values: serde_json::Value, @@ -207,8 +210,9 @@ pub fn invalid_response_api_call( )), response_headers: Some(response_headers), data: Some(crate::logging::redact_error_context(response_body)), + cause: Some(ErrorCause::source(error)), ..ApiCallError::new( - message, + error.to_string(), crate::http::sanitized_request_url(url), crate::logging::redact_error_context(request_body_values), ) @@ -373,6 +377,7 @@ where AiMuxError::ApiCall(Box::new(ApiCallError { response_headers: Some(headers.clone()), is_retryable: true, + cause: Some(ErrorCause::source(&error)), ..ApiCallError::new( error.to_string(), body_url.clone(), @@ -412,6 +417,7 @@ where yield Err(AiMuxError::ApiCall(Box::new(ApiCallError { response_headers: Some(stream_headers.clone()), is_retryable: true, + cause: Some(ErrorCause::source(error.as_ref())), ..ApiCallError::new( error.to_string(), stream_url.clone(), @@ -427,6 +433,7 @@ where yield Err(AiMuxError::ApiCall(Box::new(ApiCallError { response_headers: Some(stream_headers.clone()), is_retryable: false, + cause: Some(ErrorCause::source(&error)), ..ApiCallError::new( error.to_string(), stream_url.clone(), diff --git a/aimux-provider-utils/src/sigv4_fetch.rs b/aimux-provider-utils/src/sigv4_fetch.rs index 35fcdea0d..653b517f7 100644 --- a/aimux-provider-utils/src/sigv4_fetch.rs +++ b/aimux-provider-utils/src/sigv4_fetch.rs @@ -19,6 +19,8 @@ use chrono::{DateTime, Utc}; use hmac::{Hmac, Mac}; use sha2::{Digest, Sha256}; +use aimux_core::SourceError; + use crate::fetch::{ Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, RequestCredentials, }; @@ -427,7 +429,10 @@ impl Fetch for SigV4Fetch { return self.inner.fetch(request).await; } let credentials = self.credentials.resolve().await.map_err(|error| { - FetchError::Other(format!("failed to resolve AWS credentials: {error}")) + FetchError::Other(SourceError::with_cause( + format!("failed to resolve AWS credentials: {error}"), + &error, + )) })?; let mut extra_headers = Vec::with_capacity(request.headers.len()); for (name, value) in &request.headers { @@ -438,8 +443,11 @@ impl Fetch for SigV4Fetch { if unsigned { continue; } - let value = value.to_str().map_err(|_| { - FetchError::Other(format!("cannot sign non-ASCII header value for {name}")) + let value = value.to_str().map_err(|error| { + FetchError::Other(SourceError::with_cause( + format!("cannot sign non-ASCII header value for {name}"), + &error, + )) })?; extra_headers.push((name.as_str().to_string(), value.to_string())); } @@ -455,10 +463,16 @@ impl Fetch for SigV4Fetch { ); for (name, value) in signed.headers { let name = http::HeaderName::try_from(name.as_str()).map_err(|error| { - FetchError::Other(format!("invalid signed header name {name}: {error}")) + FetchError::Other(SourceError::with_cause( + format!("invalid signed header name {name}: {error}"), + &error, + )) })?; let value = http::HeaderValue::try_from(value.as_str()).map_err(|error| { - FetchError::Other(format!("invalid signed header value for {name}: {error}")) + FetchError::Other(SourceError::with_cause( + format!("invalid signed header value for {name}: {error}"), + &error, + )) })?; request.headers.insert(name, value); } @@ -918,7 +932,7 @@ mod tests { .fetch(FetchRequest::new(Method::GET, URL.parse().unwrap())) .await .unwrap_err(); - assert!(matches!(&error, FetchError::Other(m) if m.contains("AWS_ACCESS_KEY_ID"))); + assert!(matches!(&error, FetchError::Other(m) if m.message.contains("AWS_ACCESS_KEY_ID"))); assert!(capture.0.lock().unwrap().is_none()); } } diff --git a/aimux-provider-utils/src/ws.rs b/aimux-provider-utils/src/ws.rs index 3603a7a1e..ce32c6b71 100644 --- a/aimux-provider-utils/src/ws.rs +++ b/aimux-provider-utils/src/ws.rs @@ -35,6 +35,7 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use aimux_core::AbortSignal; use aimux_core::error::{AiMuxError, ApiCallError}; use aimux_core::options::TimeoutConfiguration; +use aimux_core::{ErrorCause, SourceError}; /// A request to open a WebSocket connection. #[derive(Debug)] @@ -154,9 +155,14 @@ fn abort_error(abort: &Option) -> AiMuxError { .unwrap_or_else(|| AiMuxError::Aborted("request aborted".into())) } -fn ws_error(url: &str, msg: impl std::fmt::Display) -> AiMuxError { +fn ws_error( + url: &str, + msg: impl std::fmt::Display, + source: Option<&(dyn std::error::Error + 'static)>, +) -> AiMuxError { AiMuxError::ApiCall(Box::new(ApiCallError { is_retryable: true, + cause: source.map(ErrorCause::source), ..ApiCallError::new( msg.to_string(), crate::http::sanitized_request_url(url), @@ -448,7 +454,10 @@ async fn connect_through_proxy( .with_safe_default_protocol_versions() .map_err(|e| { ConnectError::Tungstenite(tokio_tungstenite::tungstenite::Error::Io( - std::io::Error::other(format!("building rustls client config: {e}")), + std::io::Error::other(SourceError::with_cause( + format!("building rustls client config: {e}"), + &e, + )), )) })? .with_root_certificates(roots) @@ -615,7 +624,11 @@ async fn connect_tungstenite( }))); } Err(ConnectError::Tungstenite(e)) => { - return Err(ws_error(&req.url, format!("websocket connect failed: {e}"))); + return Err(ws_error( + &req.url, + format!("websocket connect failed: {e}"), + Some(&e), + )); } }, }; @@ -682,7 +695,7 @@ impl WsConnection { Err(AiMuxError::Timeout("websocket send exceeded total timeout".into())) } res = self.stream.send(Message::Text(text.to_string())) => { - res.map_err(|e| ws_error(&self.url, format!("websocket send failed: {e}"))) + res.map_err(|e| ws_error(&self.url, format!("websocket send failed: {e}"), Some(&e))) } } } @@ -702,7 +715,7 @@ impl WsConnection { Err(AiMuxError::Timeout("websocket send exceeded total timeout".into())) } res = self.stream.send(Message::Binary(bytes.to_vec())) => { - res.map_err(|e| ws_error(&self.url, format!("websocket send failed: {e}"))) + res.map_err(|e| ws_error(&self.url, format!("websocket send failed: {e}"), Some(&e))) } } } @@ -752,12 +765,18 @@ impl WsConnection { let detail = frame .map(|f| format!(" (code {}: {})", u16::from(f.code), f.reason)) .unwrap_or_default(); - return Some(Err(ws_error(&self.url, format!( - "websocket closed by peer{detail}" - )))); + return Some(Err(ws_error( + &self.url, + format!("websocket closed by peer{detail}"), + None, + ))); } Some(Err(e)) => { - return Some(Err(ws_error(&self.url, format!("websocket error: {e}")))); + return Some(Err(ws_error( + &self.url, + format!("websocket error: {e}"), + Some(&e), + ))); } }, } From ae5858fedbb5545683b7e5a3ee4bd89efe4826a5 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:20 +0000 Subject: [PATCH 03/14] refactor(providers): pass the cause where providers build ApiCallError Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C --- aimux-providers/src/azure/mod.rs | 5 +++-- aimux-providers/src/bedrock/model.rs | 1 + aimux-providers/src/decision_support.rs | 9 +-------- aimux-providers/src/jev.rs | 2 +- aimux-providers/src/openai_compatible/config.rs | 11 ++++++++--- aimux-providers/src/stability.rs | 1 + aimux-providers/src/vertex/auth_certificate.rs | 11 +++++++---- aimux-providers/src/xai/mod.rs | 1 + 8 files changed, 23 insertions(+), 18 deletions(-) diff --git a/aimux-providers/src/azure/mod.rs b/aimux-providers/src/azure/mod.rs index a3b7c6159..7ce5afbc3 100644 --- a/aimux-providers/src/azure/mod.rs +++ b/aimux-providers/src/azure/mod.rs @@ -31,6 +31,7 @@ use std::sync::{Arc, OnceLock}; use futures::future::BoxFuture; use crate::shared::ProviderHeaders; +use aimux_core::SourceError; use aimux_core::embedding_model::EmbeddingModel; use aimux_core::error::AiMuxError; use aimux_core::image_model::ImageModel; @@ -271,12 +272,12 @@ impl Fetch for BearerTokenFetch { .token .resolve() .await - .map_err(|error| FetchError::Other(error.to_string()))?; + .map_err(|error| FetchError::Other(SourceError::new(&error)))?; request.headers.insert( reqwest::header::AUTHORIZATION, format!("Bearer {token}").parse().map_err( |error: reqwest::header::InvalidHeaderValue| { - FetchError::Other(error.to_string()) + FetchError::Other(SourceError::new(&error)) }, )?, ); diff --git a/aimux-providers/src/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index b7e71cf8e..ce21a70a5 100644 --- a/aimux-providers/src/bedrock/model.rs +++ b/aimux-providers/src/bedrock/model.rs @@ -50,6 +50,7 @@ fn bedrock_event_stream_response_handler() AiMuxError::ApiCall(Box::new(aimux_core::ApiCallError { response_headers: Some(headers.clone()), is_retryable: true, + cause: Some(aimux_core::ErrorCause::source(&error)), ..aimux_core::ApiCallError::new( error.to_string(), url.clone(), diff --git a/aimux-providers/src/decision_support.rs b/aimux-providers/src/decision_support.rs index 8a37aa446..5bf9b1291 100644 --- a/aimux-providers/src/decision_support.rs +++ b/aimux-providers/src/decision_support.rs @@ -205,14 +205,7 @@ pub(crate) async fn post( let raw = response.value; let headers = response.response_headers; convert(raw.clone(), headers.clone()).map_err(|error| { - aimux_provider_utils::invalid_response_api_call( - error.to_string(), - 200, - url, - body, - raw, - headers, - ) + aimux_provider_utils::invalid_response_api_call(&error, 200, url, body, raw, headers) }) } diff --git a/aimux-providers/src/jev.rs b/aimux-providers/src/jev.rs index f9740bdd0..d5e168b54 100644 --- a/aimux-providers/src/jev.rs +++ b/aimux-providers/src/jev.rs @@ -185,7 +185,7 @@ impl DecisionModel for JevDecisionModel { ); result.map_err(|error| { aimux_provider_utils::invalid_response_api_call( - error.to_string(), + &error, 200, &self.config.endpoint, body, diff --git a/aimux-providers/src/openai_compatible/config.rs b/aimux-providers/src/openai_compatible/config.rs index ac671fb72..6b9d09626 100644 --- a/aimux-providers/src/openai_compatible/config.rs +++ b/aimux-providers/src/openai_compatible/config.rs @@ -17,8 +17,8 @@ use std::sync::Arc; use aimux_core::types::{ProviderMetadata, Usage}; use serde_json::Value; -use aimux_core::AiMuxError; 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, @@ -372,8 +372,13 @@ struct FetchUserAgent { #[async_trait::async_trait] impl Fetch for FetchUserAgent { async fn fetch(&self, mut request: FetchRequest) -> Result { - let value = reqwest::header::HeaderValue::try_from(self.user_agent.as_str()) - .map_err(|_| FetchError::Other("invalid header value for user-agent".to_string()))?; + let value = + reqwest::header::HeaderValue::try_from(self.user_agent.as_str()).map_err(|error| { + FetchError::Other(SourceError::with_cause( + "invalid header value for user-agent", + &error, + )) + })?; request.headers.insert(reqwest::header::USER_AGENT, value); self.inner .clone() diff --git a/aimux-providers/src/stability.rs b/aimux-providers/src/stability.rs index 3de3d8d1f..df63a96d1 100644 --- a/aimux-providers/src/stability.rs +++ b/aimux-providers/src/stability.rs @@ -81,6 +81,7 @@ fn stability_successful_response_handler() -> aimux_provider_utils::ResponseHand status_code: Some(status), response_headers: Some(headers.clone()), response_body: Some(String::from_utf8_lossy(&bytes).into_owned()), + cause: Some(aimux_core::ErrorCause::source(&error)), ..aimux_core::ApiCallError::new( format!("Invalid JSON response: {error}"), input.url, diff --git a/aimux-providers/src/vertex/auth_certificate.rs b/aimux-providers/src/vertex/auth_certificate.rs index 6c6368ee0..ee8dbc972 100644 --- a/aimux-providers/src/vertex/auth_certificate.rs +++ b/aimux-providers/src/vertex/auth_certificate.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; -use aimux_core::AiMuxError; +use aimux_core::{AiMuxError, SourceError}; use aimux_provider_utils::{Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse}; use base64::Engine; use futures::StreamExt; @@ -181,14 +181,17 @@ impl Fetch for CertificateFetch { } } +/// The request URL is reported separately, sanitized, so it is dropped from +/// reqwest's own text. fn fetch_error(error: reqwest::Error) -> FetchError { + let error = error.without_url(); if error.is_builder() { - FetchError::Other(error.to_string()) + FetchError::Other(SourceError::new(&error)) } else if error.is_timeout() { FetchError::Timeout } else if error.is_connect() { - FetchError::Connect(error.to_string()) + FetchError::Connect(SourceError::new(&error)) } else { - FetchError::Io(error.to_string()) + FetchError::Io(SourceError::new(&error)) } } diff --git a/aimux-providers/src/xai/mod.rs b/aimux-providers/src/xai/mod.rs index 29f81edaf..f51b61dc4 100644 --- a/aimux-providers/src/xai/mod.rs +++ b/aimux-providers/src/xai/mod.rs @@ -193,6 +193,7 @@ where status_code: Some(status), response_body: Some(raw.to_string()), response_headers: Some(headers.clone()), + cause: Some(aimux_core::ErrorCause::source(&error)), ..aimux_core::ApiCallError::new( format!("Invalid JSON response: {error}"), url, From c4194d1ad2e1f783ae2816b2ddc0cb702438a978 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:20 +0000 Subject: [PATCH 04/14] fix(codegen): box a Swift struct field of the struct's own type A Swift struct cannot store itself inline, so SourceError.cause did not compile. The field keeps its public name and type and is stored in a WireIndirect. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C --- tools/aimux-codegen/src/emit/swift.rs | 100 +++++++++++++++++++++----- 1 file changed, 84 insertions(+), 16 deletions(-) diff --git a/tools/aimux-codegen/src/emit/swift.rs b/tools/aimux-codegen/src/emit/swift.rs index 8742f6d34..713f17244 100644 --- a/tools/aimux-codegen/src/emit/swift.rs +++ b/tools/aimux-codegen/src/emit/swift.rs @@ -235,6 +235,17 @@ fn encode(out: &mut String, field: &Field, value: &str, indent: &str) { .unwrap(); } +/// An optional field of the struct's own type (`SourceError.cause`): a Swift +/// struct cannot store itself inline, so the value lives in a `WireIndirect` +/// behind a property of the field's own name. +fn boxed(name: &str, field: &Field) -> bool { + optional(field) && matches!(nonnull(&field.ty), Ty::Ref(inner) if inner == name) +} + +fn storage(field: &Field) -> String { + format!("_{}", camel(&field.json_name).trim_matches('`')) +} + fn structure(out: &mut String, name: &str, fields: &[Field]) { writeln!( out, @@ -244,13 +255,19 @@ fn structure(out: &mut String, name: &str, fields: &[Field]) { .unwrap(); for field in fields { docs(out, field.doc.as_deref(), " "); - writeln!( - out, - " public var {}: {}", - camel(&field.json_name), - field_ty(field) - ) - .unwrap(); + let key = camel(&field.json_name); + if boxed(name, field) { + let storage = storage(field); + writeln!( + out, + " public var {key}: {} {{\n get {{ {storage}?.value }}\n set {{ {storage} = newValue.map(WireIndirect.boxed) }}\n }}\n private var {storage}: WireIndirect<{}>?", + field_ty(field), + ident(name) + ) + .unwrap(); + } else { + writeln!(out, " public var {key}: {}", field_ty(field)).unwrap(); + } } out.push_str("\n public init("); for (i, field) in fields.iter().enumerate() { @@ -275,12 +292,22 @@ fn structure(out: &mut String, name: &str, fields: &[Field]) { out.push_str(") {\n"); for field in fields { let key = camel(&field.json_name); - writeln!(out, " self.{key} = {key}").unwrap(); + if boxed(name, field) { + writeln!( + out, + " self.{} = {key}.map(WireIndirect.boxed)", + storage(field) + ) + .unwrap(); + } else { + writeln!(out, " self.{key} = {key}").unwrap(); + } } out.push_str(" }\n"); if !fields.is_empty() && fields.iter().all(|field| { - !matches!(nonnull(&field.ty), Ty::Literal(_)) + !boxed(name, field) + && !matches!(nonnull(&field.ty), Ty::Literal(_)) && !presence(field) && !(field.required && matches!(field.ty, Ty::Nullable(_))) }) @@ -301,13 +328,23 @@ fn structure(out: &mut String, name: &str, fields: &[Field]) { } else { out.push_str(" let c = try decoder.container(keyedBy: CodingKeys.self)\n"); for field in fields { - writeln!( - out, - " self.{} = {}", - camel(&field.json_name), - decode(field) - ) - .unwrap(); + if boxed(name, field) { + writeln!( + out, + " self.{} = {}.map(WireIndirect.boxed)", + storage(field), + decode(field) + ) + .unwrap(); + } else { + writeln!( + out, + " self.{} = {}", + camel(&field.json_name), + decode(field) + ) + .unwrap(); + } } } out.push_str(" }\n public func encode(to encoder: any Encoder) throws {\n"); @@ -651,6 +688,17 @@ public struct WireUInt64: Codable, Equatable, Sendable { try c.encode(rawValue) } } +/// The boxed storage of a struct's optional field of its own type. +indirect enum WireIndirect { + case boxed(Value) + var value: Value { + switch self { + case .boxed(let value): return value + } + } +} +extension WireIndirect: Equatable where Value: Equatable {} +extension WireIndirect: Sendable where Value: Sendable {} private struct WireKey: CodingKey { let stringValue: String let intValue: Int? = nil @@ -685,6 +733,26 @@ mod tests { assert!(source.contains("c.encodePresence(self.body, forKey: .body)")); } + #[test] + fn a_field_of_the_structs_own_type_is_boxed() { + let model = normalize(&json!({"input": {}, "output": {"Chain": { + "type": "object", "properties": { + "message": {"type": "string"}, + "cause": {"$ref": "#/typeDefs/output/Chain"} + }, "required": ["message"] + }}})) + .unwrap(); + let files = Swift.files(&model).unwrap(); + let declaration = files[0].1.split("public struct Chain").nth(1).unwrap(); + assert!(declaration.contains("public var cause: Chain? {")); + assert!(declaration.contains("private var _cause: WireIndirect?")); + assert!(declaration.contains("self._cause = cause.map(WireIndirect.boxed)")); + assert!(declaration.contains( + "self._cause = try c.decodeIfPresent(Chain.self, forKey: .cause).map(WireIndirect.boxed)" + )); + assert!(declaration.contains("try c.encodeIfPresent(self.cause, forKey: .cause)")); + } + #[test] fn swift_property_names_keep_the_original_wire_keys() { let model = normalize(&json!({"input": {}, "output": {"Record": { From 00492d8b98059470e45e7b937690f90c3725476a Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:20 +0000 Subject: [PATCH 05/14] chore(dto): regenerate the DTOs and add the cause-chain vectors ErrorCause and SourceError reach every binding through the generator. AiMuxError/004 and AiMuxErrorInput/004 carry an ApiCall cause chain and are registered in index.json and sources.json. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C --- aimux-ffi/include/aimux.h | 4 +- bindings/flutter/lib/generated/ops.dart | 4 +- .../lib/src/generated/dto_vectors.dart | 6 + bindings/flutter/lib/src/generated/types.dart | 146 +++++++++++++++ bindings/flutter/native/aimux_ops.h | 4 +- bindings/go/generated/ops.go | 4 +- bindings/go/types_gen.go | 138 ++++++++++++++ bindings/java/generated/AimuxOps.java | 4 +- .../ai/arcships/aimux/types/AiMuxError.java | 21 ++- .../arcships/aimux/types/AiMuxErrorInput.java | 21 ++- .../ai/arcships/aimux/types/ApiCallError.java | 18 +- .../aimux/types/ApiCallErrorInput.java | 18 +- .../ai/arcships/aimux/types/DtoTypes.java | 6 + .../ai/arcships/aimux/types/ErrorCause.java | 62 +++++++ .../arcships/aimux/types/ErrorCauseInput.java | 62 +++++++ .../ai/arcships/aimux/types/SourceError.java | 60 ++++++ bindings/kotlin/generated/AimuxOps.kt | 4 +- bindings/node/src/generated/dto_vectors.ts | 6 + bindings/node/src/generated/ops.ts | 4 +- bindings/node/src/generated/types.ts | 66 +++++++ bindings/python/python/aimux/_types.py | 55 ++++++ bindings/python/python/aimux/ops.py | 4 +- bindings/swift/generated/AimuxOps.swift | 4 +- bindings/swift/generated/DtoVectorTypes.swift | 3 + bindings/swift/generated/Types.swift | 172 +++++++++++++++--- contract-tests/dto/AiMuxError/004.json | 25 +++ contract-tests/dto/AiMuxError/005.json | 15 ++ contract-tests/dto/AiMuxErrorInput/004.json | 25 +++ contract-tests/dto/AiMuxErrorInput/005.json | 15 ++ contract-tests/dto/index.json | 16 ++ contract-tests/dto/sources.json | 20 ++ contract-tests/ops/errors/aimux-api-call.json | 11 +- schema/types/AiMuxError.json | 2 +- schema/types/AiMuxErrorInput.json | 2 +- schema/types/ApiCallError.json | 11 ++ schema/types/ApiCallErrorInput.json | 11 ++ schema/types/ErrorCause.json | 15 ++ schema/types/ErrorCauseInput.json | 15 ++ schema/types/SourceError.json | 27 +++ tools/aimux-codegen/generated-files.json | 8 +- 40 files changed, 1062 insertions(+), 52 deletions(-) create mode 100644 bindings/java/generated/ai/arcships/aimux/types/ErrorCause.java create mode 100644 bindings/java/generated/ai/arcships/aimux/types/ErrorCauseInput.java create mode 100644 bindings/java/generated/ai/arcships/aimux/types/SourceError.java create mode 100644 contract-tests/dto/AiMuxError/004.json create mode 100644 contract-tests/dto/AiMuxError/005.json create mode 100644 contract-tests/dto/AiMuxErrorInput/004.json create mode 100644 contract-tests/dto/AiMuxErrorInput/005.json create mode 100644 schema/types/ErrorCause.json create mode 100644 schema/types/ErrorCauseInput.json create mode 100644 schema/types/SourceError.json diff --git a/aimux-ffi/include/aimux.h b/aimux-ffi/include/aimux.h index c89ee5463..b9af8447b 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: e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173 */ +/* Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 */ #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 "e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173" +#define AIMUX_MANIFEST_DIGEST "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" #define AIMUX_OP_ABORT_CREATE "abort.create" #define AIMUX_OP_ABORT_SIGNAL "abort.signal" diff --git a/bindings/flutter/lib/generated/ops.dart b/bindings/flutter/lib/generated/ops.dart index 4f51f31cb..079760f86 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: e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173 +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 const int ABI_VERSION = 65536; const String OPS_VERSION = "1.0"; -const String MANIFEST_DIGEST = "e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173"; +const String MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95"; 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/dto_vectors.dart b/bindings/flutter/lib/src/generated/dto_vectors.dart index 1529bb134..78b09b493 100644 --- a/bindings/flutter/lib/src/generated/dto_vectors.dart +++ b/bindings/flutter/lib/src/generated/dto_vectors.dart @@ -121,6 +121,10 @@ Object? roundTripDto(String type, Object? value) { return EmbeddingUsage.fromJson(value as Map).toJson(); case 'EmptyParams': return EmptyParams.fromJson(value as Map).toJson(); + case 'ErrorCause': + return ErrorCause.fromJson(value).toJson(); + case 'ErrorCauseInput': + return ErrorCauseInput.fromJson(value).toJson(); case 'ErrorDomain': return ErrorDomain.fromJson(value).toJson(); case 'ErrorEnvelope': @@ -464,6 +468,8 @@ Object? roundTripDto(String type, Object? value) { return Size.fromJson(value).toJson(); case 'Source': return Source.fromJson(value as Map).toJson(); + case 'SourceError': + return SourceError.fromJson(value as Map).toJson(); case 'SpeechCallOptions': return SpeechCallOptions.fromJson(value as Map).toJson(); case 'SpeechRequest': diff --git a/bindings/flutter/lib/src/generated/types.dart b/bindings/flutter/lib/src/generated/types.dart index 889d7995f..741fcea75 100644 --- a/bindings/flutter/lib/src/generated/types.dart +++ b/bindings/flutter/lib/src/generated/types.dart @@ -45,6 +45,9 @@ String _timestamp(String text) { /// Variants are cut by *what the caller does about it*, not by where the /// failure came from. `ApiCallError` is boxed only to keep the Rust enum /// compact; serde and every binding still observe the same object shape. +/// +/// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a +/// retry's last attempt and a repair's `cause`. sealed class AiMuxError { const AiMuxError(); String get name; @@ -103,6 +106,11 @@ sealed class AiMuxError { } final class AiMuxErrorAIAPICallError extends AiMuxError { + /// The error this failure was built from (`APICallError.cause`): the + /// transport error with its source chain, the body parse error, or the + /// aimux error a response handler returned. + final ErrorCause? cause; + /// Parsed provider error data. final Field data; @@ -145,6 +153,7 @@ final class AiMuxErrorAIAPICallError extends AiMuxError { /// Sanitized request URL. Required for every API-derived failure. final String url; const AiMuxErrorAIAPICallError({ + this.cause, this.data = const Field.absent(), required this.isRetryable, required this.message, @@ -159,6 +168,7 @@ final class AiMuxErrorAIAPICallError extends AiMuxError { String get name => 'AI_APICallError'; factory AiMuxErrorAIAPICallError.fromJson(Map json) { return AiMuxErrorAIAPICallError( + cause: json['cause'] == null ? null : ErrorCause.fromJson(json['cause']), data: json.containsKey('data') ? Field.of(json['data']) : const Field.absent(), @@ -177,6 +187,7 @@ final class AiMuxErrorAIAPICallError extends AiMuxError { Map toJson() => { 'name': 'AI_APICallError', + if (cause != null) 'cause': cause!.toJson(), if (data.isPresent) 'data': data.value, 'isRetryable': isRetryable, 'message': message, @@ -689,6 +700,9 @@ final class AiMuxErrorAIError extends AiMuxError { /// Variants are cut by *what the caller does about it*, not by where the /// failure came from. `ApiCallError` is boxed only to keep the Rust enum /// compact; serde and every binding still observe the same object shape. +/// +/// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a +/// retry's last attempt and a repair's `cause`. sealed class AiMuxErrorInput { const AiMuxErrorInput(); String get name; @@ -747,6 +761,11 @@ sealed class AiMuxErrorInput { } final class AiMuxErrorInputAIAPICallError extends AiMuxErrorInput { + /// The error this failure was built from (`APICallError.cause`): the + /// transport error with its source chain, the body parse error, or the + /// aimux error a response handler returned. + final ErrorCauseInput? cause; + /// Parsed provider error data. final Field data; @@ -789,6 +808,7 @@ final class AiMuxErrorInputAIAPICallError extends AiMuxErrorInput { /// Sanitized request URL. Required for every API-derived failure. final String url; const AiMuxErrorInputAIAPICallError({ + this.cause, this.data = const Field.absent(), this.isRetryable, required this.message, @@ -803,6 +823,9 @@ final class AiMuxErrorInputAIAPICallError extends AiMuxErrorInput { String get name => 'AI_APICallError'; factory AiMuxErrorInputAIAPICallError.fromJson(Map json) { return AiMuxErrorInputAIAPICallError( + cause: json['cause'] == null + ? null + : ErrorCauseInput.fromJson(json['cause']), data: json.containsKey('data') ? Field.of(json['data']) : const Field.absent(), @@ -821,6 +844,7 @@ final class AiMuxErrorInputAIAPICallError extends AiMuxErrorInput { Map toJson() => { 'name': 'AI_APICallError', + if (cause != null) 'cause': cause!.toJson(), if (data.isPresent) 'data': data.value, if (isRetryable != null) 'isRetryable': isRetryable, 'message': message, @@ -1358,6 +1382,11 @@ final class AiMuxErrorInputAIError extends AiMuxErrorInput { /// This keeps transport and response failures self-contained and matches the /// AI SDK's `APICallError` contract. final class ApiCallError { + /// The error this failure was built from (`APICallError.cause`): the + /// transport error with its source chain, the body parse error, or the + /// aimux error a response handler returned. + final ErrorCause? cause; + /// Parsed provider error data. final Field data; @@ -1400,6 +1429,7 @@ final class ApiCallError { /// Sanitized request URL. Required for every API-derived failure. final String url; const ApiCallError({ + this.cause, this.data = const Field.absent(), required this.isRetryable, required this.message, @@ -1412,6 +1442,7 @@ final class ApiCallError { }); factory ApiCallError.fromJson(Map json) { return ApiCallError( + cause: json['cause'] == null ? null : ErrorCause.fromJson(json['cause']), data: json.containsKey('data') ? Field.of(json['data']) : const Field.absent(), @@ -1429,6 +1460,7 @@ final class ApiCallError { } Map toJson() => { + if (cause != null) 'cause': cause!.toJson(), if (data.isPresent) 'data': data.value, 'isRetryable': isRetryable, 'message': message, @@ -1462,6 +1494,11 @@ final class ApiCallError { /// This keeps transport and response failures self-contained and matches the /// AI SDK's `APICallError` contract. final class ApiCallErrorInput { + /// The error this failure was built from (`APICallError.cause`): the + /// transport error with its source chain, the body parse error, or the + /// aimux error a response handler returned. + final ErrorCauseInput? cause; + /// Parsed provider error data. final Field data; @@ -1504,6 +1541,7 @@ final class ApiCallErrorInput { /// Sanitized request URL. Required for every API-derived failure. final String url; const ApiCallErrorInput({ + this.cause, this.data = const Field.absent(), this.isRetryable, required this.message, @@ -1516,6 +1554,9 @@ final class ApiCallErrorInput { }); factory ApiCallErrorInput.fromJson(Map json) { return ApiCallErrorInput( + cause: json['cause'] == null + ? null + : ErrorCauseInput.fromJson(json['cause']), data: json.containsKey('data') ? Field.of(json['data']) : const Field.absent(), @@ -1533,6 +1574,7 @@ final class ApiCallErrorInput { } Map toJson() => { + if (cause != null) 'cause': cause!.toJson(), if (data.isPresent) 'data': data.value, if (isRetryable != null) 'isRetryable': isRetryable, 'message': message, @@ -4470,6 +4512,80 @@ final class EmptyParams { Map toJson() => {}; } +/// The error another error was built from: the AI SDK's `cause`. +/// +/// An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an +/// `APICallError`; any other error is a [`SourceError`]. The JSON form is the +/// nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. +sealed class ErrorCause { + const ErrorCause(); + factory ErrorCause.fromJson(Object? value) { + try { + return ErrorCauseBranchAiMuxError.fromJson(value); + } catch (_) { + // Try the next branch in declaration order. + } + return ErrorCauseBranchSourceError.fromJson(value); + } + + Object? toJson(); +} + +final class ErrorCauseBranchAiMuxError extends ErrorCause { + final AiMuxError value; + const ErrorCauseBranchAiMuxError(this.value); + factory ErrorCauseBranchAiMuxError.fromJson(Object? value) => + ErrorCauseBranchAiMuxError( + AiMuxError.fromJson(value as Map)); + Object? toJson() => value.toJson(); +} + +final class ErrorCauseBranchSourceError extends ErrorCause { + final SourceError value; + const ErrorCauseBranchSourceError(this.value); + factory ErrorCauseBranchSourceError.fromJson(Object? value) => + ErrorCauseBranchSourceError( + SourceError.fromJson(value as Map)); + Object? toJson() => value.toJson(); +} + +/// The error another error was built from: the AI SDK's `cause`. +/// +/// An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an +/// `APICallError`; any other error is a [`SourceError`]. The JSON form is the +/// nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. +sealed class ErrorCauseInput { + const ErrorCauseInput(); + factory ErrorCauseInput.fromJson(Object? value) { + try { + return ErrorCauseInputBranchAiMuxErrorInput.fromJson(value); + } catch (_) { + // Try the next branch in declaration order. + } + return ErrorCauseInputBranchSourceError.fromJson(value); + } + + Object? toJson(); +} + +final class ErrorCauseInputBranchAiMuxErrorInput extends ErrorCauseInput { + final AiMuxErrorInput value; + const ErrorCauseInputBranchAiMuxErrorInput(this.value); + factory ErrorCauseInputBranchAiMuxErrorInput.fromJson(Object? value) => + ErrorCauseInputBranchAiMuxErrorInput( + AiMuxErrorInput.fromJson(value as Map)); + Object? toJson() => value.toJson(); +} + +final class ErrorCauseInputBranchSourceError extends ErrorCauseInput { + final SourceError value; + const ErrorCauseInputBranchSourceError(this.value); + factory ErrorCauseInputBranchSourceError.fromJson(Object? value) => + ErrorCauseInputBranchSourceError( + SourceError.fromJson(value as Map)); + Object? toJson() => value.toJson(); +} + enum ErrorDomain { aimux('aimux'), recording('recording'), @@ -10808,6 +10924,36 @@ final class SourceDocument extends Source { }; } +/// An error from outside aimux (a transport, parser or I/O error), kept as +/// its message and the messages of its own sources, so it can be cloned, +/// serialized and sent across the bindings with the error built from it. +/// +/// The JSON form is a JavaScript `Error`'s own `{ message, cause }`. +final class SourceError { + /// The error's own `source()`, recursively. + final SourceError? cause; + + /// The error's `Display` text. + final String message; + const SourceError({ + this.cause, + required this.message, + }); + factory SourceError.fromJson(Map json) { + return SourceError( + cause: json['cause'] == null + ? null + : SourceError.fromJson(json['cause'] as Map), + message: json['message'] as String, + ); + } + + Map toJson() => { + if (cause != null) 'cause': cause!.toJson(), + 'message': message, + }; +} + /// Options passed to [`SpeechModel::do_generate`]. /// /// Aligned with V4 `SpeechModelV4CallOptions`. diff --git a/bindings/flutter/native/aimux_ops.h b/bindings/flutter/native/aimux_ops.h index c89ee5463..b9af8447b 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: e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173 */ +/* Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 */ #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 "e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173" +#define AIMUX_MANIFEST_DIGEST "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" #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 c3e6cfe8c..d8ea3ad92 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: e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173 +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 package generated const ( ABI_VERSION = 65536 OPS_VERSION = "1.0" - MANIFEST_DIGEST = "e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173" + MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" OP_ABORT_CREATE = "abort.create" OP_ABORT_SIGNAL = "abort.signal" OP_AUTH_CODEX_REFRESH = "auth.codexRefresh" diff --git a/bindings/go/types_gen.go b/bindings/go/types_gen.go index aa1a6fa46..da5dcf33b 100644 --- a/bindings/go/types_gen.go +++ b/bindings/go/types_gen.go @@ -87,6 +87,9 @@ func decodeUnionBranch[T any](data []byte, dst *T, nullable bool, required ...st // Variants are cut by *what the caller does about it*, not by where the // failure came from. `ApiCallError` is boxed only to keep the Rust enum // compact; serde and every binding still observe the same object shape. +// +// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a +// retry's last attempt and a repair's `cause`. type AiMuxErrorVariant interface{ isAiMuxError() } type AiMuxError struct { Value AiMuxErrorVariant @@ -263,6 +266,10 @@ func (v *AiMuxError) UnmarshalJSON(data []byte) error { } type AiMuxErrorVariantAIAPICallError struct { + // The error this failure was built from (`APICallError.cause`): the + // transport error with its source chain, the body parse error, or the + // aimux error a response handler returned. + Cause *ErrorCause `json:"cause,omitzero"` // Parsed provider error data. Data Field[json.RawMessage] `json:"data,omitzero"` // Whether retrying can help (`APICallError.isRetryable`) — stored at @@ -632,6 +639,9 @@ func (v AiMuxErrorVariantAIError) MarshalJSON() ([]byte, error) { // Variants are cut by *what the caller does about it*, not by where the // failure came from. `ApiCallError` is boxed only to keep the Rust enum // compact; serde and every binding still observe the same object shape. +// +// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a +// retry's last attempt and a repair's `cause`. type AiMuxErrorInputVariant interface{ isAiMuxErrorInput() } type AiMuxErrorInput struct { Value AiMuxErrorInputVariant @@ -808,6 +818,10 @@ func (v *AiMuxErrorInput) UnmarshalJSON(data []byte) error { } type AiMuxErrorInputVariantAIAPICallError struct { + // The error this failure was built from (`APICallError.cause`): the + // transport error with its source chain, the body parse error, or the + // aimux error a response handler returned. + Cause *ErrorCauseInput `json:"cause,omitzero"` // Parsed provider error data. Data Field[json.RawMessage] `json:"data,omitzero"` // Whether retrying can help (`APICallError.isRetryable`) — stored at @@ -1193,6 +1207,10 @@ func (v AiMuxErrorInputVariantAIError) MarshalJSON() ([]byte, error) { // This keeps transport and response failures self-contained and matches the // AI SDK's `APICallError` contract. type APICallError struct { + // The error this failure was built from (`APICallError.cause`): the + // transport error with its source chain, the body parse error, or the + // aimux error a response handler returned. + Cause *ErrorCause `json:"cause,omitzero"` // Parsed provider error data. Data Field[json.RawMessage] `json:"data,omitzero"` // Whether retrying can help (`APICallError.isRetryable`) — stored at @@ -1249,6 +1267,10 @@ type APICallError struct { // This keeps transport and response failures self-contained and matches the // AI SDK's `APICallError` contract. type APICallErrorInput struct { + // The error this failure was built from (`APICallError.cause`): the + // transport error with its source chain, the body parse error, or the + // aimux error a response handler returned. + Cause *ErrorCauseInput `json:"cause,omitzero"` // Parsed provider error data. Data Field[json.RawMessage] `json:"data,omitzero"` // Whether retrying can help (`APICallError.isRetryable`) — stored at @@ -3061,6 +3083,104 @@ type EmbeddingUsage struct { type EmptyParams struct{} +// The error another error was built from: the AI SDK's `cause`. +// +// An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an +// `APICallError`; any other error is a [`SourceError`]. The JSON form is the +// nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. +type ErrorCauseVariant interface{ isErrorCause() } +type ErrorCause struct { + Value ErrorCauseVariant +} + +func (v ErrorCause) MarshalJSON() ([]byte, error) { + return json.Marshal(v.Value) +} + +func (v *ErrorCause) UnmarshalJSON(data []byte) error { + var err error + { + var next AiMuxError + if err = decodeUnionBranch(data, &next, false); err == nil { + v.Value = ErrorCauseBranchAiMuxError{Value: next} + return nil + } + } + { + var next SourceError + if err = decodeUnionBranch(data, &next, false, "message"); err == nil { + v.Value = ErrorCauseBranchSourceError{Value: next} + return nil + } + } + return err +} + +type ErrorCauseBranchAiMuxError struct{ Value AiMuxError } + +func (ErrorCauseBranchAiMuxError) isErrorCause() {} + +func (v ErrorCauseBranchAiMuxError) MarshalJSON() ([]byte, error) { + return json.Marshal(v.Value) +} + +type ErrorCauseBranchSourceError struct{ Value SourceError } + +func (ErrorCauseBranchSourceError) isErrorCause() {} + +func (v ErrorCauseBranchSourceError) MarshalJSON() ([]byte, error) { + return json.Marshal(v.Value) +} + +// The error another error was built from: the AI SDK's `cause`. +// +// An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an +// `APICallError`; any other error is a [`SourceError`]. The JSON form is the +// nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. +type ErrorCauseInputVariant interface{ isErrorCauseInput() } +type ErrorCauseInput struct { + Value ErrorCauseInputVariant +} + +func (v ErrorCauseInput) MarshalJSON() ([]byte, error) { + return json.Marshal(v.Value) +} + +func (v *ErrorCauseInput) UnmarshalJSON(data []byte) error { + var err error + { + var next AiMuxErrorInput + if err = decodeUnionBranch(data, &next, false); err == nil { + v.Value = ErrorCauseInputBranchAiMuxErrorInput{Value: next} + return nil + } + } + { + var next SourceError + if err = decodeUnionBranch(data, &next, false, "message"); err == nil { + v.Value = ErrorCauseInputBranchSourceError{Value: next} + return nil + } + } + return err +} + +type ErrorCauseInputBranchAiMuxErrorInput struct{ Value AiMuxErrorInput } + +func (ErrorCauseInputBranchAiMuxErrorInput) isErrorCauseInput() {} + +func (v ErrorCauseInputBranchAiMuxErrorInput) MarshalJSON() ([]byte, error) { + return json.Marshal(v.Value) +} + +type ErrorCauseInputBranchSourceError struct{ Value SourceError } + +func (ErrorCauseInputBranchSourceError) isErrorCauseInput() {} + +func (v ErrorCauseInputBranchSourceError) MarshalJSON() ([]byte, error) { + return json.Marshal(v.Value) +} + type ErrorDomain string const ( @@ -6301,6 +6421,18 @@ func (v SourceVariantDocument) MarshalJSON() ([]byte, error) { }{SourceType: "document", plain: plain(v)}) } +// An error from outside aimux (a transport, parser or I/O error), kept as +// its message and the messages of its own sources, so it can be cloned, +// serialized and sent across the bindings with the error built from it. +// +// The JSON form is a JavaScript `Error`'s own `{ message, cause }`. +type SourceError struct { + // The error's own `source()`, recursively. + Cause *SourceError `json:"cause,omitzero"` + // The error's `Display` text. + Message string `json:"message"` +} + // Options passed to [`SpeechModel::do_generate`]. // // Aligned with V4 `SpeechModelV4CallOptions`. @@ -10149,6 +10281,10 @@ func NewDTO(name string) (any, error) { return new(EmbeddingUsage), nil case "EmptyParams": return new(EmptyParams), nil + case "ErrorCause": + return new(ErrorCause), nil + case "ErrorCauseInput": + return new(ErrorCauseInput), nil case "ErrorDomain": return new(ErrorDomain), nil case "ErrorEnvelope": @@ -10449,6 +10585,8 @@ func NewDTO(name string) (any, error) { return new(Size), nil case "Source": return new(Source), nil + case "SourceError": + return new(SourceError), nil case "SpeechCallOptions": return new(SpeechCallOptions), nil case "SpeechRequest": diff --git a/bindings/java/generated/AimuxOps.java b/bindings/java/generated/AimuxOps.java index 73feaa73e..80ab02faf 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: e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173 +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 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 = "e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173"; + public static final String MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95"; 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 0b0615a3a..8f32455af 100644 --- a/bindings/java/generated/ai/arcships/aimux/types/AiMuxError.java +++ b/bindings/java/generated/ai/arcships/aimux/types/AiMuxError.java @@ -13,6 +13,9 @@ * Variants are cut by *what the caller does about it*, not by where the * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. + * + * `source()` follows the AI SDK's `cause`: an API failure's `cause`, a + * retry's last attempt and a repair's `cause`. */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "name") // clang-format off @@ -104,6 +107,13 @@ private AiMuxError() {} @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIAPICallError extends AiMuxError { + /** + * The error this failure was built from (`APICallError.cause`): the + * transport error with its source chain, the body parse error, or the + * aimux error a response handler returned. + */ + @JsonProperty("cause") + private final ErrorCause cause; /** * Parsed provider error data. */ @@ -168,6 +178,7 @@ public static final class AIAPICallError extends AiMuxError { @JsonCreator public AIAPICallError( + @JsonProperty(value = "cause") ErrorCause cause, @JsonProperty(value = "data") FieldPresence data, @JsonProperty(value = "isRetryable", required = true) Boolean isRetryable, @JsonProperty(value = "message", required = true) String message, @@ -177,6 +188,7 @@ public AIAPICallError( @JsonProperty(value = "responseHeaders") Map responseHeaders, @JsonProperty(value = "statusCode") Integer statusCode, @JsonProperty(value = "url", required = true) String url) { + this.cause = cause; this.data = data == null ? FieldPresence.absent() : data; this.isRetryable = isRetryable; this.message = message; @@ -193,6 +205,7 @@ public AIAPICallError( @Override @JsonProperty("name") public String getName() { return "AI_APICallError"; } + public ErrorCause getCause() { return cause; } public JsonNode getData() { return data.getValue() == null ? null : data.getValue().deepCopy(); } public boolean hasData() { return data.isPresent(); } public Boolean getIsRetryable() { return isRetryable; } @@ -205,6 +218,7 @@ public AIAPICallError( public String getUrl() { return url; } public static final class Builder { + private ErrorCause cause; private FieldPresence data = FieldPresence.absent(); private Boolean isRetryable; private String message; @@ -215,6 +229,11 @@ public static final class Builder { private Integer statusCode; private String url; + public Builder cause(ErrorCause value) { + this.cause = value; + return this; + } + public Builder data(JsonNode value) { this.data = FieldPresence.of(value == null ? null : value.deepCopy()); return this; @@ -265,7 +284,7 @@ public Builder url(String value) { return this; } - public AIAPICallError build() { return new AIAPICallError(data, isRetryable, message, providerCode, requestBodyValues, responseBody, responseHeaders, statusCode, url); } + public AIAPICallError build() { return new AIAPICallError(cause, data, isRetryable, message, providerCode, requestBodyValues, responseBody, responseHeaders, statusCode, url); } } } diff --git a/bindings/java/generated/ai/arcships/aimux/types/AiMuxErrorInput.java b/bindings/java/generated/ai/arcships/aimux/types/AiMuxErrorInput.java index ca0d06193..8140636ed 100644 --- a/bindings/java/generated/ai/arcships/aimux/types/AiMuxErrorInput.java +++ b/bindings/java/generated/ai/arcships/aimux/types/AiMuxErrorInput.java @@ -13,6 +13,9 @@ * Variants are cut by *what the caller does about it*, not by where the * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. + * + * `source()` follows the AI SDK's `cause`: an API failure's `cause`, a + * retry's last attempt and a repair's `cause`. */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "name") // clang-format off @@ -104,6 +107,13 @@ private AiMuxErrorInput() {} @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) @JsonDeserialize(using = JsonDeserializer.None.class) public static final class AIAPICallError extends AiMuxErrorInput { + /** + * The error this failure was built from (`APICallError.cause`): the + * transport error with its source chain, the body parse error, or the + * aimux error a response handler returned. + */ + @JsonProperty("cause") + private final ErrorCauseInput cause; /** * Parsed provider error data. */ @@ -168,6 +178,7 @@ public static final class AIAPICallError extends AiMuxErrorInput { @JsonCreator public AIAPICallError( + @JsonProperty(value = "cause") ErrorCauseInput cause, @JsonProperty(value = "data") FieldPresence data, @JsonProperty(value = "isRetryable") Boolean isRetryable, @JsonProperty(value = "message", required = true) String message, @@ -177,6 +188,7 @@ public AIAPICallError( @JsonProperty(value = "responseHeaders") Map responseHeaders, @JsonProperty(value = "statusCode") Integer statusCode, @JsonProperty(value = "url", required = true) String url) { + this.cause = cause; this.data = data == null ? FieldPresence.absent() : data; this.isRetryable = isRetryable; this.message = message; @@ -193,6 +205,7 @@ public AIAPICallError( @Override @JsonProperty("name") public String getName() { return "AI_APICallError"; } + public ErrorCauseInput getCause() { return cause; } public JsonNode getData() { return data.getValue() == null ? null : data.getValue().deepCopy(); } public boolean hasData() { return data.isPresent(); } public Boolean getIsRetryable() { return isRetryable; } @@ -205,6 +218,7 @@ public AIAPICallError( public String getUrl() { return url; } public static final class Builder { + private ErrorCauseInput cause; private FieldPresence data = FieldPresence.absent(); private Boolean isRetryable; private String message; @@ -215,6 +229,11 @@ public static final class Builder { private Integer statusCode; private String url; + public Builder cause(ErrorCauseInput value) { + this.cause = value; + return this; + } + public Builder data(JsonNode value) { this.data = FieldPresence.of(value == null ? null : value.deepCopy()); return this; @@ -265,7 +284,7 @@ public Builder url(String value) { return this; } - public AIAPICallError build() { return new AIAPICallError(data, isRetryable, message, providerCode, requestBodyValues, responseBody, responseHeaders, statusCode, url); } + public AIAPICallError build() { return new AIAPICallError(cause, data, isRetryable, message, providerCode, requestBodyValues, responseBody, responseHeaders, statusCode, url); } } } diff --git a/bindings/java/generated/ai/arcships/aimux/types/ApiCallError.java b/bindings/java/generated/ai/arcships/aimux/types/ApiCallError.java index 8e7e541f6..6ff98188c 100644 --- a/bindings/java/generated/ai/arcships/aimux/types/ApiCallError.java +++ b/bindings/java/generated/ai/arcships/aimux/types/ApiCallError.java @@ -33,6 +33,13 @@ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE) public final class ApiCallError { + /** + * The error this failure was built from (`APICallError.cause`): the + * transport error with its source chain, the body parse error, or the + * aimux error a response handler returned. + */ + @JsonProperty("cause") + private final ErrorCause cause; /** * Parsed provider error data. */ @@ -97,6 +104,7 @@ public final class ApiCallError { @JsonCreator public ApiCallError( + @JsonProperty(value = "cause") ErrorCause cause, @JsonProperty(value = "data") FieldPresence data, @JsonProperty(value = "isRetryable", required = true) Boolean isRetryable, @JsonProperty(value = "message", required = true) String message, @@ -106,6 +114,7 @@ public ApiCallError( @JsonProperty(value = "responseHeaders") Map responseHeaders, @JsonProperty(value = "statusCode") Integer statusCode, @JsonProperty(value = "url", required = true) String url) { + this.cause = cause; this.data = data == null ? FieldPresence.absent() : data; this.isRetryable = isRetryable; this.message = message; @@ -118,6 +127,7 @@ public ApiCallError( } public static Builder builder() { return new Builder(); } + public ErrorCause getCause() { return cause; } public JsonNode getData() { return data.getValue() == null ? null : data.getValue().deepCopy(); } public boolean hasData() { return data.isPresent(); } public Boolean getIsRetryable() { return isRetryable; } @@ -130,6 +140,7 @@ public ApiCallError( public String getUrl() { return url; } public static final class Builder { + private ErrorCause cause; private FieldPresence data = FieldPresence.absent(); private Boolean isRetryable; private String message; @@ -140,6 +151,11 @@ public static final class Builder { private Integer statusCode; private String url; + public Builder cause(ErrorCause value) { + this.cause = value; + return this; + } + public Builder data(JsonNode value) { this.data = FieldPresence.of(value == null ? null : value.deepCopy()); return this; @@ -190,6 +206,6 @@ public Builder url(String value) { return this; } - public ApiCallError build() { return new ApiCallError(data, isRetryable, message, providerCode, requestBodyValues, responseBody, responseHeaders, statusCode, url); } + public ApiCallError build() { return new ApiCallError(cause, data, isRetryable, message, providerCode, requestBodyValues, responseBody, responseHeaders, statusCode, url); } } } diff --git a/bindings/java/generated/ai/arcships/aimux/types/ApiCallErrorInput.java b/bindings/java/generated/ai/arcships/aimux/types/ApiCallErrorInput.java index 333c1afce..e0a19032a 100644 --- a/bindings/java/generated/ai/arcships/aimux/types/ApiCallErrorInput.java +++ b/bindings/java/generated/ai/arcships/aimux/types/ApiCallErrorInput.java @@ -33,6 +33,13 @@ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE) public final class ApiCallErrorInput { + /** + * The error this failure was built from (`APICallError.cause`): the + * transport error with its source chain, the body parse error, or the + * aimux error a response handler returned. + */ + @JsonProperty("cause") + private final ErrorCauseInput cause; /** * Parsed provider error data. */ @@ -97,6 +104,7 @@ public final class ApiCallErrorInput { @JsonCreator public ApiCallErrorInput( + @JsonProperty(value = "cause") ErrorCauseInput cause, @JsonProperty(value = "data") FieldPresence data, @JsonProperty(value = "isRetryable") Boolean isRetryable, @JsonProperty(value = "message", required = true) String message, @@ -106,6 +114,7 @@ public ApiCallErrorInput( @JsonProperty(value = "responseHeaders") Map responseHeaders, @JsonProperty(value = "statusCode") Integer statusCode, @JsonProperty(value = "url", required = true) String url) { + this.cause = cause; this.data = data == null ? FieldPresence.absent() : data; this.isRetryable = isRetryable; this.message = message; @@ -118,6 +127,7 @@ public ApiCallErrorInput( } public static Builder builder() { return new Builder(); } + public ErrorCauseInput getCause() { return cause; } public JsonNode getData() { return data.getValue() == null ? null : data.getValue().deepCopy(); } public boolean hasData() { return data.isPresent(); } public Boolean getIsRetryable() { return isRetryable; } @@ -130,6 +140,7 @@ public ApiCallErrorInput( public String getUrl() { return url; } public static final class Builder { + private ErrorCauseInput cause; private FieldPresence data = FieldPresence.absent(); private Boolean isRetryable; private String message; @@ -140,6 +151,11 @@ public static final class Builder { private Integer statusCode; private String url; + public Builder cause(ErrorCauseInput value) { + this.cause = value; + return this; + } + public Builder data(JsonNode value) { this.data = FieldPresence.of(value == null ? null : value.deepCopy()); return this; @@ -190,6 +206,6 @@ public Builder url(String value) { return this; } - public ApiCallErrorInput build() { return new ApiCallErrorInput(data, isRetryable, message, providerCode, requestBodyValues, responseBody, responseHeaders, statusCode, url); } + public ApiCallErrorInput build() { return new ApiCallErrorInput(cause, data, isRetryable, message, providerCode, requestBodyValues, responseBody, responseHeaders, statusCode, url); } } } diff --git a/bindings/java/generated/ai/arcships/aimux/types/DtoTypes.java b/bindings/java/generated/ai/arcships/aimux/types/DtoTypes.java index 479cee6ab..1b34394df 100644 --- a/bindings/java/generated/ai/arcships/aimux/types/DtoTypes.java +++ b/bindings/java/generated/ai/arcships/aimux/types/DtoTypes.java @@ -113,6 +113,10 @@ public static JsonNode roundTrip(String type, JsonNode value) throws java.io.IOE return DtoJson.MAPPER.valueToTree(DtoJson.MAPPER.treeToValue(value, EmbeddingUsage.class)); case "EmptyParams": return DtoJson.MAPPER.valueToTree(DtoJson.MAPPER.treeToValue(value, EmptyParams.class)); + case "ErrorCause": + return DtoJson.MAPPER.valueToTree(DtoJson.MAPPER.treeToValue(value, ErrorCause.class)); + case "ErrorCauseInput": + return DtoJson.MAPPER.valueToTree(DtoJson.MAPPER.treeToValue(value, ErrorCauseInput.class)); case "ErrorDomain": return DtoJson.MAPPER.valueToTree(DtoJson.MAPPER.treeToValue(value, ErrorDomain.class)); case "ErrorEnvelope": @@ -413,6 +417,8 @@ public static JsonNode roundTrip(String type, JsonNode value) throws java.io.IOE return DtoJson.MAPPER.valueToTree(DtoJson.MAPPER.treeToValue(value, Size.class)); case "Source": return DtoJson.MAPPER.valueToTree(DtoJson.MAPPER.treeToValue(value, Source.class)); + case "SourceError": + return DtoJson.MAPPER.valueToTree(DtoJson.MAPPER.treeToValue(value, SourceError.class)); case "SpeechCallOptions": return DtoJson.MAPPER.valueToTree(DtoJson.MAPPER.treeToValue(value, SpeechCallOptions.class)); case "SpeechRequest": diff --git a/bindings/java/generated/ai/arcships/aimux/types/ErrorCause.java b/bindings/java/generated/ai/arcships/aimux/types/ErrorCause.java new file mode 100644 index 000000000..042e00c4b --- /dev/null +++ b/bindings/java/generated/ai/arcships/aimux/types/ErrorCause.java @@ -0,0 +1,62 @@ +// Generated by aimux-codegen. Do not edit. +package ai.arcships.aimux.types; + +import com.fasterxml.jackson.annotation.*; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.math.BigDecimal; +import java.util.*; + +/** + * The error another error was built from: the AI SDK's `cause`. + * + * An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an + * `APICallError`; any other error is a [`SourceError`]. The JSON form is the + * nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. + */ +@JsonDeserialize(using = ErrorCause.Deserializer.class) +public abstract class ErrorCause { + private ErrorCause() {} + + public static final class Deserializer extends JsonDeserializer { + @Override + public ErrorCause deserialize(com.fasterxml.jackson.core.JsonParser parser, DeserializationContext context) + throws java.io.IOException { + JsonNode value = context.readTree(parser); + JsonMappingException failure = null; + try { + return parser.getCodec().treeToValue(value, AiMuxError.class); + } catch (JsonMappingException error) { + failure = error; + } + try { + return parser.getCodec().treeToValue(value, SourceError.class); + } catch (JsonMappingException error) { + failure = error; + } + throw failure; + } + } + + @JsonDeserialize(using = JsonDeserializer.None.class) + public static final class AiMuxError extends ErrorCause { + @JsonValue + private final ai.arcships.aimux.types.AiMuxError value; + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public AiMuxError(ai.arcships.aimux.types.AiMuxError value) { this.value = value; } + + public ai.arcships.aimux.types.AiMuxError getValue() { return value; } + } + + @JsonDeserialize(using = JsonDeserializer.None.class) + public static final class SourceError extends ErrorCause { + @JsonValue + private final ai.arcships.aimux.types.SourceError value; + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public SourceError(ai.arcships.aimux.types.SourceError value) { this.value = value; } + + public ai.arcships.aimux.types.SourceError getValue() { return value; } + } +} diff --git a/bindings/java/generated/ai/arcships/aimux/types/ErrorCauseInput.java b/bindings/java/generated/ai/arcships/aimux/types/ErrorCauseInput.java new file mode 100644 index 000000000..fde31031c --- /dev/null +++ b/bindings/java/generated/ai/arcships/aimux/types/ErrorCauseInput.java @@ -0,0 +1,62 @@ +// Generated by aimux-codegen. Do not edit. +package ai.arcships.aimux.types; + +import com.fasterxml.jackson.annotation.*; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.math.BigDecimal; +import java.util.*; + +/** + * The error another error was built from: the AI SDK's `cause`. + * + * An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an + * `APICallError`; any other error is a [`SourceError`]. The JSON form is the + * nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. + */ +@JsonDeserialize(using = ErrorCauseInput.Deserializer.class) +public abstract class ErrorCauseInput { + private ErrorCauseInput() {} + + public static final class Deserializer extends JsonDeserializer { + @Override + public ErrorCauseInput deserialize(com.fasterxml.jackson.core.JsonParser parser, DeserializationContext context) + throws java.io.IOException { + JsonNode value = context.readTree(parser); + JsonMappingException failure = null; + try { + return parser.getCodec().treeToValue(value, AiMuxErrorInput.class); + } catch (JsonMappingException error) { + failure = error; + } + try { + return parser.getCodec().treeToValue(value, SourceError.class); + } catch (JsonMappingException error) { + failure = error; + } + throw failure; + } + } + + @JsonDeserialize(using = JsonDeserializer.None.class) + public static final class AiMuxErrorInput extends ErrorCauseInput { + @JsonValue + private final ai.arcships.aimux.types.AiMuxErrorInput value; + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public AiMuxErrorInput(ai.arcships.aimux.types.AiMuxErrorInput value) { this.value = value; } + + public ai.arcships.aimux.types.AiMuxErrorInput getValue() { return value; } + } + + @JsonDeserialize(using = JsonDeserializer.None.class) + public static final class SourceError extends ErrorCauseInput { + @JsonValue + private final ai.arcships.aimux.types.SourceError value; + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public SourceError(ai.arcships.aimux.types.SourceError value) { this.value = value; } + + public ai.arcships.aimux.types.SourceError getValue() { return value; } + } +} diff --git a/bindings/java/generated/ai/arcships/aimux/types/SourceError.java b/bindings/java/generated/ai/arcships/aimux/types/SourceError.java new file mode 100644 index 000000000..f96e866ad --- /dev/null +++ b/bindings/java/generated/ai/arcships/aimux/types/SourceError.java @@ -0,0 +1,60 @@ +// Generated by aimux-codegen. Do not edit. +package ai.arcships.aimux.types; + +import com.fasterxml.jackson.annotation.*; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.math.BigDecimal; +import java.util.*; + +/** + * An error from outside aimux (a transport, parser or I/O error), kept as + * its message and the messages of its own sources, so it can be cloned, + * serialized and sent across the bindings with the error built from it. + * + * The JSON form is a JavaScript `Error`'s own `{ message, cause }`. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE) +public final class SourceError { + /** + * The error's own `source()`, recursively. + */ + @JsonProperty("cause") + private final SourceError cause; + /** + * The error's `Display` text. + */ + @JsonProperty("message") + private final String message; + + @JsonCreator + public SourceError( + @JsonProperty(value = "cause") SourceError cause, + @JsonProperty(value = "message", required = true) String message) { + this.cause = cause; + this.message = message; + } + + public static Builder builder() { return new Builder(); } + public SourceError getCause() { return cause; } + public String getMessage() { return message; } + + public static final class Builder { + private SourceError cause; + private String message; + + public Builder cause(SourceError value) { + this.cause = value; + return this; + } + + public Builder message(String value) { + this.message = value; + return this; + } + + public SourceError build() { return new SourceError(cause, message); } + } +} diff --git a/bindings/kotlin/generated/AimuxOps.kt b/bindings/kotlin/generated/AimuxOps.kt index 783b1208e..e12815673 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: e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173 +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 package ai.arcships.aimux.generated object AimuxKotlinOps { const val ABI_VERSION: Long = 65536 const val OPS_VERSION = "1.0" - const val MANIFEST_DIGEST = "e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173" + const val MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" 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/node/src/generated/dto_vectors.ts b/bindings/node/src/generated/dto_vectors.ts index 1ea6bb17f..42b1764b0 100644 --- a/bindings/node/src/generated/dto_vectors.ts +++ b/bindings/node/src/generated/dto_vectors.ts @@ -52,6 +52,8 @@ export interface DtoTypes { "EmbeddingResult": T.EmbeddingResult "EmbeddingUsage": T.EmbeddingUsage "EmptyParams": T.EmptyParams + "ErrorCause": T.ErrorCause + "ErrorCauseInput": T.ErrorCauseInput "ErrorDomain": T.ErrorDomain "ErrorEnvelope": T.ErrorEnvelope "Fallback": T.Fallback @@ -202,6 +204,7 @@ export interface DtoTypes { "SessionView": T.SessionView "Size": T.Size "Source": T.Source + "SourceError": T.SourceError "SpeechCallOptions": T.SpeechCallOptions "SpeechRequest": T.SpeechRequest "SpeechResponse": T.SpeechResponse @@ -395,6 +398,8 @@ export const dtoTypeNames = [ "EmbeddingResult", "EmbeddingUsage", "EmptyParams", + "ErrorCause", + "ErrorCauseInput", "ErrorDomain", "ErrorEnvelope", "Fallback", @@ -545,6 +550,7 @@ export const dtoTypeNames = [ "SessionView", "Size", "Source", + "SourceError", "SpeechCallOptions", "SpeechRequest", "SpeechResponse", diff --git a/bindings/node/src/generated/ops.ts b/bindings/node/src/generated/ops.ts index 25f824794..fb7399175 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: e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173 +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 export const ABI_VERSION = 65536 as const; export const OPS_VERSION = "1.0" as const; -export const MANIFEST_DIGEST = "e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173" as const; +export const MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" 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 f6b42d13c..618e31108 100644 --- a/bindings/node/src/generated/types.ts +++ b/bindings/node/src/generated/types.ts @@ -6,10 +6,19 @@ * Variants are cut by *what the caller does about it*, not by where the * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. + * + * `source()` follows the AI SDK's `cause`: an API failure's `cause`, a + * retry's last attempt and a repair's `cause`. */ export type AiMuxError = | { name: "AI_APICallError" + /** + * The error this failure was built from (`APICallError.cause`): the + * transport error with its source chain, the body parse error, or the + * aimux error a response handler returned. + */ + cause?: ErrorCause | null /** * Parsed provider error data. */ @@ -190,10 +199,19 @@ export type AiMuxError = * Variants are cut by *what the caller does about it*, not by where the * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. + * + * `source()` follows the AI SDK's `cause`: an API failure's `cause`, a + * retry's last attempt and a repair's `cause`. */ export type AiMuxErrorInput = | { name: "AI_APICallError" + /** + * The error this failure was built from (`APICallError.cause`): the + * transport error with its source chain, the body parse error, or the + * aimux error a response handler returned. + */ + cause?: ErrorCauseInput | null /** * Parsed provider error data. */ @@ -391,6 +409,12 @@ export type AiMuxErrorInput = * AI SDK's `APICallError` contract. */ export interface ApiCallError { + /** + * The error this failure was built from (`APICallError.cause`): the + * transport error with its source chain, the body parse error, or the + * aimux error a response handler returned. + */ + cause?: ErrorCause | null /** * Parsed provider error data. */ @@ -467,6 +491,12 @@ export interface ApiCallError { * AI SDK's `APICallError` contract. */ export interface ApiCallErrorInput { + /** + * The error this failure was built from (`APICallError.cause`): the + * transport error with its source chain, the body parse error, or the + * aimux error a response handler returned. + */ + cause?: ErrorCauseInput | null /** * Parsed provider error data. */ @@ -1404,6 +1434,24 @@ export interface EmbeddingUsage { export interface EmptyParams { } +/** + * The error another error was built from: the AI SDK's `cause`. + * + * An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an + * `APICallError`; any other error is a [`SourceError`]. The JSON form is the + * nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. + */ +export type ErrorCause = AiMuxError | SourceError + +/** + * The error another error was built from: the AI SDK's `cause`. + * + * An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an + * `APICallError`; any other error is a [`SourceError`]. The JSON form is the + * nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. + */ +export type ErrorCauseInput = AiMuxErrorInput | SourceError + export type ErrorDomain = "aimux" | "recording" | "binding" | "protocol" /** @@ -3698,6 +3746,24 @@ export type Source = title: string } +/** + * An error from outside aimux (a transport, parser or I/O error), kept as + * its message and the messages of its own sources, so it can be cloned, + * serialized and sent across the bindings with the error built from it. + * + * The JSON form is a JavaScript `Error`'s own `{ message, cause }`. + */ +export interface SourceError { + /** + * The error's own `source()`, recursively. + */ + cause?: SourceError | null + /** + * The error's `Display` text. + */ + message: string +} + /** * Options passed to [`SpeechModel::do_generate`]. * diff --git a/bindings/python/python/aimux/_types.py b/bindings/python/python/aimux/_types.py index 549ba2144..f4ec76412 100644 --- a/bindings/python/python/aimux/_types.py +++ b/bindings/python/python/aimux/_types.py @@ -80,6 +80,10 @@ def _omit_absent(self, handler, info): class AiMuxErrorAIAPICallError(_Wire): name: Literal["AI_APICallError"] = Field(default="AI_APICallError", alias="name") + #: The error this failure was built from (`APICallError.cause`): the + #: transport error with its source chain, the body parse error, or the + #: aimux error a response handler returned. + cause: Optional["_dto.ErrorCause"] = Field(default=None, alias="cause") #: Parsed provider error data. data: Optional[Any] = Field(default=None, alias="data") #: Whether retrying can help (`APICallError.isRetryable`) — stored at @@ -250,10 +254,17 @@ class AiMuxErrorAIError(_Wire): #: Variants are cut by *what the caller does about it*, not by where the #: failure came from. `ApiCallError` is boxed only to keep the Rust enum #: compact; serde and every binding still observe the same object shape. +#: +#: `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")]) class AiMuxErrorInputAIAPICallError(_Wire): name: Literal["AI_APICallError"] = Field(default="AI_APICallError", alias="name") + #: The error this failure was built from (`APICallError.cause`): the + #: transport error with its source chain, the body parse error, or the + #: aimux error a response handler returned. + cause: Optional["_dto.ErrorCauseInput"] = Field(default=None, alias="cause") #: Parsed provider error data. data: Optional[Any] = Field(default=None, alias="data") #: Whether retrying can help (`APICallError.isRetryable`) — stored at @@ -426,6 +437,9 @@ class AiMuxErrorInputAIError(_Wire): #: Variants are cut by *what the caller does about it*, not by where the #: failure came from. `ApiCallError` is boxed only to keep the Rust enum #: compact; serde and every binding still observe the same object shape. +#: +#: `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")]) #: What a failed API call observed — the field set of the AI SDK's @@ -449,6 +463,10 @@ class AiMuxErrorInputAIError(_Wire): #: This keeps transport and response failures self-contained and matches the #: AI SDK's `APICallError` contract. class ApiCallError(_Wire): + #: The error this failure was built from (`APICallError.cause`): the + #: transport error with its source chain, the body parse error, or the + #: aimux error a response handler returned. + cause: Optional["_dto.ErrorCause"] = Field(default=None, alias="cause") #: Parsed provider error data. data: Optional[Any] = Field(default=None, alias="data") #: Whether retrying can help (`APICallError.isRetryable`) — stored at @@ -505,6 +523,10 @@ class ApiCallError(_Wire): #: This keeps transport and response failures self-contained and matches the #: AI SDK's `APICallError` contract. class ApiCallErrorInput(_Wire): + #: The error this failure was built from (`APICallError.cause`): the + #: transport error with its source chain, the body parse error, or the + #: aimux error a response handler returned. + cause: Optional["_dto.ErrorCauseInput"] = Field(default=None, alias="cause") #: Parsed provider error data. data: Optional[Any] = Field(default=None, alias="data") #: Whether retrying can help (`APICallError.isRetryable`) — stored at @@ -1304,6 +1326,20 @@ class EmptyParams(_Wire): model_config = ConfigDict(extra="forbid") +#: The error another error was built from: the AI SDK's `cause`. +#: +#: An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an +#: `APICallError`; any other error is a [`SourceError`]. The JSON form is the +#: nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. +ErrorCause = TypeAliasType("ErrorCause", Annotated[Union["_dto.AiMuxError", "_dto.SourceError"], Field(union_mode="left_to_right")]) + +#: The error another error was built from: the AI SDK's `cause`. +#: +#: An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an +#: `APICallError`; any other error is a [`SourceError`]. The JSON form is the +#: nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. +ErrorCauseInput = TypeAliasType("ErrorCauseInput", Annotated[Union["_dto.AiMuxErrorInput", "_dto.SourceError"], Field(union_mode="left_to_right")]) + ErrorDomain = TypeAliasType("ErrorDomain", Literal["aimux", "recording", "binding", "protocol"]) #: Keep the payload open so newer error variants, provider data, and explicit @@ -3089,6 +3125,18 @@ class SourceDocument(_Wire): #: element of the text results' `sources`. Source = TypeAliasType("Source", Annotated[Union["SourceUrl", "SourceDocument"], Field(discriminator="source_type")]) +#: An error from outside aimux (a transport, parser or I/O error), kept as +#: its message and the messages of its own sources, so it can be cloned, +#: serialized and sent across the bindings with the error built from it. +#: +#: The JSON form is a JavaScript `Error`'s own `{ message, cause }`. +class SourceError(_Wire): + #: The error's own `source()`, recursively. + cause: Optional["_dto.SourceError"] = Field(default=None, alias="cause") + #: The error's `Display` text. + message: str = Field(alias="message") + + #: Options passed to [`SpeechModel::do_generate`]. #: #: Aligned with V4 `SpeechModelV4CallOptions`. @@ -5153,6 +5201,7 @@ class WarningOther(_Wire): SessionView.model_rebuild(_types_namespace=globals()) SourceUrl.model_rebuild(_types_namespace=globals()) SourceDocument.model_rebuild(_types_namespace=globals()) +SourceError.model_rebuild(_types_namespace=globals()) SpeechCallOptions.model_rebuild(_types_namespace=globals()) SpeechRequest.model_rebuild(_types_namespace=globals()) SpeechResponse.model_rebuild(_types_namespace=globals()) @@ -5358,6 +5407,8 @@ class WarningOther(_Wire): "EmbeddingResult": EmbeddingResult, "EmbeddingUsage": EmbeddingUsage, "EmptyParams": EmptyParams, + "ErrorCause": ErrorCause, + "ErrorCauseInput": ErrorCauseInput, "ErrorDomain": ErrorDomain, "ErrorEnvelope": ErrorEnvelope, "Fallback": Fallback, @@ -5508,6 +5559,7 @@ class WarningOther(_Wire): "SessionView": SessionView, "Size": Size, "Source": Source, + "SourceError": SourceError, "SpeechCallOptions": SpeechCallOptions, "SpeechRequest": SpeechRequest, "SpeechResponse": SpeechResponse, @@ -5749,6 +5801,8 @@ class WarningOther(_Wire): "EmbeddingResult", "EmbeddingUsage", "EmptyParams", + "ErrorCause", + "ErrorCauseInput", "ErrorDomain", "ErrorEnvelope", "Fallback", @@ -5953,6 +6007,7 @@ class WarningOther(_Wire): "Source", "SourceUrl", "SourceDocument", + "SourceError", "SpeechCallOptions", "SpeechRequest", "SpeechResponse", diff --git a/bindings/python/python/aimux/ops.py b/bindings/python/python/aimux/ops.py index 2b7b281d2..ee0dd9677 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: e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173 +# Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 ABI_VERSION = 65536 OPS_VERSION = "1.0" -MANIFEST_DIGEST = "e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173" +MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" 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 3a5b13273..ed4483e51 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: e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173 +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95 public enum AimuxOps { public static let ABI_VERSION: UInt32 = 65536 public static let OPS_VERSION = "1.0" - public static let MANIFEST_DIGEST = "e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173" + public static let MANIFEST_DIGEST = "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" 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/DtoVectorTypes.swift b/bindings/swift/generated/DtoVectorTypes.swift index ba82ce331..67f071198 100644 --- a/bindings/swift/generated/DtoVectorTypes.swift +++ b/bindings/swift/generated/DtoVectorTypes.swift @@ -56,6 +56,8 @@ enum DtoVectorTypes { case "EmbeddingResult": return try encoder.encode(decoder.decode(EmbeddingResult.self, from: data)) case "EmbeddingUsage": return try encoder.encode(decoder.decode(EmbeddingUsage.self, from: data)) case "EmptyParams": return try encoder.encode(decoder.decode(EmptyParams.self, from: data)) + case "ErrorCause": return try encoder.encode(decoder.decode(ErrorCause.self, from: data)) + case "ErrorCauseInput": return try encoder.encode(decoder.decode(ErrorCauseInput.self, from: data)) case "ErrorDomain": return try encoder.encode(decoder.decode(ErrorDomain.self, from: data)) case "ErrorEnvelope": return try encoder.encode(decoder.decode(ErrorEnvelope.self, from: data)) case "Fallback": return try encoder.encode(decoder.decode(Fallback.self, from: data)) @@ -206,6 +208,7 @@ enum DtoVectorTypes { case "SessionView": return try encoder.encode(decoder.decode(SessionView.self, from: data)) case "Size": return try encoder.encode(decoder.decode(Size.self, from: data)) case "Source": return try encoder.encode(decoder.decode(Source.self, from: data)) + case "SourceError": return try encoder.encode(decoder.decode(SourceError.self, from: data)) case "SpeechCallOptions": return try encoder.encode(decoder.decode(SpeechCallOptions.self, from: data)) case "SpeechRequest": return try encoder.encode(decoder.decode(SpeechRequest.self, from: data)) case "SpeechResponse": return try encoder.encode(decoder.decode(SpeechResponse.self, from: data)) diff --git a/bindings/swift/generated/Types.swift b/bindings/swift/generated/Types.swift index 29b905e6d..a92fc54a5 100644 --- a/bindings/swift/generated/Types.swift +++ b/bindings/swift/generated/Types.swift @@ -127,6 +127,17 @@ public struct WireUInt64: Codable, Equatable, Sendable { try c.encode(rawValue) } } +/// The boxed storage of a struct's optional field of its own type. +indirect enum WireIndirect { + case boxed(Value) + var value: Value { + switch self { + case .boxed(let value): return value + } + } +} +extension WireIndirect: Equatable where Value: Equatable {} +extension WireIndirect: Sendable where Value: Sendable {} private struct WireKey: CodingKey { let stringValue: String let intValue: Int? = nil @@ -139,7 +150,13 @@ private struct WireKey: CodingKey { /// Variants are cut by *what the caller does about it*, not by where the /// failure came from. `ApiCallError` is boxed only to keep the Rust enum /// compact; serde and every binding still observe the same object shape. +/// +/// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a +/// retry's last attempt and a repair's `cause`. 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 + /// aimux error a response handler returned. /// data: Parsed provider error data. /// isRetryable: Whether retrying can help (`APICallError.isRetryable`) — stored at /// construction, exactly like the AI SDK: the response path computes it @@ -164,7 +181,7 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { /// Anthropic from its error type); errors built without an HTTP exchange /// leave it `None`. /// url: Sanitized request URL. Required for every API-derived failure. - case aiapiCallError(data: FieldPresence, isRetryable: Bool, message: String, providerCode: String?, requestBodyValues: JSONValue, responseBody: String?, responseHeaders: [String: String]?, statusCode: UInt16?, url: String) + 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) @@ -232,7 +249,7 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { let tag = try c.decode(String.self, forKey: .name) switch tag { case "AI_APICallError": - self = .aiapiCallError(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)) + 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)) @@ -303,17 +320,18 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { public func encode(to encoder: any Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) switch self { - case .aiapiCallError(let v0, let v1, let v2, let v3, let v4, let v5, let v6, let v7, let v8): + case .aiapiCallError(let v0, let v1, let v2, let v3, let v4, let v5, let v6, let v7, let v8, let v9): try c.encode("AI_APICallError", forKey: .name) - try c.encodePresence(v0, forKey: .data) - try c.encode(v1, forKey: .isRetryable) - try c.encode(v2, forKey: .message) - try c.encodeIfPresent(v3, forKey: .providerCode) - try c.encode(v4, forKey: .requestBodyValues) - try c.encodeIfPresent(v5, forKey: .responseBody) - try c.encodeIfPresent(v6, forKey: .responseHeaders) - try c.encodeIfPresent(v7, forKey: .statusCode) - try c.encode(v8, forKey: .url) + try c.encodeIfPresent(v0, forKey: .cause) + try c.encodePresence(v1, forKey: .data) + try c.encode(v2, forKey: .isRetryable) + try c.encode(v3, forKey: .message) + try c.encodeIfPresent(v4, forKey: .providerCode) + try c.encode(v5, forKey: .requestBodyValues) + try c.encodeIfPresent(v6, forKey: .responseBody) + try c.encodeIfPresent(v7, forKey: .responseHeaders) + try c.encodeIfPresent(v8, forKey: .statusCode) + try c.encode(v9, forKey: .url) case .aiRetryError(let v0, let v1): try c.encode("AI_RetryError", forKey: .name) try c.encode(v0, forKey: .errors) @@ -403,7 +421,13 @@ public indirect enum AiMuxError: Codable, Equatable, Sendable { /// Variants are cut by *what the caller does about it*, not by where the /// failure came from. `ApiCallError` is boxed only to keep the Rust enum /// compact; serde and every binding still observe the same object shape. +/// +/// `source()` follows the AI SDK's `cause`: an API failure's `cause`, a +/// retry's last attempt and a repair's `cause`. 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 + /// aimux error a response handler returned. /// data: Parsed provider error data. /// isRetryable: Whether retrying can help (`APICallError.isRetryable`) — stored at /// construction, exactly like the AI SDK: the response path computes it @@ -428,7 +452,7 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { /// Anthropic from its error type); errors built without an HTTP exchange /// leave it `None`. /// url: Sanitized request URL. Required for every API-derived failure. - case aiapiCallError(data: FieldPresence, isRetryable: Bool?, message: String, providerCode: String?, requestBodyValues: JSONValue, responseBody: String?, responseHeaders: [String: String]?, statusCode: UInt16?, url: String) + 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) @@ -496,7 +520,7 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { let tag = try c.decode(String.self, forKey: .name) switch tag { case "AI_APICallError": - self = .aiapiCallError(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)) + 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)) @@ -567,17 +591,18 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { public func encode(to encoder: any Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) switch self { - case .aiapiCallError(let v0, let v1, let v2, let v3, let v4, let v5, let v6, let v7, let v8): + case .aiapiCallError(let v0, let v1, let v2, let v3, let v4, let v5, let v6, let v7, let v8, let v9): try c.encode("AI_APICallError", forKey: .name) - try c.encodePresence(v0, forKey: .data) - try c.encodeIfPresent(v1, forKey: .isRetryable) - try c.encode(v2, forKey: .message) - try c.encodeIfPresent(v3, forKey: .providerCode) - try c.encode(v4, forKey: .requestBodyValues) - try c.encodeIfPresent(v5, forKey: .responseBody) - try c.encodeIfPresent(v6, forKey: .responseHeaders) - try c.encodeIfPresent(v7, forKey: .statusCode) - try c.encode(v8, forKey: .url) + try c.encodeIfPresent(v0, forKey: .cause) + try c.encodePresence(v1, forKey: .data) + try c.encodeIfPresent(v2, forKey: .isRetryable) + try c.encode(v3, forKey: .message) + try c.encodeIfPresent(v4, forKey: .providerCode) + try c.encode(v5, forKey: .requestBodyValues) + try c.encodeIfPresent(v6, forKey: .responseBody) + try c.encodeIfPresent(v7, forKey: .responseHeaders) + try c.encodeIfPresent(v8, forKey: .statusCode) + try c.encode(v9, forKey: .url) case .aiRetryError(let v0, let v1): try c.encode("AI_RetryError", forKey: .name) try c.encode(v0, forKey: .errors) @@ -683,6 +708,10 @@ public indirect enum AiMuxErrorInput: Codable, Equatable, Sendable { /// This keeps transport and response failures self-contained and matches the /// AI SDK's `APICallError` contract. public struct ApiCallError: Codable, Equatable, Sendable { + /// The error this failure was built from (`APICallError.cause`): the + /// transport error with its source chain, the body parse error, or the + /// aimux error a response handler returned. + public var cause: ErrorCause? /// Parsed provider error data. public var data: FieldPresence /// Whether retrying can help (`APICallError.isRetryable`) — stored at @@ -717,7 +746,8 @@ public struct ApiCallError: Codable, Equatable, Sendable { /// Sanitized request URL. Required for every API-derived failure. public var url: String - public init(data: FieldPresence = .missing, isRetryable: Bool, message: String, providerCode: String? = nil, requestBodyValues: JSONValue, responseBody: String? = nil, responseHeaders: [String: String]? = nil, statusCode: UInt16? = nil, url: String) { + public init(cause: ErrorCause? = nil, data: FieldPresence = .missing, isRetryable: Bool, message: String, providerCode: String? = nil, requestBodyValues: JSONValue, responseBody: String? = nil, responseHeaders: [String: String]? = nil, statusCode: UInt16? = nil, url: String) { + self.cause = cause self.data = data self.isRetryable = isRetryable self.message = message @@ -729,6 +759,7 @@ public struct ApiCallError: Codable, Equatable, Sendable { self.url = url } private enum CodingKeys: String, CodingKey { + case cause = "cause" case data = "data" case isRetryable = "isRetryable" case message = "message" @@ -741,6 +772,7 @@ public struct ApiCallError: Codable, Equatable, Sendable { } public init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) + self.cause = try c.decodeIfPresent(ErrorCause.self, forKey: .cause) self.data = try c.decodePresence(JSONValue.self, forKey: .data) self.isRetryable = try c.decode(Bool.self, forKey: .isRetryable) self.message = try c.decode(String.self, forKey: .message) @@ -753,6 +785,7 @@ public struct ApiCallError: Codable, Equatable, Sendable { } public func encode(to encoder: any Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) + try c.encodeIfPresent(self.cause, forKey: .cause) try c.encodePresence(self.data, forKey: .data) try c.encode(self.isRetryable, forKey: .isRetryable) try c.encode(self.message, forKey: .message) @@ -786,6 +819,10 @@ public struct ApiCallError: Codable, Equatable, Sendable { /// This keeps transport and response failures self-contained and matches the /// AI SDK's `APICallError` contract. public struct ApiCallErrorInput: Codable, Equatable, Sendable { + /// The error this failure was built from (`APICallError.cause`): the + /// transport error with its source chain, the body parse error, or the + /// aimux error a response handler returned. + public var cause: ErrorCauseInput? /// Parsed provider error data. public var data: FieldPresence /// Whether retrying can help (`APICallError.isRetryable`) — stored at @@ -820,7 +857,8 @@ public struct ApiCallErrorInput: Codable, Equatable, Sendable { /// Sanitized request URL. Required for every API-derived failure. public var url: String - public init(data: FieldPresence = .missing, isRetryable: Bool? = nil, message: String, providerCode: String? = nil, requestBodyValues: JSONValue, responseBody: String? = nil, responseHeaders: [String: String]? = nil, statusCode: UInt16? = nil, url: String) { + public init(cause: ErrorCauseInput? = nil, data: FieldPresence = .missing, isRetryable: Bool? = nil, message: String, providerCode: String? = nil, requestBodyValues: JSONValue, responseBody: String? = nil, responseHeaders: [String: String]? = nil, statusCode: UInt16? = nil, url: String) { + self.cause = cause self.data = data self.isRetryable = isRetryable self.message = message @@ -832,6 +870,7 @@ public struct ApiCallErrorInput: Codable, Equatable, Sendable { self.url = url } private enum CodingKeys: String, CodingKey { + case cause = "cause" case data = "data" case isRetryable = "isRetryable" case message = "message" @@ -844,6 +883,7 @@ public struct ApiCallErrorInput: Codable, Equatable, Sendable { } public init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) + self.cause = try c.decodeIfPresent(ErrorCauseInput.self, forKey: .cause) self.data = try c.decodePresence(JSONValue.self, forKey: .data) self.isRetryable = try c.decodeIfPresent(Bool.self, forKey: .isRetryable) self.message = try c.decode(String.self, forKey: .message) @@ -856,6 +896,7 @@ public struct ApiCallErrorInput: Codable, Equatable, Sendable { } public func encode(to encoder: any Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) + try c.encodeIfPresent(self.cause, forKey: .cause) try c.encodePresence(self.data, forKey: .data) try c.encodeIfPresent(self.isRetryable, forKey: .isRetryable) try c.encode(self.message, forKey: .message) @@ -2289,6 +2330,50 @@ public struct EmptyParams: Codable, Equatable, Sendable { } } +/// The error another error was built from: the AI SDK's `cause`. +/// +/// An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an +/// `APICallError`; any other error is a [`SourceError`]. The JSON form is the +/// nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. +public indirect enum ErrorCause: Codable, Equatable, Sendable { + case aiMuxError(AiMuxError) + case sourceError(SourceError) + + public init(from decoder: any Decoder) throws { + do { let value = try decoder.singleValueContainer().decode(AiMuxError.self); self = .aiMuxError(value); return } catch {} + do { let value = try decoder.singleValueContainer().decode(SourceError.self); self = .sourceError(value); return } catch {} + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "No matching ErrorCause branch")) + } + public func encode(to encoder: any Encoder) throws { + switch self { + case .aiMuxError(let value): try value.encode(to: encoder) + case .sourceError(let value): try value.encode(to: encoder) + } + } +} + +/// The error another error was built from: the AI SDK's `cause`. +/// +/// An aimux error stays whole, as the AI SDK nests a `JSONParseError` in an +/// `APICallError`; any other error is a [`SourceError`]. The JSON form is the +/// nested error itself: a `name`-tagged aimux error, or `{ message, cause }`. +public indirect enum ErrorCauseInput: Codable, Equatable, Sendable { + case aiMuxErrorInput(AiMuxErrorInput) + case sourceError(SourceError) + + public init(from decoder: any Decoder) throws { + do { let value = try decoder.singleValueContainer().decode(AiMuxErrorInput.self); self = .aiMuxErrorInput(value); return } catch {} + do { let value = try decoder.singleValueContainer().decode(SourceError.self); self = .sourceError(value); return } catch {} + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "No matching ErrorCauseInput branch")) + } + public func encode(to encoder: any Encoder) throws { + switch self { + case .aiMuxErrorInput(let value): try value.encode(to: encoder) + case .sourceError(let value): try value.encode(to: encoder) + } + } +} + public enum ErrorDomain: String, Codable, Equatable, Sendable { case aimux = "aimux" case recording = "recording" @@ -5587,6 +5672,41 @@ public indirect enum Source: Codable, Equatable, Sendable { } } +/// An error from outside aimux (a transport, parser or I/O error), kept as +/// its message and the messages of its own sources, so it can be cloned, +/// serialized and sent across the bindings with the error built from it. +/// +/// The JSON form is a JavaScript `Error`'s own `{ message, cause }`. +public struct SourceError: Codable, Equatable, Sendable { + /// The error's own `source()`, recursively. + public var cause: SourceError? { + get { _cause?.value } + set { _cause = newValue.map(WireIndirect.boxed) } + } + private var _cause: WireIndirect? + /// The error's `Display` text. + public var message: String + + public init(cause: SourceError? = nil, message: String) { + self._cause = cause.map(WireIndirect.boxed) + self.message = message + } + private enum CodingKeys: String, CodingKey { + case cause = "cause" + case message = "message" + } + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self._cause = try c.decodeIfPresent(SourceError.self, forKey: .cause).map(WireIndirect.boxed) + self.message = try c.decode(String.self, forKey: .message) + } + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encodeIfPresent(self.cause, forKey: .cause) + try c.encode(self.message, forKey: .message) + } +} + /// Options passed to [`SpeechModel::do_generate`]. /// /// Aligned with V4 `SpeechModelV4CallOptions`. diff --git a/contract-tests/dto/AiMuxError/004.json b/contract-tests/dto/AiMuxError/004.json new file mode 100644 index 000000000..527ca2e6d --- /dev/null +++ b/contract-tests/dto/AiMuxError/004.json @@ -0,0 +1,25 @@ +{ + "name": "AI_APICallError", + "url": "https://example.test/v1", + "requestBodyValues": { + "input": null + }, + "statusCode": 429, + "providerCode": "rate_limit", + "message": "slow down", + "responseBody": "{\"error\":null}", + "responseHeaders": { + "retry-after-ms": "1500" + }, + "data": null, + "isRetryable": true, + "cause": { + "message": "error sending request", + "cause": { + "message": "tcp connect error", + "cause": { + "message": "Connection refused (os error 111)" + } + } + } +} diff --git a/contract-tests/dto/AiMuxError/005.json b/contract-tests/dto/AiMuxError/005.json new file mode 100644 index 000000000..7d2e7b0ec --- /dev/null +++ b/contract-tests/dto/AiMuxError/005.json @@ -0,0 +1,15 @@ +{ + "name": "AI_APICallError", + "url": "https://example.test/v1", + "requestBodyValues": {}, + "statusCode": 200, + "message": "Failed to process successful response: JSON parse error: Unexpected end of JSON input", + "responseHeaders": { + "content-type": "application/json" + }, + "isRetryable": false, + "cause": { + "name": "AI_JSONParseError", + "message": "Unexpected end of JSON input" + } +} diff --git a/contract-tests/dto/AiMuxErrorInput/004.json b/contract-tests/dto/AiMuxErrorInput/004.json new file mode 100644 index 000000000..527ca2e6d --- /dev/null +++ b/contract-tests/dto/AiMuxErrorInput/004.json @@ -0,0 +1,25 @@ +{ + "name": "AI_APICallError", + "url": "https://example.test/v1", + "requestBodyValues": { + "input": null + }, + "statusCode": 429, + "providerCode": "rate_limit", + "message": "slow down", + "responseBody": "{\"error\":null}", + "responseHeaders": { + "retry-after-ms": "1500" + }, + "data": null, + "isRetryable": true, + "cause": { + "message": "error sending request", + "cause": { + "message": "tcp connect error", + "cause": { + "message": "Connection refused (os error 111)" + } + } + } +} diff --git a/contract-tests/dto/AiMuxErrorInput/005.json b/contract-tests/dto/AiMuxErrorInput/005.json new file mode 100644 index 000000000..7d2e7b0ec --- /dev/null +++ b/contract-tests/dto/AiMuxErrorInput/005.json @@ -0,0 +1,15 @@ +{ + "name": "AI_APICallError", + "url": "https://example.test/v1", + "requestBodyValues": {}, + "statusCode": 200, + "message": "Failed to process successful response: JSON parse error: Unexpected end of JSON input", + "responseHeaders": { + "content-type": "application/json" + }, + "isRetryable": false, + "cause": { + "name": "AI_JSONParseError", + "message": "Unexpected end of JSON input" + } +} diff --git a/contract-tests/dto/index.json b/contract-tests/dto/index.json index b9d86dd9a..ea6aa206f 100644 --- a/contract-tests/dto/index.json +++ b/contract-tests/dto/index.json @@ -11,6 +11,14 @@ "type": "AiMuxError", "file": "AiMuxError/003.json" }, + { + "type": "AiMuxError", + "file": "AiMuxError/004.json" + }, + { + "type": "AiMuxError", + "file": "AiMuxError/005.json" + }, { "type": "AiMuxErrorInput", "file": "AiMuxErrorInput/001.json" @@ -23,6 +31,14 @@ "type": "AiMuxErrorInput", "file": "AiMuxErrorInput/003.json" }, + { + "type": "AiMuxErrorInput", + "file": "AiMuxErrorInput/004.json" + }, + { + "type": "AiMuxErrorInput", + "file": "AiMuxErrorInput/005.json" + }, { "type": "AssistantContent", "file": "AssistantContent/001.json" diff --git a/contract-tests/dto/sources.json b/contract-tests/dto/sources.json index 94aac7358..80a7ba23c 100644 --- a/contract-tests/dto/sources.json +++ b/contract-tests/dto/sources.json @@ -14,6 +14,16 @@ "source": "contract-tests/ops/scenarios/recoverable-error-part.json", "pointer": "/script/0/event/error" }, + { + "file": "AiMuxError/004.json", + "source": "contract-tests/ops/errors/aimux-api-call.json", + "pointer": "/error" + }, + { + "file": "AiMuxError/005.json", + "source": "aimux-provider-utils/src/post_to_api.rs", + "pointer": "line 265" + }, { "file": "AiMuxErrorInput/001.json", "source": "bindings/flutter/test/typed_round_trip_test.dart", @@ -29,6 +39,16 @@ "source": "contract-tests/ops/scenarios/recoverable-error-part.json", "pointer": "/script/0/event/error" }, + { + "file": "AiMuxErrorInput/004.json", + "source": "contract-tests/ops/errors/aimux-api-call.json", + "pointer": "/error" + }, + { + "file": "AiMuxErrorInput/005.json", + "source": "aimux-provider-utils/src/post_to_api.rs", + "pointer": "line 265" + }, { "file": "AnyValue/001.json", "source": "bindings/flutter/test/typed_round_trip_test.dart", diff --git a/contract-tests/ops/errors/aimux-api-call.json b/contract-tests/ops/errors/aimux-api-call.json index 51ab4ee4d..b480a41e9 100644 --- a/contract-tests/ops/errors/aimux-api-call.json +++ b/contract-tests/ops/errors/aimux-api-call.json @@ -16,7 +16,16 @@ "retry-after-ms": "1500" }, "data": null, - "isRetryable": true + "isRetryable": true, + "cause": { + "message": "error sending request", + "cause": { + "message": "tcp connect error", + "cause": { + "message": "Connection refused (os error 111)" + } + } + } }, "retryAfterMs": 1500 } diff --git a/schema/types/AiMuxError.json b/schema/types/AiMuxError.json index 0fc8469e7..60bb36cf0 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.", + "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`.", "oneOf": [ { "$ref": "ApiCallError.json", diff --git a/schema/types/AiMuxErrorInput.json b/schema/types/AiMuxErrorInput.json index 7bb7a9eb5..15f53a62e 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.", + "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`.", "oneOf": [ { "$ref": "ApiCallErrorInput.json", diff --git a/schema/types/ApiCallError.json b/schema/types/ApiCallError.json index 9d4edab4c..08f225139 100644 --- a/schema/types/ApiCallError.json +++ b/schema/types/ApiCallError.json @@ -4,6 +4,17 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "What a failed API call observed — the field set of the AI SDK's\n`APICallError` (`statusCode` / `responseBody`), which this project's error\ntaxonomy mirrors (docs/internal/vercel-research/07-kernel-infrastructure.md).\n\nCarried by [`AiMuxError::ApiCall`] alone. There are no per-status\nvariants: a 401, 404 or 429 is an `ApiCall` error whose classification is\nread from `status_code`, exactly as the AI SDK reads\n`APICallError.statusCode`. Transport failures (connection reset, DNS,\nbody read) are `ApiCall` errors too — no response arrived, so\n`status_code` is `None` and `is_retryable` is `true`, exactly as the AI\nSDK's `handleFetchError` builds an `APICallError` with no `statusCode`\nand `isRetryable: true`. `message` holds the provider's text verbatim when\none is available; transport and parsing failures use a library message\nwith the source detail appended. The HTTP status is always a field, never\nbaked into the string; `Display` composes the human-readable form from the\nfields at print time, so nothing downstream has to parse it back out.\n\nEvery producer must provide the sanitized request URL and request values.\nThis keeps transport and response failures self-contained and matches the\nAI SDK's `APICallError` contract.", "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCause.json" + }, + { + "type": "null" + } + ], + "description": "The error this failure was built from (`APICallError.cause`): the\ntransport error with its source chain, the body parse error, or the\naimux error a response handler returned." + }, "data": { "description": "Parsed provider error data.", "x-aimux-codec": "presence" diff --git a/schema/types/ApiCallErrorInput.json b/schema/types/ApiCallErrorInput.json index 617c23a3e..3f3e649fd 100644 --- a/schema/types/ApiCallErrorInput.json +++ b/schema/types/ApiCallErrorInput.json @@ -4,6 +4,17 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "What a failed API call observed — the field set of the AI SDK's\n`APICallError` (`statusCode` / `responseBody`), which this project's error\ntaxonomy mirrors (docs/internal/vercel-research/07-kernel-infrastructure.md).\n\nCarried by [`AiMuxError::ApiCall`] alone. There are no per-status\nvariants: a 401, 404 or 429 is an `ApiCall` error whose classification is\nread from `status_code`, exactly as the AI SDK reads\n`APICallError.statusCode`. Transport failures (connection reset, DNS,\nbody read) are `ApiCall` errors too — no response arrived, so\n`status_code` is `None` and `is_retryable` is `true`, exactly as the AI\nSDK's `handleFetchError` builds an `APICallError` with no `statusCode`\nand `isRetryable: true`. `message` holds the provider's text verbatim when\none is available; transport and parsing failures use a library message\nwith the source detail appended. The HTTP status is always a field, never\nbaked into the string; `Display` composes the human-readable form from the\nfields at print time, so nothing downstream has to parse it back out.\n\nEvery producer must provide the sanitized request URL and request values.\nThis keeps transport and response failures self-contained and matches the\nAI SDK's `APICallError` contract.", "properties": { + "cause": { + "anyOf": [ + { + "$ref": "ErrorCauseInput.json" + }, + { + "type": "null" + } + ], + "description": "The error this failure was built from (`APICallError.cause`): the\ntransport error with its source chain, the body parse error, or the\naimux error a response handler returned." + }, "data": { "description": "Parsed provider error data.", "x-aimux-codec": "presence" diff --git a/schema/types/ErrorCause.json b/schema/types/ErrorCause.json new file mode 100644 index 000000000..7653343b6 --- /dev/null +++ b/schema/types/ErrorCause.json @@ -0,0 +1,15 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/types/ErrorCause.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "$ref": "AiMuxError.json" + }, + { + "$ref": "SourceError.json" + } + ], + "description": "The error another error was built from: the AI SDK's `cause`.\n\nAn aimux error stays whole, as the AI SDK nests a `JSONParseError` in an\n`APICallError`; any other error is a [`SourceError`]. The JSON form is the\nnested error itself: a `name`-tagged aimux error, or `{ message, cause }`.", + "x-aimux-codec": "ordered-untagged" +} diff --git a/schema/types/ErrorCauseInput.json b/schema/types/ErrorCauseInput.json new file mode 100644 index 000000000..1ba6635e2 --- /dev/null +++ b/schema/types/ErrorCauseInput.json @@ -0,0 +1,15 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/types/ErrorCauseInput.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "$ref": "AiMuxErrorInput.json" + }, + { + "$ref": "SourceError.json" + } + ], + "description": "The error another error was built from: the AI SDK's `cause`.\n\nAn aimux error stays whole, as the AI SDK nests a `JSONParseError` in an\n`APICallError`; any other error is a [`SourceError`]. The JSON form is the\nnested error itself: a `name`-tagged aimux error, or `{ message, cause }`.", + "x-aimux-codec": "ordered-untagged" +} diff --git a/schema/types/SourceError.json b/schema/types/SourceError.json new file mode 100644 index 000000000..322158cb9 --- /dev/null +++ b/schema/types/SourceError.json @@ -0,0 +1,27 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/types/SourceError.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "An error from outside aimux (a transport, parser or I/O error), kept as\nits message and the messages of its own sources, so it can be cloned,\nserialized and sent across the bindings with the error built from it.\n\nThe JSON form is a JavaScript `Error`'s own `{ message, cause }`.", + "properties": { + "cause": { + "anyOf": [ + { + "$ref": "SourceError.json" + }, + { + "type": "null" + } + ], + "description": "The error's own `source()`, recursively." + }, + "message": { + "description": "The error's `Display` text.", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" +} diff --git a/tools/aimux-codegen/generated-files.json b/tools/aimux-codegen/generated-files.json index bce252d71..e724b2caf 100644 --- a/tools/aimux-codegen/generated-files.json +++ b/tools/aimux-codegen/generated-files.json @@ -60,6 +60,8 @@ "bindings/java/generated/ai/arcships/aimux/types/EmbeddingResult.java", "bindings/java/generated/ai/arcships/aimux/types/EmbeddingUsage.java", "bindings/java/generated/ai/arcships/aimux/types/EmptyParams.java", + "bindings/java/generated/ai/arcships/aimux/types/ErrorCause.java", + "bindings/java/generated/ai/arcships/aimux/types/ErrorCauseInput.java", "bindings/java/generated/ai/arcships/aimux/types/ErrorDomain.java", "bindings/java/generated/ai/arcships/aimux/types/ErrorEnvelope.java", "bindings/java/generated/ai/arcships/aimux/types/Fallback.java", @@ -211,6 +213,7 @@ "bindings/java/generated/ai/arcships/aimux/types/SessionView.java", "bindings/java/generated/ai/arcships/aimux/types/Size.java", "bindings/java/generated/ai/arcships/aimux/types/Source.java", + "bindings/java/generated/ai/arcships/aimux/types/SourceError.java", "bindings/java/generated/ai/arcships/aimux/types/SpeechCallOptions.java", "bindings/java/generated/ai/arcships/aimux/types/SpeechRequest.java", "bindings/java/generated/ai/arcships/aimux/types/SpeechResponse.java", @@ -370,6 +373,8 @@ "schema/types/EmbeddingResult.json", "schema/types/EmbeddingUsage.json", "schema/types/EmptyParams.json", + "schema/types/ErrorCause.json", + "schema/types/ErrorCauseInput.json", "schema/types/ErrorDomain.json", "schema/types/ErrorEnvelope.json", "schema/types/Fallback.json", @@ -520,6 +525,7 @@ "schema/types/SessionView.json", "schema/types/Size.json", "schema/types/Source.json", + "schema/types/SourceError.json", "schema/types/SpeechCallOptions.json", "schema/types/SpeechRequest.json", "schema/types/SpeechResponse.json", @@ -626,5 +632,5 @@ "tools/aimux-web/web/src/types/generated/WireOptions.ts", "tools/aimux-web/web/src/types/generated/WireTool.ts" ], - "manifestDigest": "e4592032b90bb079b2ba41f5325efa8430b63d8200d865d5e068706b68420173" + "manifestDigest": "24415af1232df332a79165ac3a4d9264deaf3e500fe1f4a7690c544ea15abc95" } From bec58285163cd3d25809c01fdd9d4a642ba38acb Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:20 +0000 Subject: [PATCH 06/14] docs: record ApiCallError.cause and the URL-less transport messages CHANGELOG Breaking, the request pipeline's cause row, and 228 DTO vectors in the conformance doc. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C --- CHANGELOG.md | 10 ++++++++++ docs/ai-sdk-request-pipeline.md | 8 ++++---- docs/rfc-0039-conformance.md | 8 ++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 598fd1de3..098f39e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ 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 + (…)` is now `error sending request`. Other messages are unchanged. + - aimux's public JSON equals the AI SDK's JSON. Struct fields serialize camelCase (`toolCallId`, `providerOptions`, `inputTokens`); unions carry the AI SDK's tag and strings (`{"type":"text-delta"}`, `"tool-call"`, diff --git a/docs/ai-sdk-request-pipeline.md b/docs/ai-sdk-request-pipeline.md index 1ffdc3f64..33ace76e7 100644 --- a/docs/ai-sdk-request-pipeline.md +++ b/docs/ai-sdk-request-pipeline.md @@ -82,7 +82,7 @@ Core user operation | crate 依赖方向 | retry primitive 和 Core wrapper 都放进 `aimux-core::retry`,避免 `aimux-core` 反向依赖 `aimux-provider-utils`;函数边界和算法不变 | | Rust 错误类型 | AI SDK `RetryError.errors: unknown[]` 对应 `Vec`;这是同一错误历史的强类型表达 | | enum 尺寸 | `AiMuxError::ApiCall(Box)`;`Box` 只解决 Rust enum 尺寸,不改变序列化或 binding 语义 | -| `cause` | 本轮不加入;AI SDK 用 `cause` 保存的底层错误追加到 message(`{upstream message}: {source}`),或保存在 data/response body,不得静默丢失 | +| `cause` | `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 | | Gateway | 不增加 `GatewayError`;aimux 没有独立 AI Gateway 错误体系 | | 既有错误字段 | 保留 Aimux 的 `provider_code`;删除旧路径派生的 `request_id` / `retry_after_ms`,retry hint 的唯一事实源是 `response_headers` | | error context 安全 | AI SDK 传原始 request context;Aimux 写入 public error 前使用统一白名单/脱敏/大小限制,binary 与大型 data URL 只保留摘要 | @@ -225,8 +225,8 @@ non-2xx → failed_response_handler 2xx 解析失败由 successful handler 产生 `ApiCallError`,不得再调用 failed handler。 handler 返回的 ApiCall/Timeout/Aborted 原样透传;其他 handler failure 按 AI SDK 的 -`Failed to process successful/error response` 规则包装。由于本轮没有 `cause`,原始信息 -必须保存在 message、data 或受限长度的 response body 中。 +`Failed to process successful/error response` 规则包装,原错误作为 `cause` 保留,其文字 +同时留在 message 中。 标准 handler factory 直接移植 AI SDK `response-handler.ts` 的同名行为。Provider-specific 协议使用 custom `ResponseHandler`;例如 Bedrock binary event-stream 不是 SSE,不得套 @@ -1041,7 +1041,7 @@ handler 负责分帧和解析,peek 与"错误就 `Err` 返回"仍是 provider ## 14. 不做与拒绝方案 -本 RFC 不做:`cause`、`GatewayError`、mid-stream reconnect、stream 返回后的任何 retry、 +本 RFC 不做:`GatewayError`、mid-stream reconnect、stream 返回后的任何 retry、 circuit breaker、tool execution timeout、per-child Router retry policy。 拒绝: diff --git a/docs/rfc-0039-conformance.md b/docs/rfc-0039-conformance.md index 7b8813bcb..ad284062e 100644 --- a/docs/rfc-0039-conformance.md +++ b/docs/rfc-0039-conformance.md @@ -256,9 +256,9 @@ unless explicitly labelled as a file-level reference. | R116 | §7.2 | FFI bindings and raw C initialize by checking ABI then manifest, exact current/expected ops version and consumed schema revision/hash; reject mismatch with no implicit minor negotiation. | gen | gap — [G-h](rfc-0039-implementation.md#registered-gaps): ABI/ops loaders exist; consumed-business-type revision/hash checks are unavailable. | | R117 | §7.2 | Bundled Node/Python also check generated-product consistency and expose version/digest; co-packaging does not waive drift checks. | gen | met — Node and Python require the exact generated ABI version, ops version and manifest digest at load. | | 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 (297 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. | +| 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 297 business types and the control schemas. `contract-tests/dto` holds 226 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 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`. | | 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. | @@ -297,12 +297,12 @@ unless explicitly labelled as a file-level reference. | R157 | §9.8 | Keep native PyO3 wheel packaging without ctypes library discovery or dual wheel scheme. | python | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): The PyO3 packaging path remains; an installed-wheel build/install/offline smoke is not established here. | | R158 | §9.8 | Test GIL heartbeat for all blocking routes, full-queue iterator.close, KeyboardInterrupt/explicit cancel, GC leaks, cause/Retry history, installed-wheel offline smoke and all-modalities typed round trip. | python, pythonstream, pydto | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): Local GIL/queue tests exist and the generated Pydantic DTOs are exercised (`bindings/python/tests/test_dto_types.py`; the CI job "Python binding" installs a locally built wheel with pydantic on one Linux runner). Release-wheel installation per platform (G-e) and exhaustive modality/platform evidence (G-i) remain. | | R159 | §10.1 | Keep RFC-0042 canonical types/wire/errors, RFC-0040 descriptors/settings/auth slots and RFC-0041 recording/replay ownership; this RFC owns op/control/lifecycle/bridges. | gen, types | gap — [G-b/G-f](rfc-0039-implementation.md#registered-gaps): Layer ownership is retained: RFC-0042 canonical types are the Rust types themselves and flow unchanged into the manifest `typeDefs` and every binding's DTOs. Recording schema compatibility is G-b and the full RFC-0040 descriptor specification is G-f. | -| R160 | §10.1 | One manifest drives dispatch registration, header, language ops/types/factories, schema, reference docs and vectors; handwritten loaders/bridges are allowed, handwritten provider behavior/field tables/per-provider constructor lists are not. | gen, types | gap — [G-i](rfc-0039-implementation.md#registered-gaps): The manifest now generates the header, op constants, business and control schemas, and the DTO types and codecs of every binding, plus the vector type registries (`bindings/node/src/generated/dto_vectors.ts`, `bindings/flutter/lib/src/generated/dto_vectors.dart`, `bindings/swift/generated/DtoVectorTypes.swift`). Handwritten loaders and facades are allowed. Two parts remain: the vector corpus is fixture-derived and covers 101 of 297 types (G-i), and reference docs are not manifest-driven (`docs/api/providers.md` comes from `scripts/gen_providers_doc.py`) ([D19](rfc-0039-implementation.md#registered-differences-from-rfc-0039)). | +| R160 | §10.1 | One manifest drives dispatch registration, header, language ops/types/factories, schema, reference docs and vectors; handwritten loaders/bridges are allowed, handwritten provider behavior/field tables/per-provider constructor lists are not. | gen, types | gap — [G-i](rfc-0039-implementation.md#registered-gaps): The manifest now generates the header, op constants, business and control schemas, and the DTO types and codecs of every binding, plus the vector type registries (`bindings/node/src/generated/dto_vectors.ts`, `bindings/flutter/lib/src/generated/dto_vectors.dart`, `bindings/swift/generated/DtoVectorTypes.swift`). Handwritten loaders and facades are allowed. Two parts remain: the vector corpus is fixture-derived and covers 101 of 300 types (G-i), and reference docs are not manifest-driven (`docs/api/providers.md` comes from `scripts/gen_providers_doc.py`) ([D19](rfc-0039-implementation.md#registered-differences-from-rfc-0039)). | | R161 | §10.1 | Provider tool-call input remains V4 raw String while core tool input is parsed JSON; never reintroduce untagged ToolInput to merge them. | base64 | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Raw/parsed tool input separation remains in core; the cited byte test is not a complete tool-input conformance gate. | | 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 226 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 297 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 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. | | 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. | From aa52e8f4b2afc67a2feb5e81fedac469578f020d Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:20 +0000 Subject: [PATCH 07/14] feat(errors): give the six message-only variants a cause JsonParse, NoOutputGenerated, InvalidResponseData, InvalidArgument, InvalidPrompt and Other become { message, cause }, with constructors of the same names; From keeps the serde error as the cause. InvalidToolInput.cause is an ErrorCause instead of a string. The new TypeValidation { message, value, cause } is the AI SDK's TypeValidationError. It only appears as a cause and shares ops error code 3 with InvalidResponseData. Part 2 of RFC-0047. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C --- aimux-core/src/error.rs | 270 ++++++++++++++++---- aimux-core/tests/error_value_golden_test.rs | 61 +++-- 2 files changed, 259 insertions(+), 72 deletions(-) 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/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", From b6e5710e3e8271ada63ff69ba0b67d2c6bc1fc16 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:21 +0000 Subject: [PATCH 08/14] feat(core): keep the parse, validation and download error as the cause As in the AI SDK (parse-json.ts, validate-types.ts, parse-tool-call.ts), InvalidToolInput's cause is the JsonParse or TypeValidation error itself, and structured output that does not match its schema has a TypeValidation cause. Download transport errors drop the URL before they become the message and the cause: a signed URL carries its credentials in the query. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C --- aimux-core/src/decision_model.rs | 4 +- aimux-core/src/decision_replay.rs | 57 ++++--- aimux-core/src/download.rs | 58 +++++-- aimux-core/src/download_guard.rs | 32 ++-- aimux-core/src/generate.rs | 18 +- aimux-core/src/generate/pipeline_tests.rs | 6 +- aimux-core/src/generate/tests.rs | 12 +- aimux-core/src/language_model_message.rs | 29 ++-- aimux-core/src/moa.rs | 12 +- aimux-core/src/openai_output.rs | 9 +- aimux-core/src/output.rs | 197 ++++++++++++++++------ aimux-core/src/parse_tool_call.rs | 177 ++++++++++++++----- aimux-core/src/recording.rs | 11 +- aimux-core/src/replay.rs | 88 +++++----- aimux-core/src/retry.rs | 4 +- aimux-core/src/router.rs | 14 +- aimux-core/src/shared.rs | 2 +- aimux-core/src/timeout.rs | 6 +- aimux-core/src/transcription_model.rs | 6 +- aimux-core/src/types.rs | 4 +- aimux-core/src/video_model.rs | 6 +- aimux-core/tests/cache_probe_test.rs | 12 +- aimux-core/tests/decision_test.rs | 8 +- aimux-core/tests/generate_checks_test.rs | 7 +- aimux-core/tests/master_recording_test.rs | 6 +- aimux-core/tests/tool_input_test.rs | 4 +- 26 files changed, 516 insertions(+), 273 deletions(-) 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/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/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 { .. }) )); } From 3680456ef0e2bbaf1f4e7cbe8249907cd3bd6fb3 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:24 +0000 Subject: [PATCH 09/14] refactor(providers): keep serde, base64, url and io errors as the cause The sites that formatted an underlying error into one of the six variants now also keep it as the cause; the others use the new constructors. Messages are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C --- .../src/decision_language_model.rs | 6 +- aimux-provider-utils/src/download_blob.rs | 2 +- aimux-provider-utils/src/http.rs | 49 +++-- aimux-provider-utils/src/media_type.rs | 2 +- aimux-provider-utils/src/multipart.rs | 19 +- aimux-provider-utils/src/post_to_api.rs | 2 +- aimux-provider-utils/src/resolvable.rs | 12 +- aimux-provider-utils/src/transform_stream.rs | 8 +- aimux-provider-utils/src/url.rs | 14 +- aimux-provider-utils/src/ws.rs | 44 +++-- .../tests/decision_language_model_test.rs | 7 +- .../tests/download_guard_test.rs | 6 +- .../tests/validate_base_url.rs | 10 +- aimux-providers/src/anthropic/convert.rs | 22 +-- aimux-providers/src/anthropic/files.rs | 9 +- aimux-providers/src/anthropic/mod.rs | 8 +- .../src/anthropic/prepare_tools.rs | 4 +- aimux-providers/src/anthropic/skills.rs | 6 +- aimux-providers/src/anthropic/stream.rs | 2 +- aimux-providers/src/anthropic_aws/mod.rs | 2 +- aimux-providers/src/anthropic_aws/model.rs | 2 +- aimux-providers/src/assemblyai.rs | 11 +- aimux-providers/src/aws_polly.rs | 11 +- aimux-providers/src/azure/mod.rs | 30 ++-- aimux-providers/src/azure/transcription.rs | 21 ++- aimux-providers/src/bedrock/convert.rs | 42 ++--- aimux-providers/src/bedrock/embedding.rs | 6 +- aimux-providers/src/bedrock/event_stream.rs | 6 +- aimux-providers/src/bedrock/image.rs | 29 +-- aimux-providers/src/bedrock/mod.rs | 4 +- aimux-providers/src/bedrock/model.rs | 8 +- aimux-providers/src/bedrock/reranking.rs | 4 +- aimux-providers/src/black_forest_labs.rs | 18 +- aimux-providers/src/cartesia.rs | 13 +- aimux-providers/src/catalogue.rs | 15 +- aimux-providers/src/codex.rs | 6 +- aimux-providers/src/cohere/convert.rs | 16 +- aimux-providers/src/cohere/embedding.rs | 6 +- aimux-providers/src/cohere/model.rs | 10 +- aimux-providers/src/cohere/reranking.rs | 4 +- aimux-providers/src/cohere/types.rs | 6 +- aimux-providers/src/dataforseo.rs | 2 +- aimux-providers/src/decision_scoring.rs | 37 ++-- aimux-providers/src/decision_support.rs | 24 +-- aimux-providers/src/deepgram.rs | 11 +- aimux-providers/src/deepseek/convert.rs | 16 +- aimux-providers/src/deepseek/mod.rs | 2 +- aimux-providers/src/deepseek/model.rs | 2 +- aimux-providers/src/deepseek/options.rs | 8 +- aimux-providers/src/default_providers.rs | 4 +- aimux-providers/src/elevenlabs.rs | 11 +- aimux-providers/src/exa_ai.rs | 2 +- aimux-providers/src/fal.rs | 6 +- aimux-providers/src/firecrawl.rs | 2 +- aimux-providers/src/gladia.rs | 13 +- aimux-providers/src/google/convert.rs | 9 +- aimux-providers/src/google/embedding.rs | 34 ++-- aimux-providers/src/google/files.rs | 28 ++- aimux-providers/src/google/image.rs | 2 +- aimux-providers/src/google/mod.rs | 2 +- aimux-providers/src/google/speech.rs | 43 +++-- aimux-providers/src/google/transcription.rs | 28 +-- aimux-providers/src/google/utils.rs | 16 +- aimux-providers/src/google/video.rs | 10 +- aimux-providers/src/google_pse.rs | 15 +- aimux-providers/src/groq/mod.rs | 2 +- aimux-providers/src/groq/model.rs | 2 +- aimux-providers/src/groq/options.rs | 17 +- aimux-providers/src/groq/transcription.rs | 6 +- aimux-providers/src/huggingface.rs | 2 +- aimux-providers/src/hume.rs | 2 +- aimux-providers/src/jev.rs | 6 +- aimux-providers/src/jina_ai.rs | 2 +- aimux-providers/src/klingai.rs | 10 +- aimux-providers/src/linkup.rs | 2 +- aimux-providers/src/lmnt.rs | 2 +- aimux-providers/src/luma.rs | 24 ++- aimux-providers/src/mistral/embedding.rs | 14 +- aimux-providers/src/mistral/mod.rs | 2 +- aimux-providers/src/mistral/model.rs | 9 +- aimux-providers/src/mistral/options.rs | 4 +- aimux-providers/src/mistral/transcription.rs | 15 +- aimux-providers/src/open_responses.rs | 6 +- aimux-providers/src/openai/convert.rs | 15 +- aimux-providers/src/openai/decision.rs | 48 ++--- aimux-providers/src/openai/embedding.rs | 21 ++- aimux-providers/src/openai/files.rs | 9 +- aimux-providers/src/openai/image.rs | 16 +- aimux-providers/src/openai/mod.rs | 2 +- aimux-providers/src/openai/model.rs | 2 +- .../src/openai/responses/convert.rs | 41 +++-- aimux-providers/src/openai/responses/mod.rs | 6 +- .../src/openai/responses/responses_convert.rs | 4 +- .../src/openai/responses/tool_args.rs | 6 +- aimux-providers/src/openai/speech.rs | 4 +- aimux-providers/src/openai/tools.rs | 9 +- aimux-providers/src/openai/transcription.rs | 19 +- aimux-providers/src/openai_compatible/chat.rs | 9 +- .../src/openai_compatible/completion.rs | 10 +- .../src/openai_compatible/config.rs | 12 +- .../src/openai_compatible/convert.rs | 4 +- .../src/openai_compatible/embedding.rs | 10 +- .../src/openai_compatible/image.rs | 6 +- aimux-providers/src/openai_compatible/mod.rs | 2 +- aimux-providers/src/parallel_ai.rs | 2 +- aimux-providers/src/preset.rs | 16 +- aimux-providers/src/prodia.rs | 12 +- aimux-providers/src/recraft.rs | 2 +- aimux-providers/src/replay.rs | 18 +- aimux-providers/src/replicate.rs | 10 +- aimux-providers/src/revai.rs | 13 +- aimux-providers/src/runtime_decision.rs | 59 +++--- aimux-providers/src/runwayml.rs | 6 +- aimux-providers/src/searxng.rs | 10 +- aimux-providers/src/serper.rs | 2 +- aimux-providers/src/shared/mod.rs | 6 +- aimux-providers/src/stability.rs | 12 +- aimux-providers/src/stream/chat_tool_calls.rs | 2 +- .../src/stream/chat_tool_calls_tests.rs | 2 +- aimux-providers/src/tavily.rs | 2 +- aimux-providers/src/tinyfish.rs | 10 +- aimux-providers/src/vertex/auth.rs | 168 +++++++++--------- .../src/vertex/auth_certificate.rs | 44 +++-- aimux-providers/src/vertex/auth_external.rs | 116 +++++++----- aimux-providers/src/vertex/embedding.rs | 4 +- .../src/vertex/gemini_transcription.rs | 4 +- aimux-providers/src/vertex/image.rs | 6 +- aimux-providers/src/vertex/mod.rs | 11 +- .../src/vertex/partner_provider.rs | 4 +- aimux-providers/src/vertex/transcription.rs | 5 +- aimux-providers/src/vertex/video.rs | 8 +- aimux-providers/src/voyage/mod.rs | 2 +- aimux-providers/src/xai/options.rs | 2 +- aimux-providers/src/xai/responses/convert.rs | 8 +- aimux-providers/src/xai/speech.rs | 8 +- aimux-providers/src/xai/transcription.rs | 29 +-- aimux-providers/src/xai/video.rs | 6 +- aimux-providers/src/you_com.rs | 11 +- .../tests/anthropic_aws_settings_test.rs | 2 +- .../anthropic_provider_tool_args_test.rs | 6 +- .../tests/anthropic_remaining_test.rs | 4 +- .../tests/bedrock_upstream_e2e_test.rs | 2 +- aimux-providers/tests/codex_test.rs | 2 +- aimux-providers/tests/deepseek_chat_test.rs | 2 +- .../tests/download_ssrf_guard_test.rs | 2 +- .../tests/google_evaluation_test.rs | 4 +- aimux-providers/tests/google_pse_test.rs | 2 +- aimux-providers/tests/google_speech_test.rs | 2 +- aimux-providers/tests/groq_test.rs | 2 +- .../tests/huggingface_responses_test.rs | 2 +- aimux-providers/tests/jev_test.rs | 2 +- aimux-providers/tests/list_models_test.rs | 2 +- .../tests/mistral_transcription_test.rs | 4 +- aimux-providers/tests/openai_decision_test.rs | 2 +- .../tests/openai_upstream_e2e_test.rs | 2 +- aimux-providers/tests/presets_test.rs | 4 +- .../tests/runtime_decision_test.rs | 6 +- .../tests/vertex_location_validation_test.rs | 8 +- .../tests/vertex_upstream_e2e_test.rs | 2 +- .../tests/xai_transcription_test.rs | 4 +- aimux-providers/tests/xai_video_test.rs | 6 +- 161 files changed, 1092 insertions(+), 813 deletions(-) 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/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}"), } From 4ba0a11258de3f819036a0b7461c0840a353a498 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:24 +0000 Subject: [PATCH 10/14] refactor(ops): follow the struct variants in ops, the C ABI and aimux-web The ops manifest lists TypeValidation under error code 3, and the ops error goldens show InvalidToolInput and InvalidResponseData with their causes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C --- aimux-ffi/src/abi.rs | 4 +- aimux-ops/src/envelope.rs | 13 +++--- aimux-ops/src/manifest.rs | 1 + aimux-ops/src/ops/misc.rs | 12 +++--- aimux-ops/src/ops/modalities.rs | 6 +-- aimux-ops/src/ops/provider.rs | 12 +++--- aimux-ops/src/ops/text.rs | 18 ++++----- aimux-ops/src/ops/trace.rs | 6 +-- aimux-ops/tests/composite.rs | 2 +- aimux-ops/tests/dispatch_contract.rs | 2 +- aimux-ops/tests/error_envelope_golden.rs | 4 +- aimux-ops/tests/handles.rs | 2 +- aimux-ops/tests/manifest_contract.rs | 3 +- aimux-ops/tests/operation.rs | 8 ++-- aimux-ops/tests/provider.rs | 2 +- aimux-ops/tests/registry.rs | 8 ++-- aimux-ops/tests/trace.rs | 2 +- .../errors/aimux-invalid-response-data.json | 6 ++- .../ops/errors/aimux-invalid-tool-input.json | 5 ++- .../ops/errors/aimux-type-validation.json | 15 +++++++ tools/aimux-web/src/api/cache_probe.rs | 4 +- tools/aimux-web/src/api/calls.rs | 2 +- tools/aimux-web/src/api/mod.rs | 4 +- tools/aimux-web/src/api/replay.rs | 19 ++++----- tools/aimux-web/src/api/tools.rs | 2 +- tools/aimux-web/src/api/traces.rs | 9 +++-- tools/aimux-web/src/wire.rs | 40 +++++++++---------- 27 files changed, 118 insertions(+), 93 deletions(-) create mode 100644 contract-tests/ops/errors/aimux-type-validation.json 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/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/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)", )), } } From f719e4bc0b8be1d75a763046c127a61641977bea Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:24 +0000 Subject: [PATCH 11/14] feat(bindings): regenerate the DTOs and add TypeValidationError The generated types follow the new variant shapes. Python, Java and Kotlin dispatch errors by name and get a TypeValidationError class. AiMuxError/005-006 and AiMuxErrorInput/005-006 come from the ops error goldens. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C --- aimux-ffi/include/aimux.h | 4 +- bindings/flutter/lib/generated/ops.dart | 4 +- bindings/flutter/lib/src/generated/types.dart | 143 ++++++++++++++-- bindings/flutter/native/aimux_ops.h | 4 +- bindings/go/generated/ops.go | 4 +- bindings/go/roundtrip_test.go | 2 +- bindings/go/types_gen.go | 104 +++++++++--- bindings/java/generated/AimuxOps.java | 4 +- .../ai/arcships/aimux/types/AiMuxError.java | 152 ++++++++++++++++-- .../arcships/aimux/types/AiMuxErrorInput.java | 152 ++++++++++++++++-- .../ai/arcships/aimux/AimuxException.java | 4 + .../ai/arcships/aimux/AbiContractTest.java | 2 +- bindings/kotlin/generated/AimuxOps.kt | 4 +- .../kotlin/ai/arcships/aimux/kotlin/Errors.kt | 2 + .../ai/arcships/aimux/kotlin/ErrorsTest.kt | 4 +- bindings/node/src/generated/dto_vectors.ts | 4 +- bindings/node/src/generated/ops.ts | 4 +- bindings/node/src/generated/types.ts | 38 ++++- bindings/python/python/aimux/_errors.py | 2 + bindings/python/python/aimux/_types.py | 48 +++++- bindings/python/python/aimux/ops.py | 4 +- bindings/swift/generated/AimuxOps.swift | 4 +- bindings/swift/generated/Types.swift | 146 ++++++++++------- contract-tests/dto/AiMuxError/006.json | 9 ++ contract-tests/dto/AiMuxError/007.json | 8 + contract-tests/dto/AiMuxError/008.json | 10 ++ contract-tests/dto/AiMuxError/009.json | 5 + contract-tests/dto/AiMuxErrorInput/006.json | 9 ++ contract-tests/dto/AiMuxErrorInput/007.json | 8 + contract-tests/dto/AiMuxErrorInput/008.json | 10 ++ contract-tests/dto/AiMuxErrorInput/009.json | 5 + contract-tests/dto/index.json | 32 ++++ contract-tests/dto/sources.json | 40 +++++ schema/types/AiMuxError.json | 92 ++++++++++- schema/types/AiMuxErrorInput.json | 92 ++++++++++- tools/aimux-codegen/generated-files.json | 2 +- 36 files changed, 1005 insertions(+), 156 deletions(-) create mode 100644 contract-tests/dto/AiMuxError/006.json create mode 100644 contract-tests/dto/AiMuxError/007.json create mode 100644 contract-tests/dto/AiMuxError/008.json create mode 100644 contract-tests/dto/AiMuxError/009.json create mode 100644 contract-tests/dto/AiMuxErrorInput/006.json create mode 100644 contract-tests/dto/AiMuxErrorInput/007.json create mode 100644 contract-tests/dto/AiMuxErrorInput/008.json create mode 100644 contract-tests/dto/AiMuxErrorInput/009.json 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/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/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" } From b570adacfa15484f4a39b4405a8c6e1fa8f6accc Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:24 +0000 Subject: [PATCH 12/14] docs: describe the error cause and TypeValidation CHANGELOG Breaking, the error model's cause section, the code 3 aliases, 232 DTO vectors, and RFC-0035's repair-context example. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C --- CHANGELOG.md | 23 +++++++++++++++-------- docs/ai-sdk-request-pipeline.md | 2 +- docs/error-model.md | 14 ++++++++++++++ docs/rfc-0039-conformance.md | 4 ++-- docs/rfc-0039-implementation.md | 5 +++-- rfc/0035-host-side-tool-call-repair.md | 2 +- 6 files changed, 36 insertions(+), 14 deletions(-) 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/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 } ``` From 448460b3e6967c9f3da2f1a8a96bbf64870d63fa Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Wed, 21 Oct 2026 11:43:42 +0800 Subject: [PATCH 13/14] fix: restore master content lost by the cause stack merge --- .github/workflows/ci.yml | 8 + ...crate-layering-and-dependency-inversion.md | 332 +++++++++++ rfc/0046-provider-protocol-abstraction.md | 515 ++++++++++++++++++ rfc/0047-error-cause.md | 464 ++++++++++++++++ 4 files changed, 1319 insertions(+) create mode 100644 rfc/0044-crate-layering-and-dependency-inversion.md create mode 100644 rfc/0046-provider-protocol-abstraction.md create mode 100644 rfc/0047-error-cause.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97683dfa5..15416b0c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,8 @@ jobs: $(job node-binding bindings/node) $(job python-binding bindings/python) $(job ffi-build aimux-ffi bindings/go) + $(job ffi-sanitizers aimux-ffi) + $(job ffi-linux-arm aimux-ffi) $(job jvm-bindings aimux-ffi bindings/java bindings/kotlin) $(job flutter-binding aimux-ffi bindings/flutter) $(job flutter-example-build aimux-ffi bindings/flutter) @@ -458,6 +460,8 @@ jobs: ffi-sanitizers: name: C ABI (ASan + LSan) runs-on: ubuntu-latest + needs: changes + if: fromJSON(needs.changes.outputs.run)['ffi-sanitizers'] steps: - uses: actions/checkout@v4 with: @@ -479,6 +483,8 @@ jobs: ffi-linux-arm: name: aimux-ffi (aarch64-unknown-linux-gnu) runs-on: ubuntu-24.04-arm + needs: changes + if: fromJSON(needs.changes.outputs.run)['ffi-linux-arm'] steps: - uses: actions/checkout@v4 with: @@ -802,6 +808,8 @@ jobs: - node-binding - python-binding - ffi-build + - ffi-sanitizers + - ffi-linux-arm - jvm-bindings - flutter-binding - flutter-example-build diff --git a/rfc/0044-crate-layering-and-dependency-inversion.md b/rfc/0044-crate-layering-and-dependency-inversion.md new file mode 100644 index 000000000..7d740d887 --- /dev/null +++ b/rfc/0044-crate-layering-and-dependency-inversion.md @@ -0,0 +1,332 @@ +# RFC-0044:crate 拆分与依赖反转 + +> **Status**: Draft +> +> **Date**: 2026-10-19 +> +> **Scope**: 把 [docs/aisdk-architecture-alignment.md](../docs/aisdk-architecture-alignment.md) 第 2 章 D17 的五层目标落成可以逐步合入的计划:`aimux-core` 按职责拆入 `aimux-provider`、`aimux-provider-utils`、`aimux`、`aimux-devtools`;core 内部直接调用的录制、session、trace、下载改成依赖反转;各 crate 的源码和测试目录按上游包组织。`aimux-providers` 内部的厂商目录、测试位置和厂商间复用不在本文范围,见 RFC-0046([PR #294](https://github.com/arcships/aimux/pull/294)),本文的步骤不依赖它。本文不改变 wire 格式和录制格式;公开行为只有一处变化:删除环境变量 `AIMUX_SESSION_INFER`(§7 登记)。 +> +> **Baseline**: `master@5743a40a`(#265 合入后)。上游对照:`vercel/ai` main `fce9210`(目录与文件命名),行为基线仍按对齐文档登记的版本(`ai@7.0.127` 等)。 +> +> **Related**: [对齐文档](../docs/aisdk-architecture-alignment.md) §2、§8、§9.1(D3、D4、D6、D17、D27、D28、D30)、[RFC-0036](0036-positioning-and-layered-architecture.md) §3、[RFC-0041](0041-replay-governance.md) §3 / §10、[RFC-0042](0042-v4-type-boundaries.md) §1、[RFC-0043](0043-size-performance-budgets.md)、[#276](https://github.com/arcships/aimux/issues/276)、[#270](https://github.com/arcships/aimux/issues/270)、[#273](https://github.com/arcships/aimux/issues/273)、[#278](https://github.com/arcships/aimux/issues/278)、[PR #268](https://github.com/arcships/aimux/pull/268)、RFC-0046([PR #294](https://github.com/arcships/aimux/pull/294),provider 目录、测试位置与厂商间复用) + +## 1. 决策摘要 + +对齐文档已经定了目标分层(D17),但没有说怎么从现在的代码走过去,#276 里也只有“provider-utils 不再依赖 aimux-core”一行。本文补上这部分。 + +| 编号 | 决定 | 后果 | +|---|---|---| +| L1 | 按 D17 新建 `aimux-provider`、`aimux-devtools`,`aimux-core` 拆空后改名 `aimux`;不留兼容 re-export(对齐文档 §2.1 约束 6、D31) | 每一步在同一个 PR 里改完 ops、FFI、四个工具、Node/Python 绑定(各自独立的 workspace,Python 测试里还有名为 `aimux-core` 的替身包)的全部 import | +| L2 | 先在 core 内部按层拆开混在一起的文件,再切 crate | crate 切分本身变成机械移动,评审只看依赖边 | +| L3 | core 不再直接调用录制、session 存储、trace、具体下载实现;下载改为注入接口(§4);传输层的调用关联用对齐文档 §3.5 的 `DiagnosticScope`(C3);录制、session、trace 在调用层的接入方式由调用观测的后续 RFC 定(§4.1) | 去掉 4 个进程全局单例;关闭 RFC-0039 G-a | +| L4 | 下载只保留一份实现:地址校验、重定向逐跳校验在 `aimux-provider-utils`,何时下载、大小上限在 `aimux` 的操作里 | 删除 core 自建的 reqwest client 和第二个重定向循环 | +| L5 | `aimux` 的源码目录按上游 `ai/src/<操作>/` 组织:每个操作一个目录,trait 只在 `aimux-provider` | `generate_video`、`transcribe`、`decide` 等离开 `*_model.rs` | +| L6 | 各 crate 的文件和测试按上游包对照:`aimux-provider-utils` 对 provider-utils,`aimux` 对 `ai/src/<操作>/`,每个模块目录一个 `tests/` 子目录,一个上游测试文件对应其中一个同名 Rust 文件,crate 的 `tests/` 只留跨模块的场景测试;`aimux-providers` 内部另文 | #278 能按文件名对照上游测试 | +| L7 | 一个共享错误枚举放在 `aimux-provider`(对齐文档 §2.2 已定),差异登记 | 每个错误补 `cause` 另开小 PR,不放进本文的拆分步骤 | +| L8 | `aimux-stream` 并入 `aimux-provider-utils`,删除这个 crate;SSE 适配放进对应上游 `parse-json-event-stream.ts` 的文件,`extract_lines` 放进 `extract_lines.rs`,第三方 `sse-stream` 的位置相当于上游的 `eventsource-parser` | 少一个发布包和体积预算;改写对齐文档 D17“aimux-stream 保持独立”,登记(§7) | + +## 2. 现状 + +### 2.1 crate 依赖与体量 + +| crate | 依赖的 aimux crate | src 行数 | tests/ 行数 | 说明 | +|---|---|---|---|---| +| `aimux-core` | 无 | 35,958 | 7,710 | 规范 trait、类型、操作、retry、组合模型、录制、回放、trace、session 全在这里 | +| `aimux-stream` | 无 | 178 | 850 | 约 120 行 SSE 适配(包在第三方 `sse-stream` 外)加 `extract_lines`;唯一调用方是 provider-utils `response_handler.rs:389`;`aimux-providers` 声明了依赖但没有使用;`extract_lines` 在 workspace 里没有调用方 | +| `aimux-provider-utils` | core、stream | 6,400 | 3,171 | 读 core 的录制上下文、下载守卫 | +| `aimux-providers` | core、stream、provider-utils | 79,776 | 60,279 | 48 个厂商实现(另有 282 个 preset 数据行),126 个集成测试文件 | +| `aimux-ops` | core、providers、provider-utils | 10,934 | 10,319 | 宿主组装层 | +| `aimux-ffi` | ops(dev 依赖 core) | 694 | 426 | | +| `tools/aimux-cli`、`aimux-replay`、`aimux-web` | core、providers | 896 / 422 / 3,084 | — | | +| `tools/aimux-codegen` | ops | 553 | 483 | | +| `bindings/node`、`bindings/python` | core、ops | — | — | 不在 workspace 内 | + +目标(对齐文档 §2.1)是 `aimux-provider` 在最底层且不依赖任何 aimux crate;现在最底层是包含运行时的 `aimux-core`,`aimux-provider` 和 `aimux-devtools` 都不存在。crates.io 上 `aimux`、`aimux-provider`、`aimux-devtools` 三个名字目前未被占用(2026-10-19 查询)。 + +### 2.2 同一个文件里混着两层 + +| 文件 | 规范层(去 `aimux-provider`) | 调用层(去 `aimux`) | +|---|---|---| +| `result.rs` | `GenerateResult`、`StreamResult`(`LanguageModel` 的返回类型)、`GenerateContent`、`Source`、`ReasoningOutput`、`RawToolApprovalRequest`(约 10 个厂商在用) | `ContentPart`、`TypedToolResult`、`GeneratedFile` 等调用层输出 | +| `stream_part.rs` | `StreamPart` | `TextStreamPart`(它用到 `step_result` 的类型,`stream_part.rs:19`,随它一起走);`StreamPart::into_text_stream_part`(`stream_part.rs:487-492`,inherent impl,调用方是 `generate.rs:1925` 和 `ai_sdk_json_test.rs:488-502`)改成 `aimux` 里的自由函数,放在 stream_text 旁边:`StreamPart` 在 `aimux-provider`,`aimux` 不能给它写 inherent impl | +| `tool.rs` | `FunctionTool`、`ProviderTool`、`LanguageModelV4ToolChoice`、`RawToolCall`、`ToolResult` | `TypedToolCall`、用户侧 `ToolChoice`、`prepare_tool_choice`(`tool.rs:379`) | +| `types.rs` | `FinishReason`、`FinishReasonUnified`、`Usage`、`InputTokenUsage`、`OutputTokenUsage`、`Warning`、`ProviderMetadata`、`ReasoningEffort`、`ResponseMetadata`、`NamespacedKind` | `LanguageModelUsage` | +| `options.rs` | `CallOptions`、`ResponseFormat` | `options.rs:14` 对 `ToolChoice`、`prepare_tool_choice` 的 re-export 删除 | +| `language_model_message.rs` | V4 prompt 类型 | `convert_to_language_model_prompt`、`download_assets`、`convert_with_downloaded_assets` | +| `download.rs` | `Download`、`DownloadRequest`、`DownloadedFile`、`DownloadFunction`(`TranscriptionCallOptions.download`、`VideoCallOptions.download` 字段的类型,`transcription_model.rs:154`) | `create_download`、`create_default_download_function`、下载实现 | +| 11 个 `*_model.rs` | 各模态 trait、CallOptions、结果(含 `decision_model.rs` 的 trait、类型和校验,`files_model.rs`、`skills_model.rs`) | `embed`、`generate_image`、`rerank`、`search`、`generate_speech`、`transcribe`、`stream_transcribe`、`generate_video`、`decide` / `DecideOptions` | +| `provider.rs` | `Provider`、`ProviderDiscovery`;`list_models` 返回的 `RuntimeModel`(`provider.rs:160`,现在在 `model_catalogue.rs`)一起下移。`Provider::discovery()` 返回 `Option<&dyn ProviderDiscovery>`(`provider.rs:142`),trait 只能和 `Provider` 在同一层,否则 `aimux-provider` 要反过来依赖 `aimux-providers` | — | +| `message.rs`、`content.rs` | `content.rs` 只依赖 shared/types/wire,V4 prompt 用到它,去 `aimux-provider` | 用户输入 `ModelMessage`(`message.rs`)按 RFC-0042 去 `aimux-provider-utils` | + +上游对应关系:trait 在 `@ai-sdk/provider` 的 `<模态>-model/v4/`,操作在 `ai/src/<操作>/`(如 `generate-video/`、`transcribe/`、`decide/`),两者从不在同一个文件。 + +### 2.3 core 直接调用的地方 + +下面四条调用栈从公开入口一直走到问题所在的行。 + +**录制:规范层的 CallOptions 一路把录制上下文带进传输层** + +```text +aimux_core::generate_text aimux-core/src/generate.rs:976 + └ generate_in_operation aimux-core/src/generate.rs:1062 + └ recording::recorder() aimux-core/src/generate.rs:1101 ← 读全局 RECORDER(recording.rs:605) + └ RecordingContext::scope(context, …) aimux-core/src/generate.rs:1153 ← 设 task-local + └ do_generate_with_logging aimux-core/src/generate.rs:2288 + └ model.do_generate(call_options) aimux-core/src/generate.rs:2294 + └ OpenAI do_generate aimux-providers/src/openai/model.rs:166 + └ execute_generate aimux-providers/src/openai/model.rs:174 → :221 + └ config.http_request(url, headers, options) + aimux-providers/src/openai/config.rs:180 + └ HttpRequest::new aimux-provider-utils/src/http.rs:168 + └ options.recording_context() aimux-provider-utils/src/http.rs:178 + └ RecordingContext::current() aimux-provider-utils/src/http.rs:126 → aimux-core/src/recording.rs:695 + ← provider-utils 读 core 的 task-local + └ post_json_to_api aimux-providers/src/openai/model.rs:235 + └ record_exchange aimux-provider-utils/src/http.rs:647 → :861 + └ Recorder::record_exchange aimux-provider-utils/src/http.rs:865 → aimux-core/src/recording.rs:568 + ← 传输层直接写录制 +``` + +读全局 recorder 的只有三处:`generate.rs:1101`(generate_text)、`:1621`(stream_text)、`decision_model.rs:662`(decide)。router(10 处)、MoA(6 处)、composite(4 处)读写的是 task-local 的 `RecordingContext`。 + +**session:每次生成都去读进程全局的 session 存储** + +```text +aimux_core::generate_text aimux-core/src/generate.rs:976 + └ generate_in_operation aimux-core/src/generate.rs:1062 + └ session::resolve_session_id(settings.session_id, prompt) + aimux-core/src/generate.rs:1130 + └ session::session_store() aimux-core/src/generate.rs:1131 → aimux-core/src/session.rs:333 + └ static SESSION_STORE aimux-core/src/session.rs:320 ← 全局单例 + └ store.append(session_id, call_id, source) aimux-core/src/session.rs:131 +(stream_text 同样在 generate.rs:1657) +``` + +`session.rs` 里有三样东西:调用方给的 `session_id`(已经是操作设置)、前缀推断(`SESSION_INFERER`,`session.rs:321`,还读环境变量 `AIMUX_SESSION_INFER`)、会话索引 `SessionStore`(`session.rs:84`)。上游 `generateText` 没有 session,归组属于观测工具。 + +**下载:video 的真实下载实现在 core,而且和 provider-utils 各有一份** + +```text +aimux_core::generate_video aimux-core/src/video_model.rs:463 + └ options.download.unwrap_or_else(|| create_download(None)) + aimux-core/src/video_model.rs:535 + └ download.download(url, abort) aimux-core/src/video_model.rs:538 → aimux-core/src/download.rs:83 + └ create_download 建的下载函数 aimux-core/src/download.rs:106 + └ download aimux-core/src/download.rs:222 + └ download_inner aimux-core/src/download.rs:239 + └ for redirects in 0..=MAX_DOWNLOAD_REDIRECTS + aimux-core/src/download.rs:244 ← 第一个重定向循环 + └ reqwest::Client::builder()… aimux-core/src/download.rs:259 ← core 自己建 HTTP client + +provider 下载自家返回的 URL 时: +send_following_redirects aimux-provider-utils/src/http.rs:429 + └ aimux_core::download_guard::validate_download_target + aimux-provider-utils/src/http.rs:444 → aimux-core/src/download_guard.rs:101 + ← 反向依赖 core + └ for redirect_count in 0..=MAX_REDIRECTS aimux-provider-utils/src/http.rs:453 ← 第二个重定向循环(每跳再校验,:504) +``` + +`transcribe`(`transcription_model.rs:493`)也走 `create_download`;prompt 中的 URL 走 `create_default_download_function`(`download.rs:185`,由 `generate.rs:1048` 和 ops 的 `handles.rs:136` 调用),底下是同一份实现。上游只有一份:`ai/src/generate-video/generate-video.ts` 决定何时下载(`:136` 默认下载、`:387` 调用),`ai/src/util/download/download.ts:39` 调 `@ai-sdk/provider-utils` 的 `fetchUntrustedUrl`,地址校验和逐跳重定向都在 provider-utils。 + +**trace 与 replay:core 包一层 model,回放要靠 providers 反向重建** + +```text +aimux_ops trace.create aimux-ops/src/ops/trace.rs:99 + └ aimux_core::trace::RingTraceStore::with_capacity aimux-ops/src/ops/trace.rs:113 → aimux-core/src/trace/store.rs:417 +宿主包模型时 aimux-ops/src/ops/handles.rs:212 + └ aimux_core::trace::TraceLayer::new aimux-core/src/trace/layer.rs:113,:520(impl LanguageModel) + +aimux_ops replay.live(registry) aimux-ops/src/ops/replay.rs:333 + └ aimux_providers::replay::rebuild_provider aimux-providers/src/replay.rs:58 ← core 不能依赖 providers,只好放这里 + └ aimux_core::replay::replay_with_model aimux-ops/src/ops/replay.rs:335 +(decide 回放同理:aimux-ops/src/ops/replay.rs:339 → aimux-providers/src/replay.rs:26 rebuild_decision_provider) +``` + +上游 devtools 是外挂的:`registerTelemetry(DevToolsTelemetry())`(`ai/src/telemetry/telemetry-registry.ts:6`、`devtools/src/integration.ts:113`)加 `devToolsMiddleware()`(`devtools/src/middleware.ts:101`),`ai` 包不认识 devtools。两者都在模型调用层:前者收 telemetry 事件,后者是模型 middleware。上游没有 Fetch 层的观察者,传输层录制是 aimux 的扩展(RFC-0036 §3 的 L0),登记见 §7。上游 devtools 在 `NODE_ENV === 'production'` 时直接抛错(`middleware.ts:102-107`、`integration.ts:116-121`)。 + +### 2.4 provider 目录与测试 + +厂商目录、测试文件和厂商之间的引用不在本文范围。本文只关心 crate 之间的边:`aimux-providers` 现在依赖 core,C6 之后不再依赖。 + +## 3. 目标分层 + +沿用对齐文档 §2.1 的五层,本文把每个现有模块的去向定下来。 + +```text +aimux-provider V4 规范:Provider 与各模态 trait、CallOptions、V4 prompt/content/stream part、共享错误、middleware 规范 + ↑ +aimux-provider-utils SSE 解码(原 aimux-stream)、Fetch/WS、headers、Resolvable、load_*、单次 HTTP helper、下载守卫与逐跳重定向、 + Fetch 观察点、脱敏、ModelMessage 用户输入、descriptor/catalogue 类型 + ↑ +aimux-providers 厂商工厂与模型实现、preset、catalogue 摄取 +aimux → aimux-provider、aimux-provider-utils + 各操作、prompt 标准化与下载策略、retry/timeout、registry/custom/wrap、 + operation scope、组合模型 +aimux-devtools → aimux、aimux-provider-utils + 录制、回放、缓存审计、session 归组(推断 + 索引);接入方式见 §4.1 +aimux-ops / FFI / 绑定 / 工具 宿主组装 +``` + +### 3.1 aimux-core 模块去向 + +| 去向 | 模块 | +|---|---| +| `aimux-provider` | `abort_signal`、`error`、`language_model`、`language_model_middleware`、`image_model_middleware` 的规范部分、`provider`(`Provider` 和 `ProviderDiscovery`)、11 个 `*_model.rs` 的 trait/选项/结果部分、§2.2 表中规范层一列的类型(含下载接口类型和 `RuntimeModel`)、`content`、`language_model_message` 的 V4 prompt 类型、`options`、`shared`、`timestamp`、`wire` | +| `aimux-provider-utils` | `message`(RFC-0042)、`download_guard`、`recording::is_sensitive_key` 与 `redact_json`(`recording.rs:802`、`:821`,脱敏是传输层和录制共用的)、`model_catalogue` 除 `RuntimeModel` 外的 descriptor 类型(对齐文档 §2.2 “无运行时依赖的注册条目与 descriptor 类型”)、Fetch 观察点交出的请求与响应记录类型(现在的 `HttpExchange` 等,`http.rs:15-16`;录制 schema 本身在 devtools) | +| `aimux` | `generate` 及其测试、`output`、`parse_tool_call`、`json_repair`、`math`(`cosine_similarity`,上游在 `ai`)、`openai_output`、`response_messages`、`step_result`、§2.2 表中调用层一列、`download`(下载策略,传输改用 provider-utils)、`retry`、`timeout`、`operation`、`router`、`moa`、`composite`、`custom_provider`、`wrap_provider`、`wrap_language_model` / `wrap_image_model`、`provider_registry` | +| `aimux-devtools` | `recording`(schema、Recorder 实现、`RingRecorder`)、`replay`(含 `MockReplayModel`)、`decision_replay`(含 `MockDecisionReplayModel`)、`trace/*`、整个 `session`(`resolve_session_id`、前缀推断 `SessionInferer`、`SessionStore`、`list_sessions`、`session_calls`)。ops、CLI、Web 现在用的这几个公开类型由 devtools 导出 | +| `aimux-ops` | `aimux-providers/src/replay.rs` 的 `rebuild_provider` 和 `rebuild_decision_provider`,连同它们的测试(测试调 `create_provider_registry` 并读 core 的录制样例,`replay.rs:90`、`:101`、`:107`);宿主才有 registry,#270 的传输回放落地后删除(对齐文档 §8.3) | +| 留在 `aimux-providers` | `default_providers.rs`:它只按名字调各厂商工厂、返回 provider 表,不依赖运行时;它唯一调 `create_provider_registry` 的测试随 C2 挪到 aimux 的集成测试 | +| 拆掉 | `util.rs`(只是 re-export 门面);`lib.rs` 的根 re-export 和 `prelude` 各 crate 按自己的内容重写 | + +provider-utils 里已有的 `decision_language_model.rs` 随 decide 的规范类型改 import,位置不变。 + +### 3.2 aimux 的目录 + +按上游 `ai/src/` 组织,每个操作一个目录,测试放在该目录的 `tests/` 子目录(§5): + +```text +aimux/src/ + generate_text/ generate_text.rs stream_text.rs step_result.rs output.rs parse_tool_call.rs ... + generate_object/ + embed/ generate_image/ generate_speech/ transcribe/ generate_video/ rerank/ search/ decide/ + prompt/ convert_to_language_model_prompt.rs standardize_prompt.rs + registry/ provider_registry.rs custom_provider.rs wrap_provider.rs + middleware/ (调用层 wrap 函数;middleware 规范在 aimux-provider) + composite/ router.rs moa.rs + util/ download.rs retry.rs timeout.rs +``` + +`aimux-provider` 同理按上游 `provider/src/<模态>-model/` 组织。 + +### 3.3 测试归属 + +1. 测试跟着被测代码走;一个测试同时用到上下两层时,放到较上面那个 crate。 +2. 不允许下层 crate 的测试 dev-depend 上层 crate:dev 依赖造成的环会让同一个类型在测试里出现两份。现在 providers、provider-utils 的测试用到 download、operation、openai_output、generate、replay、`RingRecorder`,这些测试搬到 `aimux`、`aimux-devtools` 或 ops 的集成测试。 +3. 只测录制、trace、缓存审计的(`cache_probe_test`、`trace_stream_text_test`、`trace_recording_correlation_test`、`master_recording_test`)去 devtools;`operation_settings_test` 按内容拆成 aimux 和 devtools 两份;`ai_sdk_json_test` 按类型族拆;`custom_provider_test` 随 `custom_provider` 去 `aimux`(`ProviderDiscovery` 在 `aimux-provider`,它不需要 providers)。 +4. 各 crate 自己的测试辅助(mock model、脚本模型)放在本 crate 的 `test_support` 模块,`#[cfg(any(test, feature = "test-support"))]` 编译:只用 `#[cfg(test)]` 的话,`tests/` 下的集成测试和下游 crate 的测试都看不到它。本 crate 的集成测试经自引用的 dev 依赖(`[dev-dependencies] aimux-x = { path = ".", features = ["test-support"] }`)开启,下游 crate 在自己的 dev 依赖里开启;正常构建不带这个 feature;core 测试里重复 3 次的 `ScriptedModel` 合成一份。providers 的 `tests/common/replay.rs:93` 用到录制类型,C6 前改成用 provider-utils 的观察记录类型。 + +## 4. 依赖反转 + +| 现在 | 反转后 | 接口所在 | 实现所在 | +|---|---|---|---| +| provider-utils 读 `RecordingContext::current()` 并直接写 `Recorder`(`http.rs:126`、`:861`) | provider-utils 不再认识录制:在 Fetch 链最内层留观察点,包住用户或默认的叶子 Fetch,鉴权之后、真正发出之前(对齐文档 §3.2 的顺序、D4 的叶子位置),请求、响应、分块、错误都经过它;和 D5 的 `replay_fetch` 是同一个接口(RFC-0041 §3)。exchange 归属哪个调用,用对齐文档 D3、§3.5 已定的传输可见 scope(`DiagnosticScope`,task-local,放在 provider-utils,内容见 §4.2):`aimux` 的操作在调用模型前建立它,观察点从当前 scope 取调用关联交给观察者;流式响应的 body 在发出时就拿到关联,不依赖之后 poll 所在的 task-local(§3.5)。这对应上游每个 telemetry 事件都带的 `callId`(`ai/src/generate-text/generate-text.ts:669` 生成,`ai/src/telemetry/telemetry.ts:332-337` 的 `executeLanguageModelCall` 让模型调用跑在集成提供的上下文里)。C3 就按这个做,C3 到 C5 之间录制不丢调用关联 | `aimux-provider-utils` | `aimux-devtools` 的 Recorder | +| generate / decide / router / MoA / composite 读全局 `RECORDER` 并自己建 `RecordingContext` | `aimux` 不再认识录制;录制在 `aimux-devtools`,接入方式由调用观测的后续 RFC 定(§4.1) | 后续 RFC | `aimux-devtools` | +| generate 解析 session、读全局 `SESSION_STORE` 并 `append`,再写进录制(`generate.rs:1129-1139`) | generate 不再认识 session:调用方给的 `session_id` 只是操作设置里的一个字符串,原样交给观测层;推断、索引、与录制的关联全部在 devtools(§4.1) | 后续 RFC | `aimux-devtools` | +| 前缀推断是全局 `SESSION_INFERER` + 环境变量 `AIMUX_SESSION_INFER` | 推断器是 devtools 里 session 归组的配置项,宿主开启,默认关闭;删除环境变量开关 | `aimux-devtools` | `aimux-devtools` | +| `TraceLayer` 是 core 里的 model 包装 | 缓存审计移到 devtools;指纹、LCP、真实性判定保留,接入方式由后续 RFC 定(§4.1) | 后续 RFC | `aimux-devtools` | +| `generate_video` / `transcribe` / prompt 下载默认构造 core 自带的 reqwest 下载 | 操作只依赖 `Download` 接口;默认实现调用 provider-utils 的不可信 URL 下载(一份地址校验、一份逐跳重定向) | `aimux` | `aimux-provider-utils` | +| 回放靠 providers 里的 `rebuild_provider` 反向重建 | 宿主(ops/CLI)组装 registry 交给回放;#270 后由传输层回放取代 | `aimux-devtools` | `aimux-ops` | + +完成后 `aimux` 里不再有 `RECORDER`、`SESSION_STORE`、`SESSION_INFERER`、`INFER_ENV_CHECKED` 这四个全局量。两个 `CALL_SEQ` 计数器(`recording.rs:771`、`session.rs:406`)和 `TRACE_SEQ`(`trace/layer.rs:14`)随各自模块去 devtools;`operation.rs` 只有 task-local 的操作设置(`CURRENT`,`operation.rs:25`),留在 `aimux`。 + +不在本文处理、保持现状的全局量:`DEFAULT_TRANSPORT`(provider-utils `fetch.rs:221-281`,上游同样有默认 fetch)、各厂商的默认实例 `DEFAULT`(上游同样有);日志初始化(`logging.rs` 的 `INIT`、`AIMUX_LOG`、`AIMUX_LOG_BODY`)交给调用观测的后续 RFC。 + +### 4.1 调用观测留给后续 RFC + +trace、日志和 telemetry 怎么统一(倾向 Rig 的 span 方案)单独成文,RFC-0044 合入后另起。对齐文档已经写了一些相关决定(D4、D6、D27,§3.2 的 `DiagnosticScope` 放在 provider-utils,§3.5 的 task-local 诊断作用域),后续 RFC 可能修改它们,届时在那份 RFC 里登记。本文只定两件事: + +1. 传输层由本文定:Fetch 观察点和 `DiagnosticScope`(上表第一行,C3)。录制、#270 的传输回放都建在它上面。 +2. 调用层(模型调用开始与结束、session 归组、缓存审计)不再由 core 直接调用,最终落在 `aimux-devtools`(§3.1、上表)。它们接到调用上的方式由后续 RFC 定,本文不先造一套之后要换掉的调用层事件接口(对齐文档 D31 不留过渡代码)。 +3. 因此 C5、C6 中“session、trace 搬进 devtools”这部分等后续 RFC 定稿再做;C1–C4、C7 不受影响。 + +### 4.2 `DiagnosticScope` 的内容 + +只带 `call_id` 不够:录制要知道一次交换属于哪个模型调用的第几次尝试、是这次尝试里的第几次交换、交给哪个观察者(RFC-0048 §3 的 `exchangeId: ".."`、§4.1 的要求,[PR #325](https://github.com/arcships/aimux/pull/325))。C3 定下的 scope: + +| 字段 | 谁设置 | 用途 | +|---|---|---| +| `call_id` | `aimux` 的操作入口 | 对应上游 telemetry 事件的 `callId` | +| `model_call: { seq, attempt, parent }` | 操作是 seq 0;router 子模型、MoA 的每个 reference 和 aggregator 进入子 scope,取新 seq 并记下 `parent`;retry 每次尝试递增 `attempt`,不换 seq | 区分组合模型的子调用和重试 | +| 交换序号 | 观察点在每个 `(seq, attempt)` 内从 1 递增,scope 里只放计数器 | 同一次尝试里的多次交换(重定向每一跳单独计一次) | +| `observers` | 宿主进入操作时作为 scope 的构造参数传入(devtools 的 Recorder 是其中一个),不放进 `OperationSettings`;为空时观察点直接透传,不复制 body | 取代全局 `RECORDER`(`recording.rs:605`):交换只交给本次操作的观察者 | + +observers 的载体是 `DiagnosticScope` 本身:它和 `OperationSettings::current()`(`operation.rs:24-26`)一样是 task-local,但是单独的一个值,宿主用它的构造参数装观察者。`OperationSettings` 放不了:它 derive 了 `Serialize`、`Deserialize`、`Clone`、`Eq`(`operation.rs:29`),还会整份写进录制(`recording.rs:214`),`Arc` 既不能序列化也不能比较。 + +C3 到 C5 之间的过渡:core 现在的 `Recorder`(`recording.rs:605` 的全局 `RECORDER` 存的那个)在 C3 实现观察者接口;`init_recording`(`recording.rs:731`)打开录制后,`aimux` 的操作入口建 scope 时把它放进 observers,HTTP 层和观察点不再读全局量。`init_recording` 这个全局开关留到 C5 按后续 RFC 去掉(C5 的“去掉四个全局量”),C6 把 Recorder 搬进 devtools,由宿主自己装。 + +seq 和 attempt 在 C3 就由 `aimux` 的 retry 循环和 router、MoA、composite 设置(它们是传输可见的关联,不是调用层事件);调用开始、结束等调用层事件仍由后续 RFC 定(§4.1)。流式响应在发出时就把这一组值交给观察者,之后 poll 的 task 不需要 scope。 + +### 4.3 下载归一的对等要求 + +C4 是重构,不改各条下载路径现在可观察的行为。两份实现现在的差别(master@`c0622ea4`): + +| 项 | 操作的下载(video、transcribe、prompt,`download.rs`) | provider 下载自家 URL(`http.rs:429-520`) | C4 之后 | +|---|---|---|---| +| 代理 | `no_proxy()`,只连校验过的地址(`download.rs:259-263`) | 走默认传输的代理设置(D26,`http.rs:458-466`) | 各自保持:不可信 URL 下载的第一个参数是连接策略(直连 / 默认传输的代理),操作用直连 | +| User-Agent | `aimux/<版本>`(`download.rs:266-269`) | 请求自带的 UA,跨源只留 UA(`http.rs:515`) | 各自保持:第二个参数是 UA 来源(固定的 `aimux/<版本>` / 请求自带) | +| 错误 | `Other`(状态码、大小)、`InvalidArgument`(地址);RFC-0047(#286,cause 的实现是 #290)之后带 cause、不带 URL | 状态与重定向错误是 `ApiCall`(`http.rs:400-406`) | 各自保持现有变体和消息:第三个参数是错误映射(操作的 `Other`、`InvalidArgument` / provider 的 `ApiCall`) | +| 重定向 | 最多 10 跳,逐跳校验 | 最多 10 跳,逐跳校验,跨源去掉调用方 header | 同一个循环 | +| 大小上限 | 默认 2 GiB | 默认 2 GiB(`read_response_with_size_limit.rs:12`) | 同一个常量 | + +上游 `ai/src/util/download/download.ts:33-71` 用 `ai-sdk/<版本>` 加运行时 UA、经 `fetchUntrustedUrl` 下载、失败抛 `DownloadError`(带 url、状态码)。是否把操作下载的 UA 和错误改成上游的样子,见 §9,不在 C4 里顺带改。 + +## 5. 目录与测试位置 + +1. `aimux-provider-utils` 的文件对照上游 provider-utils,`aimux` 对照 `ai/src/<操作>/`(§3.2),文件名把 kebab-case 换成 snake_case。 +2. 测试照 RFC-0046 §5 的厂商测试目录:每个模块目录一个 `tests/` 子目录,目录的 `mod.rs`(crate 根目录是 `lib.rs`)末尾一行 `#[cfg(test)] mod tests;`;一个上游测试文件对应 `tests/` 下一个与被测源文件同名的 Rust 文件,如 `ai/src/generate-text/generate-text.test.ts` → `aimux/src/generate_text/tests/generate_text.rs`,上游 provider-utils 的 `src/post-to-api.test.ts` → `aimux-provider-utils/src/tests/post_to_api.rs`。直接调内部函数的和走公开 API 的都放这里,不用 `#[path]`,源文件里不写测试;被测的私有函数改成 `pub(super)`。crate 的 `tests/` 只留跨模块的场景测试。 +3. `aimux-providers` 内部的目录和测试搬移不在本文范围。 + +## 6. 实施顺序 + +每一步一个 PR,按对齐文档 D31:改了哪个符号,就在同一 PR 里改完它在 ops、FFI、工具、Node/Python 绑定中的全部使用方;合入时 workspace 全绿,Node、Python 绑定(各自独立的 workspace)也编译通过;PR 描述附产物体积表(RFC-0043)。新建的 crate 在同一个 PR 里登记 RFC-0043 的体积预算项,否则体积检查报未登记。 + +| 步骤 | 内容 | 前置 | +|---|---|---| +| C0 | `aimux-stream` 并入 provider-utils(L8):源码和 850 行测试搬进 provider-utils,删掉 providers 里没用的依赖,删除 crate,README、`release.yml`、`logging.rs:139` 的 target 列表同步。`aimux-stream` 0.5.0 已发布到 crates.io,处理方式和 `aimux-core` 一样(§9):删除前用它当前的代码发最后一个版本,README 和 crate 文档第一行写明已并入 `aimux-provider-utils`、不再维护,不撤回旧版本;CHANGELOG 记一条 | 无 | +| C1 | core 内部按层拆开混合文件,操作函数搬进按操作分的目录(§2.2、§3.2),不新建 crate | #268 合入(它改了 core 的 36 个文件) | +| C2 | 新建 `aimux-provider`,搬入规范模块;core 改为依赖它。先断掉规范层往上的三条边:`RuntimeModel` 下移、下载接口类型下移、`options.rs:14` 的 re-export 删除(§2.2) | C1 | +| C3 | provider-utils 在 `Fetch` 层留观察点,core 的录制改为观察者;HTTP 层不再读 `RecordingContext`,文本生成和 decide 两条路径(`http.rs:118-128`、`:130-140`)以及写录制(`:861-867`)都改为从 `DiagnosticScope` 取调用关联(§4 第一行、§4.2),retry 设置 attempt,router、MoA、composite 建立子 scope;`ModelMessage`、下载守卫、脱敏、catalogue 类型搬入 provider-utils;provider-utils 不再依赖 core | C2 | +| C4 | 下载归一(§4 下载一行):video、transcribe、prompt、生成文件都走 provider-utils 的不可信 URL 下载,连接策略、UA、错误映射三个参数让各路径的代理、UA、错误照 §4.3 保持;删除 core 的 reqwest client | C3 | +| C5 | 等调用观测的后续 RFC:按它把录制、session 归组接到调用上,去掉四个全局量 | C3、后续 RFC | +| C6 | 新建 `aimux-devtools`,搬入录制、回放、trace、session;`rebuild_provider` 移到 ops;providers 不再依赖 core | C5 | +| C7 | `aimux-core` 改名 `aimux`;发布顺序改为 provider → provider-utils → aimux → providers → ops → ffi(C6 之后在 providers 和 ops 之间加 devtools):C7 可以在 C6 之前做,那时 providers 还依赖 `aimux`(C6 才去掉这条边),所以 `aimux` 先于 providers 发布;`aimux` 不依赖 providers(`aimux-core/Cargo.toml` 里没有 providers),这个顺序在 C6 前后都成立;生成物重新生成后检查不含 `aimux_core`(#268 删掉了 `aimux-ops/src/manifest/controls.rs` 和 `schema/ops/type-*.json`,master@`c0622ea4` 的 `schema/` 里已经没有 `aimux_core` 路径;ops 里 30 处 `TypeRef::of::` 是 Rust 路径,随改名一起改);CLI 的二进制也叫 `aimux`,给它设 `doc = false`,避免和库的文档目录冲突 | C4 | + +C5、C6 要等调用观测的后续 RFC(§4.1);改名 C7 不依赖它们,C4 之后就可以做,`aimux` 里暂时留着的录制、session、trace 在 C5、C6 再搬出。`aimux-providers` 内部的目录整理与 C 系列互不阻塞;它先做的话,C2/C3 改 import 时对着整理好的目录一次改完。 + +与其他工作的关系: + +- #270(schema 3 与传输回放)建在 C3 的 Fetch 观察点和 C6 的 `aimux-devtools` 上。先拆后做,schema 3 直接写进 `aimux-devtools`;反过来做则 schema 3 会先写进 core 再搬一次。 +- #278(测试补齐)按 §5 的位置写新测试,避免补完再搬;`aimux-providers` 内部的测试位置另文。 +- #273(体积门禁):本文不给 providers 加按厂商的 cargo feature。 + +## 7. 文档同步与登记差异 + +随各步骤 PR 一起改: + +- 对齐文档 §2.1–§2.3:补模块去向(§3.1)、下载归属、session 整体归 devtools;§9.1 已知差距中“拆 crate”“OperationRuntime、DiagnosticScope”两行在 C3、C5、C6 完成时删除。 +- RFC-0039 G-a(录制器、会话存储、推断状态为进程全局)在 C5 关闭。 +- RFC-0042 §1 类型归属不变,按实际文件路径更新引用。 +- README 的 crate 表和 `init_recording` 示例(README:198-203)、CONTRIBUTING、`docs/api/`(含 `decision.md:403-434`)、`docs/rfc-0039-implementation.md`、`docs/ai-sdk-request-pipeline.md`(:622-625、:933)、`upgrading-to-ops-abi.md`、CHANGELOG(只加新条目,不改历史)、`release.yml` 发布顺序。 +- RFC-0036 §3 的 L0 现在包含 retry 和录制;拆分后 retry 在 `aimux`、录制在 devtools,对应关系照实写,不是简单的一层对一个 crate。 +- 对齐文档 §6.5 提到的 `aimux-manifest` crate 不在本文范围,本文不新建它。 + +登记差异(写入对齐文档 §5): + +- 共享错误枚举在 `aimux-provider`,含上游放在 `ai` 包里的 retry、工具调用类错误。原因:ops 错误封装和绑定只认一个联合类型(RFC-0042)。这与对齐文档 §2.1 约束 1 “`aimux-provider` 不包含 retry”冲突,见 §9。 +- session 归组、前缀推断是 aimux 扩展,上游 `generateText` 没有。它们只影响观测,不改变调用本身(推断出的 ID 不进入请求、不影响返回结果,见 `generate.rs:1129-1139`),所以整体放在 devtools;它接到调用上的方式由后续 RFC 定(§4.1)。这改写对齐文档 C8 的 2026-10-13 修订(“沿用现有的 opt-in 前缀推断实现,与 generate 入口一起留在 `aimux`”)和 RFC-0041 中“`aimux` 拥有 SessionInferer 注册接口”一句,在 C5 的 PR 中同步改 C8、§3.6 和 RFC-0041。 +- 对齐文档 §3.5 的“关闭观测”表写着关闭观测时 session 和 session 推断仍然执行。session 整体进 devtools 后,没有装 devtools 就没有归组;ops 默认装上 devtools,所以绑定用户行为不变,只有自己组装、不装 devtools 的 Rust 用户没有归组。见 §9。 +- 删除环境变量 `AIMUX_SESSION_INFER`:它是公开开关(RFC-0024、Python 绑定 `wrapper.py:920` 的文档),改为 devtools 的配置项,算一次公开行为变化。 +- `rebuild_provider` 搬到 ops 是过渡代码(对齐文档 D31、RFC-0041 §12 G3 不鼓励过渡),#270 的传输回放落地后删除。 +- 录制在 Fetch 层(RFC-0036 §3 的 L0):上游 devtools 是 telemetry 集成和模型 middleware(§2.3),没有 Fetch 层观察者。Fetch 观察点和 `DiagnosticScope` 是 aimux 扩展。 +- devtools 用于生产:上游 devtools 在 `NODE_ENV === 'production'` 时抛错(`devtools/src/middleware.ts:102-107`、`integration.ts:116-121`),aimux 的录制和回放是产品功能(RFC-0023、RFC-0041),ops 默认装上 devtools,生产环境照常可用。 +- `aimux-stream` 不再是独立 crate(L8):对齐文档 D17、§0.3 和 §2.1(含约束 2)写的“aimux-stream 保持独立的 SSE 解析库、保留自己的解析错误”改为 SSE 解码在 provider-utils 内部,`SseError` 仍在边界转换为共享错误,与上游 `parseJsonEventStream` 包住 `eventsource-parser` 的分法一致。 + +## 8. 验收 + +1. 依赖边检查(新增脚本,读 `cargo metadata`,进 CI):`aimux-provider` 不依赖任何 aimux crate,也不依赖 reqwest、tokio 网络特性;`aimux-provider-utils` 只依赖 `aimux-provider`;workspace 里没有 `aimux-stream`;`aimux-providers` 不依赖 `aimux`、`aimux-devtools`;`aimux` 不依赖 `aimux-providers`、`aimux-devtools`;`aimux-devtools` 不依赖 `aimux-providers`。 +2. §4 列出的四个全局量不再存在。`aimux` 里其余的 `static` 可变全局都在允许列表里,每一项注明对应的上游全局(例如照上游做的全局 telemetry 注册:`ai/src/telemetry/telemetry-registry.ts:6`、`ai/src/global.ts:54`);task-local 的操作设置和 `DiagnosticScope` 不算。 +3. workspace 里任何地方都不出现 `aimux_core`(C7 后)。 +4. 每个 PR 的测试数量前后相等(`cargo test --workspace -- --list`),生成物重新生成后无 diff。 +5. 产物体积不超过 RFC-0043 记录的当前值。 + +## 9. 开放问题与不采纳 + +- **`session_id` 的入口。** 本文保留它作为操作设置里的一个字段(绑定已暴露,RFC-0041 §10.2 已落地),`aimux` 只透传。完全照上游可以改成通用的 telemetry metadata,由 devtools 自己读出 session;这会改绑定 API,单独决定。 +- **不装 devtools 时要不要 session 归组。** 本文的方案是不要(ops 默认装上);对齐文档 §3.5 写的是关闭观测也照样归组。 +- **重试错误放在哪。** 本文放在 `aimux-provider` 的共享错误里(绑定只认一个联合类型),与对齐文档 §2.1 约束 1 冲突,需要登记或改放 `aimux`。 +- **crates.io 上的 `aimux-core`、`aimux-stream`。** 建议删除前各发最后一个版本说明去向(`aimux-core` 改名 `aimux`,`aimux-stream` 并入 `aimux-provider-utils`),不撤回旧版本。 +- **操作下载的 UA 和错误(待讨论)。** C4 保持现状(§4.3)。上游下载用 `ai-sdk/<版本>` 加运行时 UA,失败抛带 url 和状态码的 `DownloadError`;照上游改要新增一个错误变体,且和 RFC-0047 “传输错误不带 URL” 冲突,单独决定。 +- **不采纳:保留 `aimux-core` 作为 re-export 门面。** 违反对齐文档 §2.1 约束 6,且会让两个路径长期共存。 +- **不采纳:一次性大 PR 完成全部拆分。** 改动面覆盖整个 workspace 和两个绑定,无法评审,也无法逐步定位回归。 +- 每个错误补 `cause`(错误链)另开小 PR,与本文的搬移互不依赖;若先于 C2 合入,C2 原样搬移。 diff --git a/rfc/0046-provider-protocol-abstraction.md b/rfc/0046-provider-protocol-abstraction.md new file mode 100644 index 000000000..7ee370112 --- /dev/null +++ b/rfc/0046-provider-protocol-abstraction.md @@ -0,0 +1,515 @@ +# RFC-0046:provider 协议的小重构与抽象设计 + +> **Status**: Draft +> +> **Date**: 2026-10-20 +> +> **Scope**: `aimux-providers` 里各家协议实现(OpenAI Chat、OpenAI Responses、Anthropic Messages、Google generateContent、Bedrock Converse 等)怎么分层、怎么被别的厂商复用:通用函数下沉到 `aimux-provider-utils`,每种协议一个实现、用 `XxxConfig` 承载变体差异,托管变体只换配置和鉴权,厂商之间只经过 `internal` 入口单向依赖。目录、文件名和测试位置按这套分层落地,能逐个对照上游包。是对现有代码的小重构:不改变 wire 行为,厂商模块根的导出和公开设置不变;厂商目录里现在公开的子模块(`openai::model`、`mistral::convert` 等)会改名或收为私有,provider-utils 删掉两个只有测试在用的公开函数,都是破坏性变更,按 §4.5 发布;第 3.4 节 Vertex Gemini 的合并会修正已经走样的行为,单独登记。crate 怎么拆见 RFC-0044,两份互不依赖。 +> +> **Baseline**: `master@5743a40a`。上游对照:`fixtures/aisdk/VERSIONS.json` 锁定的包版本(`scripts/fetch-aisdk.sh` 取得的发布包),目录与命名另参考 `vercel/ai` main `fce9210`。 +> +> **Related**: [对齐文档](../docs/aisdk-architecture-alignment.md) §2(D17 五层)、§3.2(fetch 链)、§9.1;[RFC-0042](0042-v4-type-boundaries.md) T2(共享流处理留在 providers);[RFC-0044](0044-crate-layering-and-dependency-inversion.md)(crate 拆分与依赖反转,PR #279;`aimux-providers` 内部的目录和测试由本文定);[RFC-0045](0045-upstream-test-parity.md)(上游测试迁移,引用本文的测试位置和 `layout.toml`);[#275](https://github.com/arcships/aimux/issues/275)、[#276](https://github.com/arcships/aimux/issues/276)、[#278](https://github.com/arcships/aimux/issues/278)。 + +## 1. 决策摘要 + +| 编号 | 决定 | 后果 | +|---|---|---| +| V1 | provider 代码分三层:通用函数在 `aimux-provider-utils`;协议实现是一个模型加一个 `XxxConfig`(OpenAI Chat、OpenAI Responses、Anthropic Messages、Google generateContent、Bedrock Converse,加兼容层);厂商只是工厂、Config 的取值和 Fetch 层的鉴权(§3.1) | 每种协议只有一份实现;厂商之间的差异都落在 Config 字段和 Fetch 层 | +| V2 | 上游放在 provider-utils 的通用函数,我们也放在 `aimux-provider-utils`;厂商代码不再自带一份,也不再去别的厂商目录拿 | 补齐 parseProviderOptions、流错误、响应元数据、推理强度映射等(§3.2) | +| V3 | 厂商之间只允许单向依赖,只能用对方 `internal` 模块导出的东西(对应上游 `@ai-sdk//internal`),CI 检查 | 解开现有 7 个环(§3.3) | +| V4 | 托管变体复用协议实现,只换配置:Anthropic 的模型 + `AnthropicConfig`,Google 的模型 + `GoogleConfig`,兼容层 + `OpenAICompatibleConfig`;类型名和字段名照上游 | Vertex Gemini 删掉复制的流处理和结果解析;AWS、Vertex 上的 Claude 不再有自己的模型(§3.4) | +| V5 | 厂商之间内部用的请求体、URL 钩子是封闭枚举,每个变体是一种已知改写,枚举和改写函数都在协议目录,由 `match` 分发,协议目录不 `use` 厂商;用户在 10 个公开设置里传的 `transform_request_body` 回调保留,在协议 Config 里有自己的字段;鉴权和签名走 Fetch 层 | 内部改写可以打印、比较、写进录制,绑定和 preset 按名字选;公开设置不变(§3.5) | +| V6 | 有独立上游包的厂商(Groq、DeepSeek、Mistral、Cohere、xAI、HuggingFace、Open Responses)保留自己的转换代码,和上游一样 | 不为了去重把它们硬并进兼容层或 OpenAI(§3.7) | +| V7 | 模态和搜索厂商的机械重复抽成公共结构和函数;各家的请求构造、响应解析不抽 | 约 2,500 行重复,抽掉其中 10 类完全重复(§3.6) | +| V8 | 按这套分层落目录:每个厂商一个目录 `src//`,`mod.rs` 只做声明和导出(对应上游 `index.ts`);有上游包的厂商文件与上游包逐个对应,命名规则固定(§4.2) | 文件和测试能按名字对照上游;现有的公开子模块路径会变,随下一个 minor 版本发布(§4.5) | +| V9 | 上游有、我们没实现的文件不建空文件,在 `layout.toml` 里记为未实现并挂 issue | 缺口可见,不制造占位代码 | +| V10 | 每个厂商一个测试目录 `src//tests/`,一个上游测试文件对应其中一个同名 Rust 文件,直接调内部函数的和走 provider + mock fetch 的都放这里;crate 的 `tests/` 只留跨厂商的测试 | 上游测试能逐个对照,一家厂商的测试只在一处,和代码一起搬、一起删;厂商测试都编进 lib 的一个测试程序 | +| V11 | 本文不拆 crate、不加按厂商的 cargo feature,厂商仍在一个 `aimux-providers` 里,以后再考虑;参考做法是只有厂商要引入很重的第三方库时才单独成 crate(Rig 的做法:27 个厂商在 rig-core,只有用 AWS SDK、Google Cloud SDK、gRPC、本地推理的拆出去) | 48 个厂商实现中文本模型占约 5.8 万行,多模态和搜索 31 家只有约 1.4 万行,按厂商开关省不了多少体积 | + +## 2. 现状 + +### 2.1 协议实现和谁在用 + +48 个厂商实现跑在几种协议上,同一种协议现在有多份实现: + +| 协议 | aimux 的实现 | 跑在上面的 | 上游 | +|---|---|---|---| +| OpenAI Chat Completions | `openai/`(chat)、`openai_compatible/` | Azure(经 OpenAI);282 个 preset、Vertex 上的合作模型(`vertex/partner_provider.rs:215`,经兼容层);Groq、DeepSeek、Mistral、Cohere 各写一份 | `@ai-sdk/openai`、`@ai-sdk/openai-compatible`;Groq 等是独立包,各写一份 | +| OpenAI Responses | `openai/responses/` | Azure、Codex(`codex.rs:56-60` 直接用 OpenAI 的私有模块);另有 `open_responses.rs`(1,430 行)、`xai/responses/`(2,314 行)、`huggingface/responses.rs`(1,157 行)各一份 | `@ai-sdk/open-responses`、`@ai-sdk/xai`、`@ai-sdk/huggingface` 也各自一份,只依赖 provider 和 provider-utils | +| Anthropic Messages | `anthropic/` | Anthropic AWS(`anthropic_aws/mod.rs:40-44`)、Vertex 上的 Claude;Bedrock 借用它的工具准备和 schema 清洗(`bedrock/tools.rs:67`、`bedrock/convert.rs:1348`) | 托管变体走 `@ai-sdk/anthropic/internal` | +| Google generateContent | `google/` | Vertex 上的 Gemini:流处理和结果解析整份复制(§2.4) | `@ai-sdk/google-vertex` 复用 `@ai-sdk/google` 的模型 | +| Bedrock Converse | `bedrock/` | — | `@ai-sdk/amazon-bedrock` | +| 各家自己的协议 | 31 个模态与搜索厂商 | — | 有包的照包 | + +两条调用栈能看出现在的问题。基础厂商去别的厂商私有文件拿通用函数(Mistral): + +```text +aimux_providers::mistral::create_mistral aimux-providers/src/mistral/mod.rs:161 + └ MistralProvider::language_model aimux-providers/src/mistral/mod.rs:270 + └ MistralModel::do_generate aimux-providers/src/mistral/model.rs:176 + └ build_request_body aimux-providers/src/mistral/convert.rs:225 + └ convert_prompt_to_mistral_messages aimux-providers/src/mistral/convert.rs:105 + └ crate::openai::convert::tool_result_to_content + aimux-providers/src/mistral/convert.rs:167 → openai/convert.rs:512 ← 去 OpenAI 的私有文件拿 + └ aimux_provider_utils::post_json_to_api aimux-providers/src/mistral/model.rs:180 +``` + +上游 `@ai-sdk/mistral` 只依赖 `@ai-sdk/provider` 和 `@ai-sdk/provider-utils`,工具结果在 `convert-to-mistral-chat-messages.ts:167` 自己转换。 + +协议实现反过来认识托管变体(Vertex 上的 Claude): + +```text +Vertex provider 建 Claude 模型 aimux-providers/src/vertex/anthropic_provider.rs:85 + └ vertex::anthropic_model::model aimux-providers/src/vertex/anthropic_model.rs:63 + └ AnthropicEndpoint::Vertex(resolver) aimux-providers/src/vertex/anthropic_model.rs:86 + └ AnthropicModel::do_generate aimux-providers/src/anthropic/model.rs:152 + └ prepare → config.resolved() aimux-providers/src/anthropic/model.rs:111 → :116 + └ AnthropicModelConfig::resolved aimux-providers/src/anthropic/config.rs:156 + └ match AnthropicEndpoint::Vertex aimux-providers/src/anthropic/config.rs:160 + └ crate::vertex::Publisher::Anthropic aimux-providers/src/anthropic/config.rs:162 ← Anthropic 认识 Vertex(环) +``` + +上游托管变体走协议包的 `/internal` 入口,协议包不认识变体:`amazon-bedrock/src/anthropic/amazon-bedrock-anthropic-provider.ts:17` 从 `@ai-sdk/anthropic/internal` 取模型。 + +### 2.2 厂商之间的引用 + +上游每个厂商包只依赖 `@ai-sdk/provider` 和 `@ai-sdk/provider-utils`;跨包复用只有托管变体,而且走对方的 `/internal` 入口(`amazon-bedrock` 用 `@ai-sdk/anthropic/internal`)。Groq、Mistral 的消息转换与 OpenAI 的结构相似度只有 0.44、0.46,各写各的。 + +我们反过来,厂商之间直接引用对方的私有文件: + +| 被引用 | 引用方 | 性质 | +|---|---|---| +| `openai/convert.rs:512` `tool_result_to_content`、`:527` `tool_result_content_value` | Open Responses、xAI、兼容层、Mistral、Groq、DeepSeek、Cohere、Google | 通用函数放错了地方 | +| `openai/convert.rs:656` `parse_chat_provider_options`、`:636` `parse_option_fields` | Groq、DeepSeek、Anthropic | 上游的 `parseProviderOptions`,应在 provider-utils | +| `openai/model.rs` `list_models_once` | Codex、HuggingFace | 应在 `shared/discovery.rs` | +| `google/utils.rs` `GoogleJsonAccumulator` | Vertex | 合理复用,但要走导出入口 | +| `openai/tools.rs` `AzureOpenAITools` | Azure | Azure 专属类型放在了 OpenAI 里 | +| Anthropic、Google、OpenAI、兼容层的模型和配置 | AWS、Vertex、Bedrock、Azure、Codex、HuggingFace | 合理复用(上游也这样),要走导出入口 | + +七个环: + +| 环 | 证据 | +|---|---| +| OpenAI ↔ Azure | `openai/config.rs:24` 用 `azure::UrlRules` | +| OpenAI ↔ 兼容层 | `openai/completion.rs:5` 把 completion 交给兼容层;兼容层 `completion.rs:25,201,479,573` 用 OpenAI 的配置、错误处理、流错误 | +| 兼容层 ↔ preset | `openai_compatible/config.rs:27` 用 `PresetDescriptor`;`preset.rs:39-41` 用兼容层 | +| OpenAI ↔ decision_support | `openai/decision.rs:19,150,470`;`decision_support.rs:78-79` | +| 兼容层 ↔ runtime_decision | `openai_compatible/mod.rs:342-346`;`runtime_decision.rs:14` | +| Anthropic ↔ Vertex | `anthropic/config.rs:50,160-163` 用 `vertex::Resolver`、`Publisher` | +| Anthropic ↔ Anthropic AWS | `anthropic/config.rs:54,68,172-179` 用 `Auth`、`AwsHeaders` | + +另外 `stream/chat_tool_calls_tests.rs` 引用了 4 个厂商,公共层的测试依赖上层;生产代码里 `stream/`、`shared/` 不引用厂商。 + +### 2.3 provider-utils 的缺口 + +上游 provider-utils 导出、而我们没有公共实现的: + +| 上游 | 我们现在 | +|---|---| +| `parseProviderOptions` | 约 25 处各自解析,3 家借 OpenAI 的 | +| `createProviderStreamError` | 9 个厂商各写,15 处直接拼 `StreamPart::Error` | +| `createLanguageModelResponseMetadata` | 15 个文件 16 次手拼 | +| `mapReasoningToProviderEffort` / `Budget` | 只在 `anthropic/convert.rs:1751`、`:1813`;Cohere `convert.rs:429` 抄了同一张表 | +| `resolveProviderReference` | `openai/convert.rs:255`、`xai/convert.rs:48`、`anthropic/convert.rs:1521` 各一份 | +| `convertToBase64`、`convertImageModelFileToDataUri` | 约 35 个文件直接用 base64 库,18 处手拼 data URL | +| 流式工具调用参数追踪 | 在 `aimux-providers/src/stream/`(RFC-0042 T2 决定留在 providers) | +| `ToolNameMapping` | 只在 `anthropic/tool_name_mapping.rs:62` | +| `isValidHostnamePart`、`loadOptionalSetting` + `validateBaseURL`、凭据、UA | 在 `aimux-providers/src/shared/mod.rs:35-300` | + +### 2.4 托管变体 + +| 变体 | 和基础模型的差异 | 现状 | +|---|---|---| +| Anthropic AWS | 9 处:区域 URL、区域校验、key 或 SigV4、工作区 header、UA、名字、无 URL 来源、无 files/skills | 已复用 Anthropic 模型,但靠反向引用(环) | +| Vertex 上的 Claude | 10 处;只有 Google 令牌签发是独立代码(`vertex/auth*.rs`) | 同上 | +| Bedrock Converse | 信封、消息转换、AWS 二进制事件流都不同 | 独立实现;`bedrock/convert.rs:858-872`、`:961-975` 抄了 Anthropic 的能力表 | +| Vertex 上的 Gemini | 11 处 | 流处理(`vertex/model.rs:244-677` 对 `google/model.rs:256-610`)和结果解析(`:819-1028` 对 `:724-939`)整份复制且已走样;ID 规则不同;用量计算、端点拼接、embedding、video 各有一份 | + +`AnthropicAwsFetch`(`anthropic_aws/model.rs:210-232`)和 `BedrockAuthFetch`(`bedrock/mod.rs:364-395`)包的是同一个 `SigV4Fetch`,重复的是外面那一层:加各自包名的 UA,再按 header 决定签不签(AWS 看 `x-api-key`,Bedrock 看 `Authorization`)。 + +### 2.5 模态与搜索厂商 + +31 个厂商(21 个双形态 + 10 个单文件)里的重复,按行数: + +| 重复 | 厂商数 | 约行数 | +|---|---|---| +| 厂商工厂:设置、Debug、create、默认实例、模型配置 | 31 | 900 | +| `options.rs` 只有 13 行,除名字外完全一样 | 20 | 260 | +| 异步任务的提交、轮询、下载 | 8 | 250 | +| 搜索请求的头尾和结果映射 | 11 | 200 | +| 错误体 `{"error":{message, code}}` 解析(6 个一字不差) | 12 | 150 | +| 响应尾部 headers、body、时间戳 | 29 | 150 | +| `Warning::Unsupported` 手拼(全仓 147 处) | — | 100 | +| 任务失败错误 | 8 | 100 | +| 多模态 provider 的 no_such_model 桩 | 6 | 100 | +| 下载生成结果 URL(带 SSRF 校验),5 处相同 | 5 | 90 | +| 音频转字节、multipart 前缀 | 7 | 80 | +| 图片、视频转 URL 或 data URL | 5 | 70 | +| 透传 providerOptions(去掉轮询键) | 6 | 40 | +| 完全相同的小函数(`gcd`、宽高比、`resolve_count`) | 4 | 30 | + +各家的 `do_generate` 两两相似度只有 0.33–0.43,请求构造 0.43–0.49,这部分是真正的厂商差异。 + +### 2.6 目录 + +`aimux-providers/src` 有 37 个目录、44 个文件: + +| 形态 | 数量 | 例子 | +|---|---|---| +| 带 `mod.rs` 的厂商目录 | 14 | `openai/`、`anthropic/`、`google/`、`vertex/`、`bedrock/` | +| `.rs` + 只有 `options.rs` 的同名目录 | 21 | `fal.rs` + `fal/options.rs`、`elevenlabs.rs` + `elevenlabs/options.rs`;`huggingface/` 另有 `responses.rs` | +| 单文件厂商 | 13 | `codex.rs`、`jev.rs`、`tavily.rs`、`serper.rs`、`open_responses.rs` | +| 共用基础设施 | 2 | `shared/`、`stream/` | +| 非厂商顶层文件 | 9 | `catalogue.rs`、`default_providers.rs`、`preset.rs`、`provider_registry.json`、`replay.rs`、`decision_support.rs`、`decision_scoring.rs`、`runtime_decision.rs`、`systemone.rs` | + +文件名用通用名(`model.rs`、`convert.rs`、`types.rs`),不对应上游文件。282 个 preset 是 `provider_registry.json` 里的数据行,跑在兼容层上。 + +### 2.7 与上游对照 + +上游不是每个包文件数相同,而是命名规则固定、一个模型类一个文件、测试紧挨源文件: + +| 规则 | 出现的包数 | +|---|---| +| `-provider.ts`、`*-options.ts`、`version.ts`、`index.ts` | 13 / 13 | +| `-error.ts`、`*-api-types.ts` 或 `*-api.ts` | 12 / 13 | +| `-chat-language-model.ts` / `-language-model.ts` | 有文本模型的全部 | +| `convert-to--*.ts`、`*prepare-tools.ts` | 有文本模型的全部 | +| 其余模态:每种模型一个 `-<模态>-model.ts` + `-options.ts` | 有该模态的全部 | +| 测试 `foo.test.ts` 与 `foo.ts` 同目录 | 13 个包里 6 个全部如此,其余只有 1–5 个跨文件场景测试例外 | + +13 个同名上游包逐文件对照(上游不计 `index.ts`、测试、`*.test-d.ts`、fixtures): + +| 厂商 | 上游文件 | aimux 文件 | 有对应 | aimux 文件要拆 | 上游有我们没有 | aimux 独有 | +|---|---|---|---|---|---|---| +| openai | 77 | 21 | 66 | 13 | 11 | 2 | +| google | 64 | 12 | 39 | 10 | 25 | 0 | +| anthropic | 44 | 14 | 43 | 7 | 1 | 0 | +| xai | 39 | 9 | 32 | 7 | 7 | 1 | +| amazon-bedrock | 32 | 12 | 30 | 6 | 2 | 1 | +| mistral | 22 | 8 | 17 | 5 | 5 | 0 | +| groq | 17 | 11 | 17 | 4 | 0 | 0 | +| fal | 17 | 2 | 14 | 2 | 3 | 0 | +| cohere | 15 | 7 | 15 | 3 | 0 | 0 | +| deepseek | 15 | 9 | 12 | 3 | 3 | 0 | +| deepgram | 12 | 2 | 8 | 2 | 4 | 0 | +| elevenlabs | 12 | 2 | 12 | 2 | 0 | 0 | +| luma | 5 | 2 | 5 | 1 | 0 | 0 | +| 合计 | 371 | 111 | 310 | 65 | 61 | 4 | + +“上游有我们没有”主要是:Google Interactions(17 个文件)、各家 realtime 与 live、batch、speech-translation、xAI 和 DeepSeek 的文件接口、Mistral Conversation。其余 29 个厂商在上游没有包。 + +### 2.8 测试 + +`aimux-providers/tests/` 有 126 个文件,每个是一个独立的测试二进制:101 个能按前缀对应到厂商,8 个是 preset(alibaba、baseten、cerebras、deepinfra、fireworks、moonshotai、openrouter、perplexity),17 个是跨厂商场景(cassette、conformance、e2e)。另有 `tests/common/`(14 个文件 `mod common;`,22 个文件用 `#[path]` 引 `mock_fetch.rs`)、`tests/cassettes/`(2,799 个文件,176 MB,`Cargo.toml` 的 `exclude` 不打包)、`tests/fixtures/`、`tests/replay_fixtures/`。 + +搬进同一个测试二进制要处理的: + +- 18 个文件改环境变量,共 88 个 `#[serial]`; +- 已有的冲突:`bedrock_user_agent_test.rs:109`、`sigv4_signed_headers_test.rs:74` 断言 `AWS_BEARER_TOKEN_BEDROCK` 未设置,而 `bedrock_remaining_test.rs:394` 会设置它;`vertex_upstream_e2e_test.rs:443`、`:649` 读 `GOOGLE_*` 却没有 `#[serial]`; +- `xai/responses/mod.rs:44` 的 `SOURCE_ID_COUNTER` 依赖执行顺序;`replay_test.rs:214` 用固定临时路径; +- 181 个测试函数名在多个文件里重名(搬进各自目录后在不同模块里,不冲突)。 + +`src/` 里已有 19 个内联测试模块,`stream/chat_tool_calls_tests.rs` 是唯一的独立测试文件(复数 `_tests`)。 + +## 3. 抽象设计 + +### 3.1 三层 + +```text +厂商(工厂、Config 取值、鉴权 Fetch) vertex/、anthropic_aws/、azure/、codex/、preset 数据行 … + │ 只经过 internal,单向(§3.3) + ▼ +协议实现(模型 + XxxConfig) openai/(Chat、Responses)、openai_compatible/、anthropic/、google/、bedrock/ + │ 独立上游包的厂商(Groq、Mistral、xAI …)各自是自己的协议实现 + ▼ +通用函数(aimux-provider-utils) parse_provider_options、流错误、响应元数据、工具结果转换、SigV4 … +``` + +改完后同一次调用的样子: + +```text +Mistral:MistralModel::do_generate → convert_prompt_to_mistral_messages + └ aimux_provider_utils::tool_result_to_content ← 通用函数,不再进 openai/ + +Vertex 上的 Claude:vertex 工厂 + └ AnthropicModel + AnthropicConfig { endpoint: Arc, build_request_url: VertexRawPredict, … } + └ vertex 实现 ResolveEndpoint;Google 令牌在 Fetch 层 ← Anthropic 不再认识 vertex::Resolver、Publisher +``` + +一个厂商要么是协议实现(有自己的模型),要么是托管变体(只有工厂和 Config 取值),不出现“半个模型复制一份”的中间形态。 + +### 3.2 provider-utils 补齐 + +| 加入 `aimux-provider-utils` | 取代 | +|---|---| +| `parse_provider_options` | 各家手写解析、`openai/convert.rs:636,656` | +| `create_provider_stream_error` | 9 个厂商的流错误构造;各家只留状态码表 | +| `create_language_model_response_metadata` | 16 处手拼 | +| `map_reasoning_to_provider_effort`、`map_reasoning_to_provider_budget` | Anthropic 和 Cohere 的两份表 | +| `resolve_provider_reference` | 3 份 | +| `convert_to_base64`、`convert_image_model_file_to_data_uri` | 直接调用 base64 库和手拼 data URL | +| `tool_result_to_content` 一族 | `openai/convert.rs:512,527` | +| `ToolNameMapping` | `anthropic/tool_name_mapping.rs` | +| 主机名校验、base URL 设置、凭据、UA 组装 | `shared/mod.rs:35-300` | + +名字是上游函数的 snake_case。provider-utils 的死代码一并删除:`headers.rs:38` `with_user_agent_suffix_fn`、`url.rs:16` `without_trailing_slash_opt`(只有测试用)。两者都是 provider-utils 的公开 API(`headers` 是 `pub mod`,`without_trailing_slash_opt` 在 `lib.rs:68` re-export),删除算破坏性变更,登记见 §4.5。 + +### 3.3 协议实现之间的依赖 + +1. 每个被别家复用的厂商有一个 `internal` 模块,只 `pub use` 允许别家用的东西,对应上游 `@ai-sdk//internal`。别家只能 `use crate::::internal::...`。 +2. 允许的方向只有托管变体到基础厂商:Azure → OpenAI,Codex → OpenAI,HuggingFace → OpenAI,Amazon Bedrock → Anthropic,Anthropic AWS → Anthropic,Google Vertex → Google / Anthropic / 兼容层,preset → 兼容层。其余厂商之间不允许引用。 +3. 七个环的解法: + +| 环 | 解法 | +|---|---| +| OpenAI ↔ Azure | `UrlRules` 移进 OpenAI 的配置,Azure 只填值 | +| OpenAI ↔ 兼容层 | OpenAI 照上游自己写 completion(上游 openai 包有自己的 `completion/`);错误处理、流错误下沉到 provider-utils(§3.2) | +| 兼容层 ↔ preset | preset 产出 `OpenAICompatibleConfig`,兼容层不认识 preset | +| OpenAI ↔ decision、兼容层 ↔ runtime_decision | decision 代码依赖一个小接口(发请求所需的端点和 headers),OpenAI 和兼容层实现它 | +| Anthropic ↔ Vertex、Anthropic ↔ Anthropic AWS | 引入 `AnthropicConfig`,先只放 `endpoint: Arc`(`shared/exchange.rs:141-144` 已有,Vertex Gemini、Bedrock、Polly 在用),Vertex 和 AWS 各实现一个;删掉与 `EndpointHeaders` 重复的 `AnthropicHeaders::Aws`(`anthropic/config.rs:61-69`)。Config 的其余字段在 R3 加(§6) | + +4. CI 检查(扩展已在 CI 里的 `scripts/check_provider_boundaries.sh`,`ci.yml:714`):厂商之间的 `use` 只能指向第 2 条允许的方向且只能经过 `internal`;`shared/`、`stream/`、`test_support/` 不引用厂商。脚本里现有的 `|| true` 会让改名后的路径静默通过,改成路径不存在即失败。 + +### 3.4 托管变体复用协议实现 + +| 变体 | 做法 | +|---|---| +| Anthropic AWS | Anthropic 的模型 + `AnthropicConfig`:`build_request_url` 指向区域端点,SigV4 或 key 在 Fetch 层 | +| Vertex 上的 Claude | Anthropic 的模型 + `AnthropicConfig`:`build_request_url` 选 `:rawPredict` 路径、`body_rewrite` 选去掉 `model` 加 `anthropic_version` 的改写、不支持原生结构化输出和 strict tools;Google 令牌在 Fetch 层 | +| Vertex 上的 Gemini | 删掉 `vertex/model.rs` 里复制的流处理和结果解析,改用 Google 的模型 + `GoogleConfig`:端点解析、命名空间、一张字段剥离表;`sharedRequestType` 等 header 和 `streamFunctionCallArguments` 是 Config 字段。ID 生成改用注入的 `generate_id`(现在是计数器),这会改变 Vertex 输出的 ID,登记为修正 | +| Bedrock Converse | 另一种协议,保持独立;能力表改为调用 Anthropic 的 `get_model_capabilities`(`anthropic/convert.rs:1674-1720`) | +| Azure | OpenAI 的模型 + 配置,和上游一样 | + +两个外层合成一个,放在 provider-utils(和 `sigv4_fetch.rs` 一起),包名和“带哪个 header 就不签”是两个参数。 + +### 3.5 Config 的写法 + +类型名和字段名照上游:`OpenAICompatibleConfig`(`include_usage`、`supports_structured_outputs`、`error_structure`、`metadata_extractor`、`transform_request_body`)、`AnthropicConfig`(`build_request_url`、`transform_request_body`、`transform_request_headers`、`supports_native_structured_output`、`supports_strict_tools`;上游的 `transform_request_body` 是厂商之间用的,在这里叫 `body_rewrite`,见下文)、`GoogleConfig`(`provider`、`base_url`、`headers`、`fetch`、`generate_id`、`supported_urls`)。aimux 自己加的字段放进同一个 Config。 + +上游这些钩子是回调函数,分两种: + +- 用户能填的:上游只有兼容层,`createOpenAICompatible` 的设置里可以传 `transformRequestBody`、`metadataExtractor`、`supportedUrls`、`convertUsage` 这些函数,Rust 版 `OpenAICompatibleProviderSettings`(`openai_compatible/mod.rs:64`)照样开放。aimux 还在另外 9 个公开设置里加了同一个 `transform_request_body: Option`(aimux 扩展),共 10 个,全部保留,回调在协议 Config 里有自己的字段,原样调用: + + | 公开设置 | 字段位置 | 回调现在落到 | 改完后 | + |---|---|---|---| + | `OpenAICompatibleProviderSettings` | `openai_compatible/mod.rs:93` | 兼容层模型配置 | 兼容层 Config 的 `transform_request_body` | + | `PresetSettings` | `preset.rs:124` | 经兼容层,同上 | 同上 | + | `AnthropicAwsProviderSettings` | `anthropic_aws/mod.rs:106` | Anthropic 模型配置(`anthropic/config.rs:135`) | `AnthropicConfig.transform_request_body`(R3) | + | `VertexProviderSettings` | `vertex/mod.rs:113` | Vertex Gemini(`vertex/mod.rs:436`)和 Vertex 上的 Claude(`vertex/anthropic_provider.rs:89`) | `GoogleConfig.transform_request_body`(R4)、`AnthropicConfig.transform_request_body`(R3) | + | `CodexProviderSettings` | `codex.rs:141` | OpenAI 配置(`openai/config.rs:85`) | 不变 | + | `AmazonBedrockProviderSettings` | `bedrock/mod.rs:203` | Bedrock 自己的模型 | 不变 | + | `ElevenLabsProviderSettings`、`HuggingFaceProviderSettings`、`OpenResponsesProviderSettings`、`VoyageProviderSettings` | `elevenlabs.rs:113`、`huggingface.rs:76`、`open_responses.rs:123`、`voyage/mod.rs:79` | 各自的模型 | 不变 | + + 绑定传不了函数(`default_providers.rs:141,157` 对它报不支持),只能用 preset 里按名字选的改写。把这些回调也删掉是 §9 的另一种做法。 +- 厂商之间内部用的:Anthropic、Google 的 `transformRequestBody`、`buildRequestUrl` 等只给上游自己的 Bedrock、Vertex 包用,用户设置里没有(上游 `AnthropicProviderSettings` 只开放 `baseURL`、`apiKey`、`authToken`、`headers`、`fetch`、`generateId`、`name`)。这一类我们写成封闭枚举,每种协议一个,放在协议实现的目录里: + +```rust +// anthropic/request_rewrite.rs(aimux 扩展) +pub enum AnthropicBodyRewrite { + VertexRawPredict, // 去掉 model,加 anthropic_version + Bedrock, +} + +pub enum AnthropicRequestUrl { + Messages, // {base_url}/messages + VertexRawPredict, // …/{model}:rawPredict 或 :streamRawPredict + AwsRegional, // aws-external-anthropic.{region}.api.aws/v1/messages +} + +pub struct AnthropicConfig { + pub provider: String, + pub endpoint: Arc, + pub build_request_url: AnthropicRequestUrl, + pub body_rewrite: Option, // 厂商之间:Vertex、Bedrock 选一种 + pub transform_request_body: Option, // 用户在公开设置里传的回调 + pub supports_native_structured_output: bool, + pub supports_strict_tools: bool, +} + +// 模型里先跑内部改写,再跑用户回调,和现在的 transform_body +//(anthropic/config.rs:285-293,先 hooks.prepare_body 再 transform_request_body)顺序一样 +if let Some(rewrite) = &config.body_rewrite { + body = request_rewrite::apply(rewrite, body); +} +if let Some(callback) = &config.transform_request_body { + body = callback(body); +} + +// openai_compatible/request_rewrite.rs(aimux 扩展) +pub enum CompatibleBodyRewrite { VertexMaas, VertexXai } +``` + +改写的是协议自己的请求体,所以函数和枚举都在协议目录,Vertex、Bedrock 只选变体,依赖方向仍是托管变体到协议实现(§3.3 第 2 条),协议目录不 `use` 任何厂商。上游 `AnthropicConfig.transformRequestBody` 是厂商之间用的回调,对应这里的 `body_rewrite`;同名的 `transform_request_body` 留给用户回调,因为公开设置里已经叫这个名字。 + +兼容层的 Config 同样是两个字段:`body_rewrite: Option` 给 Vertex 上的合作模型(它们现在借公开设置的回调字段传进去,`vertex/partner_provider.rs:219,310`),`transform_request_body` 只放用户传的回调。Google 只有用户回调(Vertex Gemini 从 `VertexProviderSettings` 带来),`GoogleConfig` 只加 `transform_request_body`。 + +枚举不叫 `TransformRequestBody`:这个名字已经是公开的回调别名 `Arc Value + Send + Sync>`(`shared/mod.rs:75`,在 `openai`、`openai_compatible`、`anthropic`、`anthropic_aws`、`codex`、`huggingface`、`voyage` 7 个模块 re-export),照旧给用户回调。 + +端点解析 `ResolveEndpoint` 保留为接口,因为它要在请求时读区域、项目和令牌,这是行为不是配置。 + +鉴权、签名不进 Config,在 Fetch 链里(对齐文档 §3.2 的 auth fetch 位置)。 + +### 3.6 模态与搜索厂商 + +抽成公共部分: + +| 抽成 | 取代 | +|---|---| +| 厂商工厂的公共结构和宏(设置、Debug、默认实例、模型配置),扩展已有的 `impl_single_modality_provider!` 到多模态 | 31 个厂商的工厂代码、6 个多模态的 no_such_model 桩 | +| 一个读命名空间选项的函数 | 20 个只有 13 行的 `options.rs`(删掉) | +| 搜索请求函数(端点、请求、错误、结果映射) | 11 个搜索厂商的头尾 | +| 标准错误体解析 | 12 个厂商的同形闭包 | +| 异步任务执行器(提交、轮询、下载),轮询参数是数据 | 8 个厂商的任务代码和各自声明的轮询常量 | +| 生成结果下载(走 RFC-0044 的单一下载路径) | 5 处相同代码 | +| `Warning::unsupported`、任务失败错误、响应尾部的构造函数 | 147 处手拼告警等 | +| 音频、图片、视频输入上的转换方法 | 12 处 | +| 完全相同的小函数合并 | `black_forest_labs.rs:230` 与 `stability.rs:250`、`black_forest_labs.rs:240` 与 `stability.rs:245`、`you_com.rs:178` 与 `tinyfish.rs:177` | + +### 3.7 不抽象的 + +- 各厂商的消息转换、finish reason、用量、工具准备、选项结构、响应结构、请求构造,和上游一样各写各的。 +- Groq、DeepSeek、Mistral、Cohere 保留自己的目录和代码。逐项比较后只有 Groq 的差异全部能写成配置(8 项数据、1 项钩子),但上游是独立包、测试按上游文件对照,所以不并进兼容层;§3.2 下沉后它自然变薄。 +- xAI、HuggingFace 只有 Responses 实现,HuggingFace 的 `responses.rs`(1,165 行)和 OpenAI Responses 有 12 个同名函数,是否复用 OpenAI 的 Responses 实现在它自己的 PR 里比较后决定。 + +## 4. 目录与文件 + +按第 3 节的分层落目录。 + +### 4.1 厂商目录 + +1. 每个厂商一个目录 `src//`,`mod.rs` 只做 `mod` 声明和 `pub use`,对应上游 `index.ts`。 +2. 厂商目录名保留现有名字(`bedrock/`、`vertex/` 等),不改成上游包名:目录名就是 Rust 的公开模块路径(`aimux_providers::bedrock::…`),改名是破坏性变更,换来的只是名字一致。对应哪个上游包写在 `layout.toml` 里。上游包内的子目录照建,如上游 `amazon-bedrock/src/anthropic/` 对应 `bedrock/anthropic/`。 +3. 非厂商的顶层文件:`catalogue.rs`、`default_providers.rs`、`preset/`(`preset.rs` + `provider_registry.json`)留在顶层,它们是注册表和目录,不是厂商;`replay.rs` 按 RFC-0044 C6 移出;`systemone.rs`(Jev 的 wire 编解码)进 `jev/`;`decision_support.rs`、`decision_scoring.rs`、`runtime_decision.rs` 进 `decision/`,只依赖 §3.3 定义的小接口。 + +### 4.2 有上游包的厂商 + +1. 目录内文件与上游包逐个对应:kebab-case 换成 snake_case,去掉和目录重复的厂商前缀。`mistral-chat-language-model.ts` → `mistral/chat_language_model.rs`;`map-mistral-finish-reason.ts` → `mistral/map_finish_reason.rs`;`convert-to-mistral-chat-messages.ts` → `mistral/convert_to_chat_messages.rs`。 +2. 上游有子目录就照建(openai 的 `chat/`、`responses/`、`tool/`,google 的 `tool/`),任意深度。 +3. 去掉前缀后重名时保留区分词:deepgram、elevenlabs 都有 `-api-types.ts` 和 `speech-api-types.ts`,分别是 `api_types.rs`、`speech_api_types.rs`。 +4. 上游写在模型文件里的 zod 响应 schema,aimux 放进 `api_types.rs`(上游 12/13 的包本来就有这个文件)。 +5. `version.ts` 不建文件,用 crate 版本。 +6. 上游有、我们没实现的文件不建空文件,在 `layout.toml` 里记为 `missing` 并写 issue(#275 或 #276)。 +7. 上游的实现被我们放在公共层的(Bedrock 的 `sigv4-fetch` 在 provider-utils,OpenAI completion 若按 §3.3 仍由兼容层承担),在 `layout.toml` 里记为 `elsewhere` 并写路径。 +8. 上游没有、aimux 独有的文件(`list_models` 发现、SigV4 GET 签名等已登记扩展)和上游对应的文件放在同一目录,不另建子目录;文件第一行注明“aimux 扩展”和对应的登记差异,`layout.toml` 里记为 `aimux`。 + +### 4.3 没有上游包的厂商 + +29 个厂商没有上游包,整个目录都是 aimux 自己的。按模态命名:`provider.rs`、`<模态>_model.rs`、`<模态>_model_options.rs`、`api_types.rs`;没有内容的文件不建(§3.6 抽掉公共部分后,大部分厂商只剩 `provider.rs` 和一个模型文件)。 + +preset 仍是 `provider_registry.json` 里的数据行,不建目录;preset 的测试放在 `preset/tests/` 下(§5)。 + +### 4.4 `layout.toml` + +每个有上游包的厂商一个表,逐个列上游源文件: + +```toml +[mistral] +package = "@ai-sdk/mistral" # 版本以 VERSIONS.json 为准 + +[[mistral.files]] +upstream = "src/mistral-chat-language-model.ts" +rust = "mistral/chat_language_model.rs" +status = "mapped" # mapped | missing | elsewhere | not-applicable | aimux + +[mistral.files.tests] # RFC-0045 §3.2:一律是表;空表表示没有手写排除 +ts-only = [ + { case = "MistralChatLanguageModel › …", reason = "…" }, +] + +[[mistral.files]] +upstream = "src/mistral-conversation-language-model.ts" +status = "missing" +issue = 275 +``` + +`status` 决定这个文件的上游测试迁不迁(`mapped`、`elsewhere` 迁;`missing`、`not-applicable` 不迁);`mapped`、`elsewhere` 的条目必须有 `tests` 表,表里只列手写排除:`ts-only`、`registered`、`sub-feature` 三类,每条是 `{ case, reason }`,`registered` 另写 `ref`,`sub-feature` 另写 `issue`;single-step、telemetry 由 RFC-0045 的 `check.mjs` 按 API 名判定,不写进表。模块按比锁定版本更新的上游对齐时,条目另写 `version`(目前只有 `aimux` 那张表里的 decide,`ai@7.0.128`)。aimux 独有的文件也进表,记为 `status = "aimux"` 并写 `reason`(对应的登记差异);没有上游包的厂商不进表。`aimux-provider-utils` 和 `aimux` 照同样格式各有一张(RFC-0044 定目录,本文定格式)。 + +### 4.5 公开路径 + +本节管本文各步骤带来的公开路径变化。crate 根(`lib.rs` 的 `pub use`)和厂商模块根(`aimux_providers::mistral::MistralModel` 这类 `mod.rs` 里的 `pub use`)是文档里写的入口,R7 搬移后不变;10 个公开设置里的 `transform_request_body` 也不变(§3.5)。厂商目录里现在声明成 `pub mod` 的子模块有 55 个(基线 master@`5743a40a`,含嵌套:openai 14、anthropic 11、google 9、bedrock 5、xai 4、openai_compatible 3、cohere 3、voyage 2、mistral 2、vertex 1、huggingface 1),文件按上游改名后,`aimux_providers::openai::model::…`、`aimux_providers::mistral::convert::…` 这类路径会断。 + +1. 搬移 PR 里新文件的可见性照上游:上游 `index.ts` 导出的经厂商模块根 `pub use`;其余子模块是私有的 `mod`,别家要用的经 `internal`(§3.3)。 +2. 旧的子模块路径不留兼容 shim,算破坏性变更,随下一个 minor 版本发布(0.x 里 minor 就是破坏性版本);每个搬移 PR 在 CHANGELOG 的 Unreleased 里列出本家的旧路径和替代入口。workspace 里(ops、FFI、工具、两个绑定)用到旧路径的地方在同一个 PR 里改掉。 +3. 例外有两处,不属于 R7: + - R1 删除 provider-utils 的 `with_user_agent_suffix_fn`(`headers.rs:38`,经 `pub mod headers` 公开)和 `without_trailing_slash_opt`(`lib.rs:68` re-export),同样算破坏性变更,R1 的 PR 在 CHANGELOG 写替代写法:前者用 `with_user_agent_suffix`,后者用 `url.map(without_trailing_slash)`。 + - `aimux-providers` 的 `replay` 模块和它在 crate 根的 `rebuild_provider`、`rebuild_decision_provider`(`lib.rs:146-148`)按 RFC-0044 C6 移到 ops,在那份 RFC 里登记,本文不动。 +4. 另一种做法见 §9。 + +## 5. 测试位置 + +上游的测试和源文件在同一目录,一个源文件一个 `foo.test.ts`,直接调内部函数的和起假 fetch 走 provider 的都在这里。Rust 的 `tests/` 只能用公开 API,每个文件是一个单独链接的测试程序;上游测试大多测内部函数(消息转换、用量转换、finish reason、工具准备),这些只能放在 crate 里面,而走 mock fetch 的测试放在 crate 里面一样能从公开 API 测。参考的 Rust 项目:Rig 的单元测试是 `foo/tests.rs`(`#[cfg(test)] mod tests;`,CI 用 `cargo xtask check-test-layout` 强制),录制回放测试在 `rig-cassette/tests/<厂商>.rs`;genai、reqwest、axum 的单元测试写在源文件里,`tests/` 放走公开 API 的测试。本文不照 Rig 给每个源文件配一个只放 `tests.rs` 的目录,也不把同一家厂商的测试分到 `src/` 和 `tests/` 两处,而是每个厂商一个测试目录: + +```text +aimux-providers/src/ + test_support/mock_fetch.rs 共用的 mock fetch(现 tests/common/) + groq/ + mod.rs 末尾一行 #[cfg(test)] mod tests; + provider.rs + chat_language_model.rs 源文件里不写测试 + convert_to_chat_messages.rs + … + tests/ + mod.rs 只声明子模块 + chat_language_model.rs ← groq-chat-language-model.test.ts(provider + mock fetch) + convert_to_chat_messages.rs ← convert-to-groq-chat-messages.test.ts(直接调函数) + … + fixtures/ snapshots/ +aimux-providers/tests/ 只留跨厂商的测试(cassette 回放、conformance 等) +``` + +1. 每个厂商一个测试目录 `src//tests/`:`src//mod.rs` 末尾一行 `#[cfg(test)] mod tests;`,`tests/mod.rs` 只声明子模块;不用 `#[path]`,源文件里不写测试,已有的内联 `mod tests { … }` 搬进对应的测试文件。 +2. 一个上游测试文件对应 `tests/` 下一个 Rust 文件,名字与被测源文件相同:`mistral-chat-language-model.test.ts` 测 `src/mistral/chat_language_model.rs`,放在 `src/mistral/tests/chat_language_model.rs`。上游有子目录的照搬(`src/deepseek/tests/chat/convert_to_chat_messages.rs`)。直接调内部函数的、走 provider → 模型 → mock fetch 的都放这里,整包的厂商测试编进 lib 的一个测试程序。aimux 自己的厂商测试按被测文件放进同名文件,跨多个文件的场景测试用描述性名字(如 `upstream_e2e.rs`)。 +3. 测试模块不再是源文件的子模块,被测的私有函数改成 `pub(super)`,函数体不变。 +4. 测试数据照上游的 `__fixtures__/`、`__snapshots__/` 放进 `src//tests/fixtures/`、`snapshots/`;大的 cassette 仍留在 crate 根目录按路径读取。 +5. 测试共用的 mock fetch、回放 helper(现 `tests/common/`)移到 `src/test_support/`(`mock_fetch.rs` 等),`#[cfg(any(test, feature = "test-support"))]` 编译,正常构建不带,不进发布产物;`tests/` 下留下的跨厂商测试经自引用的 dev 依赖开启 `test-support` 使用同一份(与 RFC-0044 §3.3 第 4 条相同)。`tests/cassettes/` 原地不动,`exclude` 不变。 +6. `aimux-providers/tests/` 只留跨厂商的测试(cassette 回放、conformance、跨厂商 e2e);preset 的测试放在 `src/preset/tests/`。现有 `tests/` 里 101 个按厂商的文件逐个搬进对应厂商的 `tests/` 目录。 +7. 公共层 `stream/`、`shared/` 同样用自己的 `tests/` 目录(`stream/chat_tool_calls_tests.rs` 改成 `stream/tests/chat_tool_calls.rs`),且不引用厂商:其中用到 4 个厂商的用例移到 crate 的 `tests/`。 +8. 合进同一个测试程序前先去掉对进程环境的依赖:测试显式传设置,不靠 `set_var`;必须读环境变量的用例统一 `#[serial(env)]`。§2.8 列出的冲突、计数器和固定路径在搬移 PR 里修掉。 +9. 依赖录制、回放的测试(`decision_recording_test.rs`、`runtime_decision_test.rs`、`decision_failure_e2e_test.rs` 等)先随厂商搬;RFC-0044 C6 之后 providers 不能依赖 devtools,devtools 也不依赖 providers(RFC-0044 §8 第 1 条),届时移到 ops 的集成测试(ops 两者都依赖)。RFC-0048([PR #325](https://github.com/arcships/aimux/pull/325))计划把 `aimux-providers/tests/common/replay.rs` 改成用 `ReplaySession` 当 provider 的 `fetch`,同样要放在 ops:`tests/common/` 按第 5 条移到 `src/test_support/`,而 `ReplaySession` 在 devtools。 + +迁移不改断言、不删测试,但不是“只搬不改”:原来每个测试文件是单独的进程,搬进 lib 的同一个测试程序后,环境变量、全局计数器、固定的临时路径变成和别家测试共享。每个搬移 PR 在描述里列出本家碰到的这几类和处理方式(第 8 条、§2.8)。每个 PR 前后比较 `cargo test -p aimux-providers -- --list` 的测试数量(模块路径会变,比数量和函数名多重集,不比完整路径),cassette 文件数不变。 + +## 6. 实施顺序 + +每一步一个 PR,只动 `aimux-providers` 和 `aimux-provider-utils`,合入时 workspace 全绿;搬移类 PR 测试数量前后相等。 + +| 步骤 | 内容 | 前置 | +|---|---|---| +| R1 | provider-utils 补齐通用函数(§3.2),各厂商改用;删除死代码 | 无 | +| R2 | 解开七个环(§3.3 第 3 条),其中两个 Anthropic 的环靠引入只有 `endpoint` 字段的 `AnthropicConfig`;加 `internal` 模块和 CI 检查 | R1 | +| R3 | 托管变体:`AnthropicConfig` 补齐其余字段(§3.5),URL 和请求体改写一开始就是枚举(`AnthropicRequestUrl`、`AnthropicBodyRewrite`),用户回调进 `transform_request_body`;Anthropic AWS、Vertex Claude 的 URL、请求体改写、能力开关改用它;两个 SigV4 外层合并;Bedrock 能力表 | R2 | +| R4 | Vertex Gemini 改用 Google 模型 + `GoogleConfig`(行为修正,单独评审) | R2 | +| R5 | 兼容层的钩子:Vertex 上合作模型的两种改写从公开的回调字段挪到 `body_rewrite`(`CompatibleBodyRewrite`,§3.5);Anthropic 的在 R3、Google 的在 R4 已经按 §3.5 写,这一步不再改它们 | R2 | +| R6 | 模态与搜索厂商去重(§3.6) | R1 | +| R7 | 目录、文件名、测试位置(§4、§5),`layout.toml`,按厂商家族分批:OpenAI 一族与兼容层、Anthropic、Google、Bedrock、其余有上游包的、没有上游包的与 preset | 可与 R1–R6 交错;同一厂商先去重再搬,避免搬两次 | + +和其他工作的关系: + +- #268 只改 providers 的 2 个文件,不冲突。 +- RFC-0044:R1 里依赖 core 下载和重试的部分(生成结果下载、重试退避)等 RFC-0044 C3、C4;其余不依赖。RFC-0044 的 C2/C3 改 import 时对着本文整理好的目录一次改完。 +- RFC-0045:测试位置和 `layout.toml` 的 `tests` 字段按本文;搬移测试时同时把出处注释改成它定义的标记。 + +## 7. 文档同步与登记差异 + +- RFC-0044 已把 provider 目录与测试部分移出(其 §2.4、§3 写“不在本文范围”),本文合入时 RFC-0044 的这几处改为引用本文。 +- 对齐文档 §9.1 的顺序是“core 类型 → provider 工厂与请求层 → 厂商包”;本文的整理不依赖 core 拆分,可以先做,登记这一顺序差异。 +- 对齐文档引用 `stream/chat_tool_calls_tests.rs` 的地方改成 `stream/tests/chat_tool_calls.rs`。 +- `docs/contributing/adding-a-provider.md` 按 §4、§5 重写。 +- ROADMAP 里“tracker 移入 aimux-provider-utils(#204)”一行已被 RFC-0042 T2 取代,同步删除。 +- 登记:Vertex Gemini 的 ID 生成和流处理改为与 Google 一致(§3.4);厂商之间内部用的 Config 钩子是枚举而不是回调(§3.5)。 + +## 8. 验收 + +1. 每个厂商一个目录;`src/` 顶层只剩 `lib.rs`、`catalogue.rs`、`default_providers.rs`、`preset/`、`decision/`、`shared/`、`stream/`、`test_support/` 和厂商目录。 +2. `layout.toml` 覆盖锁定版本里每个上游源文件,CI 校验:表里的 Rust 文件存在、厂商目录里每个 `.rs` 都在表里(对应上游文件,或记为 `aimux`)、表里的上游文件在锁定版本的发布包里存在(CI 用 `scripts/fetch-aisdk.sh` 取包)。 +3. `check_provider_boundaries.sh` 通过:没有厂商之间的环,跨厂商引用只经过 `internal`。 +4. §2.3 列出的函数在 provider-utils 里各只有一份,厂商目录里没有同名实现。 +5. 厂商测试都在 `src//tests/`,一个上游测试文件对应一个同名 Rust 文件,源文件里没有测试,没有 `#[path]`;`aimux-providers/tests/` 只剩跨厂商的测试、`cassettes/`、`fixtures/`、`replay_fixtures/`;测试数量与搬移前相等;cassette 文件数不变。 +6. 产物体积不超过 RFC-0043 的当前值。 + +## 9. 开放问题 + +- **厂商目录名(待讨论)。** 本文的方案是保留现有名字(`bedrock/`、`vertex/` 等,§4.1 第 2 条),对应的上游包写在 `layout.toml`。另一种做法是改成上游包名(`amazon_bedrock/`、`google_vertex/`),名字完全一致,但公开模块路径会变。 +- **旧子模块路径(待讨论)。** 本文的方案是不留 shim,作为破坏性变更随下一个 minor 版本发布(§4.5)。另一种做法是每家留一个 `#[deprecated]` 的同名 `pub mod`,只 `pub use` 新位置的内容,保留一个 minor 版本后删除;代价是 55 个过渡模块和一次之后的删除。 +- **Config 钩子的写法(待讨论)。** 本文的方案是厂商之间内部用的改写写成封闭枚举(`body_rewrite`、`build_request_url`),10 个公开设置里的用户回调保留(§3.5)。另外两种做法:一是全部保留回调,和上游写法一致,但配置不能打印、比较、写进录制,绑定也选不了;回调可以由 Vertex、Bedrock 自己的目录提供,也不会形成反向边。二是公开设置里的回调也删掉,用户改用自定义 fetch 或中间件改请求体;10 个字段和 7 处 `TransformRequestBody` re-export 删除,算破坏性变更,按 §4.5 第 2 条随下一个 minor 版本发布。 +- **扫描中发现的行为偏差**由单独的线程核实和修复,不放进搬移 PR。原列 6 处,其中 xAI 拒绝文本、Anthropic 显式 `outputFormat` 两处核对后与上游一致,不是偏差;剩下 4 处:HuggingFace 丢掉 `ToolChoice::None`、Vertex 上的 Grok 用量 total、preset 强制 `supports_structured_outputs`、Vertex Gemini 的 ID 规则(§3.4)。 diff --git a/rfc/0047-error-cause.md b/rfc/0047-error-cause.md new file mode 100644 index 000000000..755155aea --- /dev/null +++ b/rfc/0047-error-cause.md @@ -0,0 +1,464 @@ +# RFC-0047:错误体系与错误原因(cause) + +> **Status**: Draft +> +> **Date**: 2026-10-19 +> +> **Scope**: `AiMuxError` 的结构与 `cause`、传输层错误链、ops 错误信封的四个域、#268 生成的错误 DTO、各语言绑定的原生错误、录制里的错误。不改错误名称、错误码、消息文字和 C ABI 函数。 +> +> **Baseline**: `master@42208472413a9b157428924e3f4785bb009bc27f`(#268 合并后,DTO 由生成器生成,ts-rs 已删除)。 +> +> **Related**: [RFC-0039](0039-ops-bindings-contract.md) §6 错误信封、[RFC-0042](0042-v4-type-boundaries.md) 错误联合归属、RFC-0044(#279)共享错误枚举的归属、RFC-0048(#325)录制 schema 3、#349(信封去掉 `retryAfterMs`)、`docs/error-model.md`、`docs/ai-sdk-request-pipeline.md` §3、#268、#280(本 RFC 的原型实现,拆分后关闭)。 +> +> **Amends**: `docs/ai-sdk-request-pipeline.md` §3 与 §14 “`cause` 本轮不加入”。 + +## 1. 决策摘要 + +1. **aimux 的错误体系有意和 AI SDK 不同,但 `cause` 要对齐。** AI SDK 的错误是 JS 类,`cause` 可以挂任何对象;aimux 的错误是一个可克隆、可序列化、要跨 C ABI 和 8 种语言的 Rust 枚举。所以 aimux 不能照搬“挂原始对象”,而是把原因记成一条可序列化的链。 +2. **AI SDK 哪个错误类接受 `cause`,aimux 对应的变体就有 `cause`,其他变体没有。** 底层错误在构造处手上有的,必须传进去;不得再 `format!` 进消息后丢掉对象。 +3. **一个 cause 只有两种:** 嵌套的 `AiMuxError`(原样保留,带自己的 `name`),或 `SourceError { message, cause? }`(OS、TLS、JSON 解析器等外部错误沿 `source()` 一层层抄下来)。 +4. **Rust 里 cause 就是 `Error::source()`。** 继续用 thiserror,`cause` 字段标 `#[source]`;只有算出来的 source 才手写:`RetryError` 的 source 是最后一次尝试(AI SDK 的 `lastError`),`ApiCallError` 的 source 是它的 `cause`。 +5. **消息文字不变,仍包含底层错误的文字。** C ABI 的 `aimux_error_message` 和只打印消息的调用方只看得到这一行,不能因为有了 cause 就把文字去掉。 +6. **ops 四个域用同一个 `cause` 形状。** `aimux` 域就是 `AiMuxError` 的 cause;`recording`、`binding`、`protocol` 域有底层错误时(I/O、serde)也放进 `cause`,没有就不放。各语言只有一条解码路径:读 `error.cause`。 +7. **C ABI 不加函数。** cause 只在 `aimux_error_json` 返回的 JSON 里;代码、消息、JSON 三个 getter 保持冻结。 +8. **DTO 由 #268 的生成器从 Rust 类型生成。** `ErrorCause`、`SourceError` 是普通的 schemars 类型,生成器按“先 `AiMuxError`、后 `SourceError`”的有序无标签联合生成;Python 的嵌套错误投影把 `cause` 加进每个带 cause 的变体。 +9. **宿主语言自己的异常不跨 ABI。** 回调里宿主抛的异常仍在宿主那边挂成原生 cause(例如 Java 的 `CallbackFailure`),不会写进信封。 +10. **传输错误先去掉 URL。** URL 的 query 里可能有 API key;cause 链和消息都不得带它。 +11. **实现分三个 PR,基于 #268 合并后的 master,用它的生成器。** 每个 PR 带自己的测试,单独过 CI。 + +## 2. 现状和问题 + +### 2.0 现在错误怎么走 + +先看整体:一个非流式调用从入口到调用方,错误在哪里产生、在哪里分类、在哪里重试、最后怎么变成各语言的异常。 + +```text +generate_text aimux-core/src/generate.rs:954 +└ retries.retry(..) generate.rs:1133 重试边界 + │ └ retry_with_exponential_backoff aimux-core/src/retry.rs:103 + │ ├ 可重试:AiMuxError::is_retryable aimux-core/src/error.rs:691 + 响应头提示 retry_header_delay(retry.rs:225) + │ └ 次数用完或不可重试 → AiMuxError::Retry(RetryError { reason, errors }) retry.rs:163 / 192 + └ do_generate_with_logging generate.rs:2266 + └ OpenAIChatLanguageModel::do_generate aimux-providers/src/openai/model.rs:166 + └ execute_generate openai/model.rs:221 + └ post_json_to_api aimux-provider-utils/src/post_to_api.rs:17 + └ execute_exchange post_to_api.rs:144 + ├ 传输失败 http.rs:360 → fetch.rs:430 → map_reqwest_error(fetch.rs:445) + │ └ fetch_error_to_ai_mux_error handle_fetch_error.rs:29 → ApiCall,无状态码,可否重试看 FetchError + ├ 非 2xx post_to_api.rs:166 + │ └ failed_response_handler openai_failed_response_handler(openai/mod.rs:64) + │ │ → create_json_error_response_handler(response_handler.rs:220) + │ └ handler 自己失败 → ApiCall { status_code, is_retryable: is_retryable_status } post_to_api.rs:172、error.rs:651 + └ 2xx post_to_api.rs:188 + └ successful_response_handler 失败 → ApiCall “Failed to process successful response” post_to_api.rs:193 +失败后录制 +└ record_outcome(OutcomeRecord::from_error) generate.rs:1144 → recording.rs:484 存 Display 文字和整个 AiMuxError +``` + +流式调用多两点:重试只包住拿到第一个事件之前;之后的错误作为事件交给调用方,不抛出。 + +```text +stream_text aimux-core/src/generate.rs:1541 +└ retries.retry(..) generate.rs:1661 只到第一个事件 + └ do_stream → execute_stream openai/model.rs:184 / 376 + └ create_event_source_response_handler aimux-provider-utils/src/response_handler.rs:358 + ├ 流里的 error 帧 → openai_stream_error openai/mod.rs:91 → ApiCall + └ 传输中断 SseError → ApiCall response_handler.rs:409 +└ TextStreamPart::Error { error } generate.rs:1887-1890 作为事件交付 +``` + +跨语言时,同一个错误在 ops 边界投影成信封一次,各语言从信封还原: + +```text +aimux_call / aimux_stream aimux-ffi/src/abi.rs:138 +└ dispatch::call aimux-ops/src/dispatch.rs:298 + └ OpsError::project_with_clock dispatch.rs:338 + └ OpsError::envelope aimux-ops/src/envelope.rs:116 + { kind, code, message = Display, error = payload, retryAfterMs } +└ finish abi.rs:35 → aimux_error_t* + ├ aimux_error_code / _message / _json abi.rs:364 / 371 / 380 + └ Node bindings/node/src/error.rs:37、Python _errors.py:97 from_envelope、 + Java AimuxResult.decode、Go decodeError、Swift takeError、Dart OpsException.fromJson、C++ aimux::check +``` + +分类只看结构化字段:HTTP 状态在 `ApiCallError.status_code`,可否重试在 `is_retryable`,重试提示从 `response_headers` 算。整条路上没有任何一处保留底层错误对象,下面逐段看丢在哪里。 + +下面每段从调用方用的公开函数开始,一路往下到丢掉原因的那一行(标 `✗`),旁边是上游 AI SDK 同一位置的做法,最后是改法。行号都是基线 master 上的。 + +### 2.1 网络连不上:reqwest 的原因链只剩第一层 + +```text +generate_text aimux-core/src/generate.rs:954 +└ do_generate_with_logging aimux-core/src/generate.rs:2266 + └ OpenAIChatLanguageModel::do_generate aimux-providers/src/openai/model.rs:166 + └ post_json_to_api aimux-provider-utils/src/post_to_api.rs:17 + └ execute_exchange aimux-provider-utils/src/post_to_api.rs:144 + └ send_one_request aimux-provider-utils/src/http.rs:360 + ├ ReqwestFetch::fetch aimux-provider-utils/src/fetch.rs:430 + │ └ ✗ map_reqwest_error fetch.rs:445-455 error.to_string() + └ ✗ fetch_error_to_ai_mux_error handle_fetch_error.rs:29-40 ApiCallError::new(other.to_string(), …) +上游 handle-fetch-error.ts:56-95 new APICallError({ message: 'Cannot connect to API: …', cause: error, isRetryable: true }) +``` + +连一个没开的端口,reqwest 的错误其实是三层: + +```text +error sending request for url (http://127.0.0.1:9/x?key=SECRET) reqwest +└ tcp connect error hyper + └ Connection refused (os error 111) 操作系统 +``` + +`map_reqwest_error` 只留第一层,真正的原因没了,而且这一层带着完整 URL,URL 的 query 里可能是 API key。SigV4、Vertex、Azure 的自定义传输里还有同样的 8 处。 + +**改法**:`FetchError` 改为携带 `SourceError`,先 `reqwest::Error::without_url()` 再把整条 `source()` 链抄下来;`fetch_error_to_ai_mux_error` 把它放进 `ApiCallError.cause`。 + +### 2.2 成功响应解析失败:serde 错误只留在消息里 + +```text +post_json_to_api aimux-provider-utils/src/post_to_api.rs:17 +└ execute_exchange post_to_api.rs:144 + └ create_json_response_handler aimux-provider-utils/src/response_handler.rs:105 + └ ✗ serde_json::from_slice:: response_handler.rs:130-140 format!("Invalid JSON response: {error}") +上游 response-handler.ts:216-226 new APICallError({ message: 'Invalid JSON response', cause: parsedResult.error, … }) +``` + +**改法**:serde 错误作为 cause,消息不变。 + +### 2.3 handler 自身失败:包成 “Failed to process … response: …” + +```text +execute_exchange aimux-provider-utils/src/post_to_api.rs:144 +├ ✗ 非 2xx 分支 post_to_api.rs:172-183 format!("Failed to process error response: {error}") +└ ✗ 2xx 分支 post_to_api.rs:193-200 format!("Failed to process successful response: {error}") +上游 post-to-api.ts:128-161 new APICallError({ message: 'Failed to process … response', cause: error, … }) +``` + +**改法**:原来的 `AiMuxError` 整个作为 cause(保留它的 `name`、状态码)。消息仍带 `: {error}` 后缀,因为 C ABI 只暴露消息。 + +### 2.4 流式中途断开:SSE 传输错误转成文字 + +```text +stream_text aimux-core/src/generate.rs:1541 +└ OpenAIChatLanguageModel::do_stream aimux-providers/src/openai/model.rs:184 + └ create_event_source_response_handler aimux-provider-utils/src/response_handler.rs + ├ ✗ SseError::Stream(error) response_handler.rs:409-421 ApiCallError::new(error.to_string(), …) + └ ✗ 其它 SseError response_handler.rs:423-436 同上 +上游 response-handler.ts:60-80 new APICallError({ message: 'Failed to process successful response', cause: error, … }) +``` + +`SseError` 自己带 `#[source]`,原因链在这一行之前都还在。同类的还有读取响应体(`read_response_with_size_limit.rs:68`)、WebSocket(`ws.rs:157`)、Bedrock event stream(`bedrock/model.rs:50`)、Stability 和 xAI 的 JSON 解析。 + +**改法**:同 2.2。 + +### 2.5 JSON 解析和 serde 转换:只留字符串 + +```text +generate_text(带 output 规格) aimux-core/src/generate.rs:954 +└ ✗ parse_json aimux-core/src/output.rs:251-264 JsonParse(format!("… Error message: {message}")) +任意 provider 里的 `?` +└ ✗ impl From for AiMuxError aimux-core/src/error.rs:636-644 e.to_string() +上游 parse-json.ts:55 throw new JSONParseError({ text, cause: error }) +上游 validate-types.ts:28 TypeValidationError.wrap({ value, cause: result.error }) +``` + +**改法**:`JsonParse` 改为 `{ message, cause }`,`From` 把 serde 错误自己作为 cause。 + +### 2.6 工具参数不合法:cause 是字符串 + +```text +generate_text aimux-core/src/generate.rs:954 +└ parse_tool_call aimux-core/src/parse_tool_call.rs + ├ ✗ parse_json_input parse_tool_call.rs:299-322 InvalidToolInput { cause: format!("JSON parsing failed: …") } + └ ✗ schema 校验 parse_tool_call.rs:281-296 InvalidToolInput { cause: format!("Type validation failed: …") } +上游 parse-tool-call.ts:208/259 new InvalidToolInputError({ toolName, toolInput, cause: JSONParseError | TypeValidationError }) +``` + +**改法**:`InvalidToolInput.cause` 改为 `ErrorCause`,放 `JsonParse` 或 `TypeValidation` 错误对象。`TypeValidation { message, value, cause }` 是新增变体,对应上游 `TypeValidationError`;结构化输出不合 schema 时(`InvalidResponseData`,上游 `NoObjectGeneratedError`)cause 也是它。 + +### 2.7 参数、prompt、下载:底层错误 format! 进消息 + +```text +✗ download_guard.rs:65 InvalidArgument(format!("invalid download URL: {error}")) url::ParseError +✗ language_model_message.rs:780 InvalidPrompt(format!("Invalid URL: {given}: {e}")) url::ParseError +✗ google/speech.rs:493 InvalidResponseData(format!("invalid base64 audio: {e}")) base64::DecodeError +✗ download.rs:316 Other(format!("download failed: {error}")) reqwest::Error,文字带 URL +上游 InvalidArgumentError / InvalidPromptError / AISDKError 的构造参数都带 cause +``` + +这一类在 #268 合并前的 master(`5743a40a`)上共 109 处:`InvalidArgument` 72、`InvalidResponseData` 20、`JsonParse` 15、`InvalidPrompt` 1、`Other` 1;#268 合并后实现时补上 cause 的共 117 处(#290)。它们都是只带一个字符串的变体。 + +**改法**:六个字符串变体改为 `{ message, cause }`,有底层错误的地方都传进去。 + +### 2.8 跨语言:Rust 已经只剩文字,其他语言更拿不到 + +```text +aimux_call aimux-ffi/src/abi.rs:138 +└ dispatch::call aimux-ops/src/dispatch.rs:298 + └ OpsError::project_with_clock aimux-ops/src/dispatch.rs:338 + └ OpsError::envelope aimux-ops/src/envelope.rs:116 AiMuxError 整个序列化进 error(没有 cause 字段) + ├ ✗ recording_payload envelope.rs:204-219 io::Error 的 source.to_string() + ├ ✗ BindingError::payload envelope.rs:222 serde 错误早已是字符串(abi.rs:63、dispatch.rs:368) + └ ✗ ProtocolError::payload envelope.rs:248 serde 错误早已是字符串(dispatch.rs:221) +aimux_error_json aimux-ffi/src/abi.rs:380 各语言从这里拿 JSON +├ ✗ Node bindings/node/src/error.rs:37 create_error(message),不设 cause +├ ✗ Python bindings/python/python/aimux/_errors.py:91 只有 ToolCallRepair 设 __cause__ +├ ✗ Java NativeException.java:12 super(message),不 initCause +├ ✗ Go bindings/go/error.go:15 没有 Unwrap() +├ ✗ Swift / Flutter 只能翻原始 JSON +└ ✗ C++ bindings/c/aimux_raii.hpp:22-26 throw std::runtime_error(message),连错误码都丢了 +上游 JS:error.cause 就是原始错误对象 +``` + +**改法**:见 §5 和 §7。 + +### 2.9 为什么是问题 + +- **排查不了。** 地址错、服务没开、防火墙、证书不对,区别都在丢掉的那几层。 +- **程序没法按原因处理。** 只能匹配会随版本变化的文字。 +- **泄露 key。** 带 URL 的那一行会进日志、录制文件,并跨语言传给调用方。 + +## 3. aimux 错误体系和 AI SDK 的差异 + +| 方面 | AI SDK | aimux | 原因 | +|---|---|---|---| +| 形态 | 一组 JS 类,`isInstance` 判断 | 一个封闭的 Rust 枚举 `AiMuxError`,JSON 以 `name` 区分变体(名字与 AI SDK 相同) | 要 `Clone`、`Serialize`,要跨 C ABI | +| 错误名 | `AI_APICallError` 等 | 相同;未知名字在绑定里保留为 Unknown | 跨语言判断只能靠名字 | +| 可否重试 | `isRetryable` 属性 | 同名字段,存在 `ApiCallError` 上 | 一致 | +| 重试提示 | 每次从响应头算 | 信封顶层 `retryAfterMs`,在 ops 边界按一次时钟算一次 | RFC-0039 §6,避免各绑定各算一遍 | +| 消息 | 只有自身消息 | `Display` 带前缀(`API call error: …`),并包含底层错误文字 | C ABI 只暴露消息一行 | +| `cause` | `unknown`,任意对象 | 嵌套 `AiMuxError` 或 `SourceError` 链 | 原始 Rust 错误对象不能克隆、序列化、跨语言 | +| 哪些错误有 `cause` | 构造参数接受 `cause` 的类 | 对应变体(见 §4.2) | 对齐 | +| 错误上下文 | 原样带请求上下文 | 白名单、脱敏、大小上限;传输错误去 URL | 错误会进录制、跨 FFI | +| 错误域 | 只有 SDK 错误 | ops 信封分 `aimux`、`recording`、`binding`、`protocol` 四个域,各有错误码区间 | RFC-0039 §6 | +| 没有的类型 | `GatewayError`、`StreamProviderError` 等 | 不加;流中错误帧映射为 `ApiCallError` | 已登记 | + +## 4. Rust 设计 + +### 4.1 类型 + +```rust +/// 外部错误(I/O、TLS、解析器……)的快照:自身文字和它 source() 链上每一层的文字。 +pub struct SourceError { + pub message: String, + pub cause: Option>, +} + +/// 一个错误是从什么错误构造出来的:AI SDK 的 `cause`。 +#[serde(untagged)] +pub enum ErrorCause { + AiMux(Box), // 带 name 的 aimux 错误,原样保留 + Source(SourceError), // { message, cause? } +} +``` + +`cause` 是变体上的字段,`ErrorCause` 是这个字段的类型: + +```rust +pub enum AiMuxError { + InvalidArgument { + message: String, + #[source] + cause: Option, + }, + // JsonParse、InvalidResponseData、InvalidPrompt、NoOutputGenerated、Other 同形; + // ApiCallError 结构体上也是 `cause: Option`。 + ... +} +``` + +单独定义类型,是因为这个字段要放两种东西:另一个 aimux 错误(原样嵌套),或外部库的错误(不能克隆、序列化、跨语言,只能抄成 `SourceError`)。JS 的 `cause` 可以是任意对象,Rust 做不到,所以多这一层;序列化后仍是 payload 里的一个 `cause` 字段。 + +`SourceError` 和 `ErrorCause` 都实现 `std::error::Error`,`source()` 返回下一层,所以整条链对 `anyhow`、`{:#}`、`tracing` 都可见。`ErrorCause::source(&e)` 是唯一的入口:`e` 若能 downcast 成 `AiMuxError` 就原样嵌套,否则抄成 `SourceError`。 + +JSON 形状: + +```json +{ "name": "AI_APICallError", "message": "Cannot connect to API: tcp connect error", "isRetryable": true, + "cause": { "message": "tcp connect error", "cause": { "message": "Connection refused (os error 111)" } } } +``` + +没有原因时不输出 `cause`。 + +### 4.2 哪些变体有 cause + +| aimux 变体 | 对应的 AI SDK 类 | cause | +|---|---|---| +| `ApiCall` | `APICallError` | 可选(`ApiCallError.cause`,`.caused_by(..)`) | +| `JsonParse` | `JSONParseError` | 可选 | +| `InvalidResponseData` | `InvalidResponseDataError`;结构化输出不合 schema 时对应 `NoObjectGeneratedError` | 可选(不合 schema 时是 `TypeValidation`) | +| `TypeValidation`(新增) | `TypeValidationError`,带 `value` | 可选(校验器错误);只作为 cause 出现,ops 错误码与 `InvalidResponseData` 共用 3 | +| `InvalidArgument` | `InvalidArgumentError` | 可选 | +| `InvalidPrompt` | `InvalidPromptError` | 可选 | +| `NoOutputGenerated` | `NoOutputGeneratedError` | 可选 | +| `Other` | `AISDKError` | 可选 | +| `InvalidToolInput` | `InvalidToolInputError` | 必有,改为错误对象 | +| `ToolCallRepair` | `ToolCallRepairError` | 必有(已有),接上 `source()` | +| `Retry` | `RetryError` | 不加字段;`source()` 是最后一次尝试 | +| 其余(`Timeout`、`Aborted`、`TokenExpired`、`NoSuchModel` 等) | 不接受 `cause` | 没有 | + +只带一个字符串的六个变体改成 `{ message, cause }`,配同名构造函数(`AiMuxError::invalid_argument(..)`、`json_parse(..)` 等)。新增 `TypeValidation { message, value, cause }`,名字 `AI_TypeValidationError`;上游的 `context`(field、entityName、entityId)aimux 现在没有地方填,不加。 + +### 4.3 传输层 + +`FetchError` 的连接、I/O、其他三类改为携带 `SourceError`,用 `reqwest::Error::without_url()` 构造。`fetch_error_to_ai_mux_error` 把它作为 `ApiCallError.cause`。SSE、WebSocket、成功响应解析失败、handler 失败同样把原错误作为 cause,消息文字不变。 + +改完以后 §2.1 那条路径: + +```text +ReqwestFetch::fetch aimux-provider-utils/src/fetch.rs +└ map_reqwest_error error.without_url() → FetchError::Connect(SourceError::new(&error)) +fetch_error_to_ai_mux_error aimux-provider-utils/src/handle_fetch_error.rs +└ ApiCallError { is_retryable, cause: Some(ErrorCause::source(&fetch_error)), .. } +调用方看到: +API call error: Cannot connect to API: tcp connect error ← Display,消息不变 +└ cause: tcp connect error ← source() + └ cause: Connection refused (os error 111) ← URL 已去掉 +``` + +## 5. ops 层 + +### 5.1 信封 + +信封结构不变:`{kind, code, message, error, retryAfterMs?}`。`message` 仍是完整 `Display`;`error` 是该域的 payload,`cause` 是 payload 里的可选字段。 + +| 域 | payload 里的 cause | 来源 | +|---|---|---| +| `aimux` | `AiMuxError` 自己的 `cause` | §4 | +| `recording` | `Init`、`OpenFile`、`Spawn` 的 `std::io::Error` | 现在是字符串字段 `source`,改为 `cause: SourceError`(见 §10 问题 2) | +| `binding` | `InvalidWireJson`、`ResultSerialization` 的 serde 错误 | 现在只有文字 | +| `protocol` | 一般没有;`InvalidParams` 的 serde 错误 | 现在只有文字 | + +四个域同一个形状,绑定只需要一条解码路径。错误码、名字和消息都不变。 + +改完以后同一条路径是这样的: + +```text +aimux_call aimux-ffi/src/abi.rs:138 +└ dispatch::call aimux-ops/src/dispatch.rs:298 + └ OpsError::envelope aimux-ops/src/envelope.rs:116 + ├ aimux 域:serde_json::to_value(AiMuxError) cause 随 AiMuxError 一起序列化 + ├ recording_payload io::Error → cause: SourceError::new(source) + ├ BindingError::payload InvalidWireJson / ResultSerialization 保存 serde 错误 → cause + └ ProtocolError::payload InvalidParams 保存 serde 错误 → cause +aimux_error_json aimux-ffi/src/abi.rs:380 不变,JSON 里多了 error.cause +``` + +`BindingError::InvalidWireJson`、`ResultSerialization`、`ProtocolError::InvalidParams` 现在构造时就把 serde 错误转成了字符串(`abi.rs:63`、`dispatch.rs:221`、`dispatch.rs:368`),改为保存 `SourceError`。 + +### 5.2 C ABI + +不加函数。`aimux_error_code`、`aimux_error_message`、`aimux_error_json` 三个 getter 冻结;cause 只在 JSON 里。C++ 包装 `aimux::check` 改抛 `aimux::NativeError`(继承 `std::runtime_error`),带错误码和完整 JSON。 + +### 5.3 宿主异常 + +宿主回调(流消费者)抛出的异常不跨 ABI。绑定把它挂成本语言的 cause(Java `CallbackFailure.initCause`,Go 的 callback 错误),信封 JSON 里没有它。以后支持宿主实现 provider(#274)时,宿主异常链转成 `SourceError` 再传进来,形状与 §4.1 相同。 + +### 5.4 重试提示 + +`retryAfterMs` 仍只在顶层是 `ApiCall` 时给出。cause 里嵌套的 `ApiCall` 不产生顶层提示:调用方要重试的是外层错误。 + +## 6. DTO(#268 生成器) + +#268 用 `aimux-codegen` 从 Rust 类型的 schemars schema 生成各语言 DTO,ts-rs 和 `scripts/gen_ts_types.py` 被删除。错误 DTO 已经出现在 `TextStreamPart::Error`、`RetryError.errors`、`ToolCallRepair` 里,并有 `contract-tests/dto/AiMuxError` 向量。 + +生成链路(master `42208472`): + +```text +cargo run -p aimux-codegen tools/aimux-codegen/src/main.rs:32 +└ aimux_ops::manifest() aimux-ops/src/manifest.rs:41 typeDefs = 各 Rust 类型的 schemars schema + └ generate tools/aimux-codegen/src/lib.rs:190 + ├ 模型:anyOf + "x-aimux-codec": "ordered-untagged" → Kind::Untagged tools/aimux-codegen/src/model.rs:731 + └ 各语言 emitter tools/aimux-codegen/src/emit/{node,python,java,go,swift,dart}.rs + └ Python:_NESTED_ERROR_FIELDS tools/aimux-codegen/src/emit/python.rs:391 按“字段类型含错误”逐变体推出 +运行时 Python 还原 +└ from_envelope bindings/python/python/aimux/_errors.py:99 + └ payload_fields _error_payloads.py(生成) 只把 _NESTED_ERROR_FIELDS 里的字段还原成异常 +``` + +所以只要 Rust 类型对,DTO 不用手改: + +1. `ErrorCause`、`SourceError` 派生 `JsonSchema`,作为普通类型进入 manifest `typeDefs`,不手写任何语言的类型。 +2. `ErrorCause` 标 `#[schemars(extend("x-aimux-codec" = "ordered-untagged"))]`(`content.rs:508` 已有同样用法),生成为**有序无标签联合**:先试 `AiMuxError`(必有 `name`),再试 `SourceError`(只要求 `message`)。生成器已有 `Kind::Untagged` 和按必需字段选分支的逻辑;`AiMuxError` 已经通过 `RetryError.errors` 递归,所以递归不是新能力。 +3. Python 的 `_NESTED_ERROR_FIELDS` 由 `emit/python.rs:391` 的 `contains_error` 推出,要让它把 `ErrorCause` 也算作错误类型,每个带 cause 的变体都含 `cause`,嵌套的 aimux 错误还原成对应异常类,`SourceError` 还原成 `aimux.SourceError`。 +4. DTO 向量新增三类:带传输链的 `ApiCall`、cause 是嵌套 aimux 错误、没有 cause;每种语言的生成 codec 都要往返通过(`scripts/check_dto_vectors.py`)。 +5. 输入方向(`AiMuxErrorInput`)接受并保留 `cause`,规则与输出相同。 +6. `SourceError.cause` 的类型是 `SourceError` 自己。Swift 的 struct 不能内联存放自身类型,生成器为这种“可选、类型是所在 struct 本身”的字段生成 `WireIndirect` 盒子,属性名和 JSON 不变(#289,`tools/aimux-codegen/src/emit/swift.rs` 的 `boxed`)。其他语言的生成物没有这个问题。 + +## 7. 各语言 + +| 语言 | 原生入口 | +|---|---| +| Rust | `source()` 链 | +| Node | 标准 `error.cause`:嵌套 aimux 错误是 `AimuxCauseError`(`name` 是 AI SDK 名字,`error` 是整个 payload),外部错误是 `AimuxSourceError`;两者都继承 `Error`,有自己的 `cause` 和 stack | +| Python | `__cause__`;嵌套错误是对应子类,外部错误是 `SourceError` | +| Java / Kotlin | `getCause()` 链上的 `ErrorCause`(`getErrorName()` 对外部错误为空);Kotlin 跟随 Java | +| Go | `(*Error).Unwrap()` 返回 `*aimux.Cause`,`errors.Unwrap` / `errors.As` 可用 | +| Swift / Flutter | `cause` 属性 | +| C / C++ | JSON 的 `error.cause`;C++ `aimux::NativeError::json()` | + +`Retry` 没有 `cause` 字段,wire 上只有 `errors`。它的 Rust `source()` 是最后一次尝试(AI SDK 的 `lastError`),所以每个绑定从 `errors` 的最后一个合成原生 cause:Node `error.cause`、Python `__cause__`(同 `last_error`)、Java / Kotlin `getCause()`、Go `Unwrap()`、Swift / Flutter `cause`;`errors` 为空时没有 cause。嵌套在别处的 `Retry` 同样处理。每种绑定都有这条测试(#293)。 + +Java 有两个同名类型,分工不同:`ai.arcships.aimux.types.ErrorCause` 是生成的 DTO,用在 `RetryError.errors`、事件里的错误等数据字段,按 §6 的有序无标签联合解码;`ai.arcships.aimux.ErrorCause` 是手写的 `RuntimeException`,只为挂在 `getCause()` 上,包住同一段 JSON(`getErrorName()`、`getError()`),不重新解码。Node 的 `AimuxCauseError`、`AimuxSourceError`,Python 的 `SourceError`,Go 的 `Cause` 是同样的手写异常层;生成的 DTO 类型名不变。 + +## 8. 录制与回放 + +`Outcome::from_error` 把整个 `AiMuxError` 写进 `error_value`,所以 cause 链会进录制文件。在 master 上(schema 2)这是可选字段:没有它的旧文件照样能读,旧读取器遇到它会忽略(错误类型没有 `deny_unknown_fields`)。 + +RFC-0048(#325)落地后录制只有 schema 3,schema 1、2 的读取删除(RFC-0048 §1 第 1 条),上一段的“旧文件可读”随之只适用于 RFC-0048 之前。schema 3 的 `outcome.errorValue` 仍是 `AiMuxError` 的 JSON,`cause` 照样在里面,且只在 `payload` 策略下存(RFC-0048 §3);这条不依赖 schema 3,也不需要等它。回放出来的错误带着录下来的 cause。 + +RFC-0048 的回放引擎把录下的传输终态映射回 `FetchError`,并用 `FetchError::Other(": …")` 报引擎错误。本 RFC 之后 `FetchError` 的连接、I/O、其他三类携带 `SourceError`(§4.3),所以那里写成 `FetchError::Other(SourceError::from(": …"))`;录下的终态只有 `kind` 和 `message`,回放出来的是一层没有下级的 `SourceError`。 + +## 9. 安全 + +- 传输和下载错误一律用 `without_url()` 构造,消息和 cause 都不带 URL(URL 的 query 里可能有 API key)。 +- `SourceError` 抄的是外部错误的 `Display` 文字,可能包含数据。serde_json 的 data 类错误会引用违规的值(如 ``invalid type: string "…", expected u32``、``unknown variant `…` ``),值来自调用方的参数或模型的响应。这些文字在本 RFC 之前就在消息里(`dispatch.rs:221`、`abi.rs:63` 的 `error.to_string()`),cause 不新增暴露面,但也没有减少它。url、base64、io 的错误文字不含输入内容;新增外部错误类型时要确认这一点。 +- `TypeValidation.value` 是不符合 schema 的整个值,和 AI SDK 一样,没有大小上限,也不脱敏。 +- 信封现在没有大小上限,也不对值脱敏。录制对 `error_value` 做按键名的脱敏(`recording.rs` 的 `redact_json`,凭据类键名的值被替换),会作用到 cause 里的同名键,但不处理文字里引用的值。 + +因此错误 JSON(包括 cause 和 `value`)和请求、响应体同样敏感:写日志、发往第三方前由调用方决定是否保留。是否在 aimux 里加大小和脱敏边界见 §10 问题 5。 + +## 10. 待讨论 + +1. **cause 链要不要设深度上限。** 现在由 `source()` 链自然决定,`Retry` 的每次尝试各自带链。倾向不设:网络错误一般三四层,重试只有几次尝试,整条链也就几十行文字。 +2. **`recording` 域的 `source` 字符串改名为 `cause`。** 已采纳(维护者 review 建议标为已采纳,#293 已实现):四个域同一个形状;`recording-*.json` 黄金样例随之改变,属于发布前的破坏性变更(§11)。 +3. **`binding`、`protocol` 域要不要带 cause。** 已采纳(同上,#293 已实现):有 serde 错误时带,没有就不带;`BindingError`、`ProtocolError` 的 `source()` 也返回它。 +4. **C++ 要不要提供解析 cause 的辅助函数。** 倾向不提供,C++ 没有 JSON 依赖,调用方自己解析 `json()`。 +5. **错误 JSON 要不要加大小和脱敏边界(§9)。** 两种做法: + - A(倾向):只限大小,不改文字。`SourceError` 每层 `message` 最多 1 KiB(按字符边界截断,末尾加 `…`);`TypeValidation.value` 序列化后超过 16 KiB 时写 `null`,`message` 末尾注明原大小。消息里已有的 serde 文字不变,调用方按 §9 把错误 JSON 当作请求、响应体看待。 + - B:在 A 之外,serde_json 的 data 类错误(`classify() == Category::Data`)构造 `SourceError` 和消息时只留类别和位置(`invalid data at line L column C`),不留引用的值。代价是 `InvalidParams`、`InvalidWireJson`、响应解析失败的消息文字改变,丢掉 `expected u32` 这类提示。 + +## 11. 兼容性 + +不只是新增可选字段,下面每一条都写入 CHANGELOG 的 Breaking: + +- **错误名、错误码不变。** 新增 `AI_TypeValidationError`,与 `InvalidResponseData` 共用错误码 3,只作为 cause 出现。 +- **消息文字**:只有传输和下载错误变化,reqwest 原来的 `for url (…)` 不再出现(§9)。只按消息匹配的调用方要改。 +- **wire JSON 的形状变化**: + - `InvalidToolInput.cause` 从字符串变为错误对象(`AI_JSONParseError` 或 `AI_TypeValidationError`),按字符串读它的代码要改。 + - `recording` 域 `Init`、`OpenFile`、`Spawn` 的 `source` 字符串改为 `cause: { message, cause? }`(§10 问题 2),`recording-*.json` 黄金样例随之改变。 + - 其余是新增可选字段 `cause`,按 `docs/error-model.md` 属于兼容扩展;旧绑定读到它会保留或忽略。 +- **Rust API**:六个字符串变体改为结构体变体;`InvalidToolInput.cause` 类型变化;`FetchError` 的载荷类型变化;新增变体 `TypeValidation`(对 `AiMuxError` 穷尽匹配的代码要加一支);`BindingError::InvalidWireJson`、`ResultSerialization`、`ProtocolError::InvalidParams` 改为带 `cause` 的结构体变体。 +- **绑定**:Node 的 `error.cause`、Python 的 `__cause__`、Java 的 `getCause()`、Go 的 `Unwrap()` 从空变为有值,`Retry` 的是最后一次尝试(§7);C++ `aimux::check` 改抛 `aimux::NativeError`(`std::runtime_error` 的子类,原来的 `catch` 仍然有效)。 + +### 11.1 与其他 RFC 的关系 + +- **RFC-0044(#279)**:L7 把共享错误枚举放进 `aimux-provider`。`ErrorCause`、`SourceError` 和 `AiMuxError` 在同一个文件(`aimux-core/src/error.rs`),随它一起搬,不单独定归属;本文写 `aimux-core` 只是因为基线还没拆分。RFC-0044 也写明每个错误补 `cause` 另开小 PR,不放进拆分步骤,与本文的三个 PR 一致。 +- **RFC-0048(#325)**:schema 1、2 读取删除,见 §8;回放引擎的 `FetchError::Other` 载荷随 §4.3 变为 `SourceError`。 +- **#349**:信封去掉 `retryAfterMs` 和时钟(`project_with_clock`、`Projected`)。§5.1 的信封结构和 §5.4 以它合并前的 master 为准;#349 合并后信封是 `{kind, code, message, error}`,cause 的规则不变。#293 和 #349 都改 `aimux-ops/src/envelope.rs`,后合并的一个解决冲突。 + +## 12. 实施 + +三个 PR,一个叠一个,最底下一个(#289)基于 #268 合并后的 master,用它的生成器: + +1. **核心类型和网络层**(#289):`ErrorCause`、`SourceError`、`ApiCallError.cause`、`source()`、`FetchError` 错误链去 URL、SSE / WebSocket / 响应处理器、生成的 DTO 和向量、文档登记。 +2. **字符串变体**(#290):六个变体改形,117 处补上原因,`InvalidToolInput.cause` 改成错误对象,新增 `TypeValidation`,下载错误去 URL。 +3. **ops 和绑定**(#293):§5.1 四个域、8 种绑定的原生 cause、C++ `NativeError`。 + +每个 PR 带自己的测试:Rust 的 `source()` 链和 JSON 往返(有、无 cause)、真实被拒连接(链完整、没有 URL)、DTO 向量、每种绑定一个 cause 测试。#280 是这三步的原型,拆分后关闭。 + +## 13. 不做 + +- 不加 `GatewayError`、`StreamProviderError`。 +- 不改 C ABI 函数。 +- 不把宿主异常写进信封。 +- 除去掉 URL 外不改消息文字。 From 1a33d20e4dd190adf165771362d408910aa9b103 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Wed, 21 Oct 2026 11:46:35 +0800 Subject: [PATCH 14/14] test: make the refused connection fixture portable --- aimux-provider-utils/Cargo.toml | 1 - aimux-provider-utils/src/fetch.rs | 15 ++++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) 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/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 {