From fa4fb9bcddcc412eca23e2d27badfc85a2eab5f0 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 10:51:19 +0000 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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. |