Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
fa4fb9b
feat(errors): add ErrorCause, SourceError and ApiCallError.cause
cunninghamcard-bit Oct 20, 2026
b034155
feat(transport): keep the transport error as ApiCallError.cause
cunninghamcard-bit Oct 20, 2026
ae5858f
refactor(providers): pass the cause where providers build ApiCallError
cunninghamcard-bit Oct 20, 2026
c4194d1
fix(codegen): box a Swift struct field of the struct's own type
cunninghamcard-bit Oct 20, 2026
00492d8
chore(dto): regenerate the DTOs and add the cause-chain vectors
cunninghamcard-bit Oct 20, 2026
bec5828
docs: record ApiCallError.cause and the URL-less transport messages
cunninghamcard-bit Oct 20, 2026
aa52e8f
feat(errors): give the six message-only variants a cause
cunninghamcard-bit Oct 20, 2026
b6e5710
feat(core): keep the parse, validation and download error as the cause
cunninghamcard-bit Oct 20, 2026
3680456
refactor(providers): keep serde, base64, url and io errors as the cause
cunninghamcard-bit Oct 20, 2026
4ba0a11
refactor(ops): follow the struct variants in ops, the C ABI and aimux…
cunninghamcard-bit Oct 20, 2026
f719e4b
feat(bindings): regenerate the DTOs and add TypeValidationError
cunninghamcard-bit Oct 20, 2026
b570ada
docs: describe the error cause and TypeValidation
cunninghamcard-bit Oct 20, 2026
20fc9c5
Merge master into [2/3] error cause: converge to the layer tree
eric8810 Oct 21, 2026
448460b
fix: restore master content lost by the cause stack merge
cunninghamcard-bit Oct 21, 2026
1a33d20
test: make the refused connection fixture portable
cunninghamcard-bit Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Breaking

- `ApiCallError` keeps its cause, as the AI SDK's `APICallError.cause`: the
transport error with its whole source chain down to the OS error, the body
parse error, or the error a response handler returned (`.caused_by(..)`);
`invalid_response_api_call` takes that error instead of a message, its text
becoming the message. A cause is a nested `AiMuxError` or a `SourceError` chain of an OS, TLS or
parser error, and is what `source()` returns; `RetryError::source()` is the
last attempt. `FetchError` carries a `SourceError` without the request URL,
so transport error messages lose it too: `error sending request for url
- Errors keep their cause, as the AI SDK's `cause`. `ApiCallError` has
`cause` (`.caused_by(..)`): the transport error with its whole source chain
down to the OS error, the body parse error, or the error a response handler
returned; `invalid_response_api_call` takes that error instead of a
message, its text becoming the message. `JsonParse`, `NoOutputGenerated`, `InvalidResponseData`,
`InvalidArgument`, `InvalidPrompt` and `Other` are `{ message, cause }`
(build them with `AiMuxError::json_parse(..)`, `invalid_argument(..)`, ...),
and `InvalidToolInput.cause` is the parse or validation error itself instead
of its text. A value that fails its schema is the new `TypeValidation`
error (`AI_TypeValidationError`, with the value), the cause of an
`InvalidToolInput` or of a structured output that does not match. A cause
is a nested `AiMuxError` or a `SourceError` chain of an OS, TLS or parser
error, and is what `source()` returns; `RetryError::source()` is the last
attempt. `FetchError` and download errors carry a `SourceError` without the
request URL, so their messages lose it too: `error sending request for url
(…)` is now `error sending request`. Other messages are unchanged.

- aimux's public JSON equals the AI SDK's JSON. Struct fields serialize
Expand Down
4 changes: 2 additions & 2 deletions aimux-core/src/decision_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ pub struct DecideResult {
}

fn invalid_input(parameter: &str, message: &str) -> AiMuxError {
AiMuxError::InvalidArgument(format!(
AiMuxError::invalid_argument(format!(
"Invalid argument for parameter {parameter}: {message}"
))
}
Expand Down Expand Up @@ -429,7 +429,7 @@ pub fn validate_decision_input(questions: &DecisionQuestions) -> Result<(), AiMu
const TOLERANCE: f64 = 1e-6;

fn invalid_answer(message: String) -> AiMuxError {
AiMuxError::InvalidResponseData(message)
AiMuxError::invalid_response_data(message)
}

fn is_probability(value: f64) -> bool {
Expand Down
57 changes: 36 additions & 21 deletions aimux-core/src/decision_replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::decision_model::{
DecideOptions, DecideResult, DecisionCallOptions, DecisionModel, DecisionQuestionType,
DecisionResult, DecisionUsage, decide,
};
use crate::error::ErrorCause;
use crate::recording::{OutcomeStatus, Recording, RecordingOperation};

/// Appended to a replay error when recordings written by aimux 0.5 or
Expand Down Expand Up @@ -52,8 +53,8 @@ impl MockDecisionReplayModel {
&& rec.provider.provider == provider
&& rec.provider.model_id == model_id
}) {
return Err(AiMuxError::InvalidArgument(
"decision replay: no matching provider/model recording".into(),
return Err(AiMuxError::invalid_argument(
"decision replay: no matching provider/model recording",
));
}
Ok(Self {
Expand All @@ -67,21 +68,24 @@ impl MockDecisionReplayModel {
/// # Errors
/// Returns read/parse errors or an error if the file contains no decisions.
pub fn from_jsonl(path: impl AsRef<std::path::Path>) -> Result<Self, AiMuxError> {
let content = std::fs::read_to_string(path)
.map_err(|e| AiMuxError::InvalidArgument(format!("decision replay: {e}")))?;
let content = std::fs::read_to_string(path).map_err(|e| AiMuxError::InvalidArgument {
message: format!("decision replay: {e}"),
cause: Some(ErrorCause::source(&e)),
})?;
let recordings: Vec<Recording> = content
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
serde_json::from_str(line).map_err(|e| AiMuxError::JsonParse(e.to_string()))
serde_json::from_str(line).map_err(|e| AiMuxError::JsonParse {
message: e.to_string(),
cause: Some(ErrorCause::source(&e)),
})
})
.collect::<Result<_, _>>()?;
let first = recordings
.iter()
.find(|rec| rec.input.operation == RecordingOperation::Decision)
.ok_or_else(|| {
AiMuxError::InvalidArgument("decision replay: no decisions in file".into())
})?;
.ok_or_else(|| AiMuxError::invalid_argument("decision replay: no decisions in file"))?;
Self::new(
first.provider.provider.clone(),
first.provider.model_id.clone(),
Expand Down Expand Up @@ -114,8 +118,12 @@ impl DecisionModel for MockDecisionReplayModel {
DecisionQuestionType::ALL
}
async fn do_decide(&self, options: &DecisionCallOptions) -> Result<DecisionResult, AiMuxError> {
let requested =
key(&serde_json::to_value(options).map_err(|e| AiMuxError::JsonParse(e.to_string()))?);
let requested = key(
&serde_json::to_value(options).map_err(|e| AiMuxError::JsonParse {
message: e.to_string(),
cause: Some(ErrorCause::source(&e)),
})?,
);
let rec = self
.recordings
.iter()
Expand Down Expand Up @@ -143,7 +151,7 @@ impl DecisionModel for MockDecisionReplayModel {
"; {older} recording(s) of this model were {OLDER_RECORDING} and never match"
));
}
AiMuxError::InvalidArgument(message)
AiMuxError::invalid_argument(message)
})?;
if rec.outcome.status != OutcomeStatus::Success {
return Err(rec
Expand All @@ -152,7 +160,7 @@ impl DecisionModel for MockDecisionReplayModel {
.clone()
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_else(|| {
AiMuxError::InvalidResponseData(
AiMuxError::invalid_response_data(
rec.outcome
.error
.clone()
Expand All @@ -162,9 +170,12 @@ impl DecisionModel for MockDecisionReplayModel {
}
let recorded: DecideResult =
serde_json::from_value(rec.outcome.decision_result.clone().ok_or_else(|| {
AiMuxError::InvalidResponseData("decision replay: missing recorded result".into())
AiMuxError::invalid_response_data("decision replay: missing recorded result")
})?)
.map_err(|e| AiMuxError::InvalidResponseData(format!("decision replay result: {e}")))?;
.map_err(|e| AiMuxError::InvalidResponseData {
message: format!("decision replay result: {e}"),
cause: Some(ErrorCause::source(&e)),
})?;
Ok(DecisionResult {
answers: recorded.answers,
rounding: recorded.rounding,
Expand All @@ -188,8 +199,8 @@ pub async fn replay_decision_with_model(
model: &dyn DecisionModel,
) -> Result<DecideResult, AiMuxError> {
if recording.input.operation != RecordingOperation::Decision {
return Err(AiMuxError::InvalidArgument(
"decision replay requires a decision recording".into(),
return Err(AiMuxError::invalid_argument(
"decision replay requires a decision recording",
));
}
let unread = crate::replay::snake_case_option_keys(&recording.input.options);
Expand All @@ -199,7 +210,7 @@ pub async fn replay_decision_with_model(
} else {
String::new()
};
return Err(AiMuxError::JsonParse(format!(
return Err(AiMuxError::json_parse(format!(
"decision replay: {older}this version reads the options camelCase and would \
ignore {}",
unread.join(", ")
Expand All @@ -208,11 +219,15 @@ pub async fn replay_decision_with_model(
let options = crate::replay::readable_options(&recording.input.options);
let options: DecideOptions = serde_json::from_value(options).map_err(|e| {
if recording.provider.provider_id.is_empty() {
AiMuxError::JsonParse(format!(
"decision replay: the recording was {OLDER_RECORDING}: {e}"
))
AiMuxError::JsonParse {
message: format!("decision replay: the recording was {OLDER_RECORDING}: {e}"),
cause: Some(ErrorCause::source(&e)),
}
} else {
AiMuxError::JsonParse(e.to_string())
AiMuxError::JsonParse {
message: e.to_string(),
cause: Some(ErrorCause::source(&e)),
}
}
})?;
decide(model, options).await
Expand Down
58 changes: 45 additions & 13 deletions aimux-core/src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ use futures::future::BoxFuture;
use crate::abort_signal::AbortSignal;
use crate::download_guard::{validate_download_target, validate_download_url};
use crate::error::AiMuxError;
use crate::error::ErrorCause;
use crate::shared::{FileBytes, GeneratedFileData};

const DEFAULT_MAX_DOWNLOAD_SIZE: usize = 2 * 1024 * 1024 * 1024;
Expand Down Expand Up @@ -161,7 +162,7 @@ impl DownloadFunction {
let requested = downloads.len();
let files = (self.0)(downloads).await?;
if files.len() != requested {
return Err(AiMuxError::InvalidArgument(format!(
return Err(AiMuxError::invalid_argument(format!(
"the download function returned {} results for {requested} URLs",
files.len()
)));
Expand Down Expand Up @@ -246,10 +247,10 @@ async fn download_inner(
let addresses = validate_download_target(&current, None).await?;
let host = parsed
.host_str()
.ok_or_else(|| AiMuxError::InvalidArgument("download URL has no host".into()))?;
.ok_or_else(|| AiMuxError::invalid_argument("download URL has no host"))?;
let port = parsed
.port_or_known_default()
.ok_or_else(|| AiMuxError::InvalidArgument("download URL has no known port".into()))?;
.ok_or_else(|| AiMuxError::invalid_argument("download URL has no known port"))?;
let addresses: Vec<_> = addresses
.into_iter()
.map(|address| SocketAddr::new(address, port))
Expand All @@ -261,7 +262,7 @@ async fn download_inner(
.redirect(reqwest::redirect::Policy::none())
.resolve_to_addrs(host, &addresses)
.build()
.map_err(download_error)?;
.map_err(transport_error)?;
let response = client
.get(parsed.clone())
.header(
Expand All @@ -270,21 +271,21 @@ async fn download_inner(
)
.send()
.await
.map_err(download_error)?;
.map_err(transport_error)?;
let status = response.status();
if matches!(status.as_u16(), 301 | 302 | 303 | 307 | 308)
&& let Some(location) = response.headers().get(reqwest::header::LOCATION)
{
if redirects == MAX_DOWNLOAD_REDIRECTS {
return Err(AiMuxError::Other("download exceeded 10 redirects".into()));
return Err(AiMuxError::other("download exceeded 10 redirects"));
}
let location = location.to_str().map_err(download_error)?;
current = parsed.join(location).map_err(download_error)?.to_string();
drop(response);
continue;
}
if !status.is_success() {
return Err(AiMuxError::Other(format!(
return Err(AiMuxError::other(format!(
"download failed with HTTP status {status}"
)));
}
Expand All @@ -302,7 +303,7 @@ async fn download_inner(
let mut stream = response.bytes_stream();
let mut data = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(download_error)?;
let chunk = chunk.map_err(transport_error)?;
if chunk.len() > max_bytes.saturating_sub(data.len()) {
return Err(size_error(max_bytes));
}
Expand All @@ -313,12 +314,21 @@ async fn download_inner(
unreachable!("download loop returns a response or redirect error")
}

fn download_error(error: impl std::fmt::Display) -> AiMuxError {
AiMuxError::Other(format!("download failed: {error}"))
/// A reqwest failure without its URL: a signed download URL carries its
/// credentials in the query.
fn transport_error(error: reqwest::Error) -> AiMuxError {
download_error(error.without_url())
}

fn download_error(error: impl std::error::Error + 'static) -> AiMuxError {
AiMuxError::Other {
message: format!("download failed: {error}"),
cause: Some(ErrorCause::source(&error)),
}
}

fn size_error(max_bytes: usize) -> AiMuxError {
AiMuxError::Other(format!(
AiMuxError::other(format!(
"download exceeded maximum size of {max_bytes} bytes"
))
}
Expand All @@ -327,6 +337,28 @@ fn size_error(max_bytes: usize) -> AiMuxError {
mod tests {
use super::*;

#[tokio::test]
async fn a_transport_failure_keeps_the_signed_query_out_of_message_and_cause() {
let error = reqwest::Client::new()
.get("http://127.0.0.1:1/file.png?X-Amz-Signature=SECRET")
.send()
.await
.unwrap_err();
assert!(error.to_string().contains("SECRET"), "{error}");

let error = transport_error(error);
let AiMuxError::Other { message, cause } = &error else {
panic!("{error:?}");
};
assert!(message.starts_with("download failed: "), "{message}");
assert!(cause.is_some());
let json = serde_json::to_string(&error).unwrap();
assert!(
!json.contains("SECRET") && !json.contains("127.0.0.1"),
"{json}"
);
}

use std::sync::Mutex;

fn recording_download(requests: Arc<Mutex<Vec<String>>>) -> Download {
Expand Down Expand Up @@ -392,7 +424,7 @@ mod tests {
.await
.unwrap_err();
assert!(
matches!(&error, AiMuxError::InvalidArgument(message)
matches!(&error, AiMuxError::InvalidArgument { message, .. }
if message == "the download function returned 1 results for 2 URLs"),
"{error:?}"
);
Expand All @@ -407,7 +439,7 @@ mod tests {
.await
.unwrap_err();
assert!(
matches!(&error, AiMuxError::InvalidArgument(message)
matches!(&error, AiMuxError::InvalidArgument { message, .. }
if message == "download URL targets a non-public address: 127.0.0.1"),
"{error:?}"
);
Expand Down
Loading
Loading