Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,70 @@ All notable changes to aimux are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Breaking

**Rust (aimux-stream)**

- `StreamingToolCallTracker` rewritten to match the current AI SDK
(`@ai-sdk/provider-utils`) design; the previous version was a port of an
older, index-only tracker that nothing in the workspace used. Deltas are now
correlated by wire `id`, `index` and function name (ambiguous deltas are
dropped), ids are de-duplicated with bounded suffixes, blank function names
are ignored, and `flush` orders calls by index only when every call has one.
API changes: `process_delta` and `flush` now return the emitted
`ToolCallStreamPart`s instead of buffering them (`parts()` / `clear_parts()`
are gone); `with_max_index` and `TrackerError::{MissingId, IndexOutOfRange}`
are removed; `TrackerError::IdExhausted` is added; the builder closures must
be `Send + Sync`.
- Added `StreamingToolCallArgumentState` and `starts_with_structured_value`,
the structural JSON-prefix tracker the new correlation logic relies on.
- `SseStream` is now a thin adapter over the `sse-stream` crate (WHATWG
event-stream parsing), mirroring how the AI SDK's `parseJsonEventStream`
wraps `eventsource-parser`: `\n`, `\r` and `\r\n` line endings in any
mix, a leading UTF-8 BOM is stripped, a field line without `:` counts as an
empty value, an empty `event:` is `None`, an `id` containing U+0000 is
ignored, `retry` must be all ASCII digits, and a block is dispatched only
when it had a `data` line. There is no event size limit any more (as
upstream): `with_max_event_size` and `SseError::FrameTooLarge` are gone,
which fixes streams carrying multi-megabyte events (OpenAI Responses and
Gemini image generation). `SseError::Stream` carries the transport error
as its source instead of a `String`; `SseError::Utf8` holds a
`str::Utf8Error`; `SseError::Decode` is added. Decoder errors (invalid
UTF-8, transport failure) now end the stream after being reported.
`SseStream` requires the body error type to implement `std::error::Error +
Send + Sync + 'static`.
- Removed `NdjsonStream` / `NdjsonError`: nothing in the workspace used them
and the AI SDK has no counterpart. `tokio` is now a dev-dependency only and
the unused direct `serde` dependency is dropped.
- `StreamingToolCallTracker`, `StreamingToolCallArgumentState`,
`starts_with_structured_value` and `ToolCallStreamPart` are no longer in
`aimux-stream`, which is now SSE decoding only (the role
`eventsource-parser` plays for the AI SDK).

**Rust (aimux-provider-utils)**

- Gains `StreamingToolCallTracker` (plus `StreamingToolCallDelta`,
`StreamingToolCallFunction`, `TypeValidation`, `TrackerError`,
`StreamingToolCallArgumentState`), where `@ai-sdk/provider-utils` keeps it.
It emits `aimux_core::StreamPart` tool-input parts directly; the separate
`ToolCallStreamPart` event type is gone. `TrackerError` converts into
`AiMuxError::InvalidResponseData`. Metadata hooks are typed with
`serde_json::Value` / `ProviderMetadata` instead of a generic parameter.

**Rust (aimux-providers)**

- The OpenAI chat-completions stream (`openai/model.rs`, which serves every
registry-backed provider) correlates `tool_calls` deltas with the tracker
instead of by `index` alone: deltas are matched by wire id, index and
function name; a continuation without an id follows its call; indices
reused across parallel calls stay distinct; ambiguous deltas are dropped;
a call whose delta carries no id gets a generated `tool-call` /
`tool-call-N` id instead of an empty string; a new call without a function
name ends the stream with `InvalidResponseData` (previously it started a
call with an empty name). `DeltaToolCall.index` is now `Option<usize>`.

## [0.5.0] - 2026-09-27

**Breaking release.** 13 PRs since 0.3.0: the cross-language error model
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ to set up a development environment, run the tests, and submit changes.
aimux/
├── aimux-core/ # Core abstractions: LanguageModel / Provider / Message / StreamPart
├── aimux-providers/ # provider implementations + cassettes (counts: docs/api/providers.md)
├── aimux-stream/ # SSE / NDJSON stream parsing
├── aimux-provider-utils/ # HTTP utilities: retry, backoff, error parsing, API-key loading
├── aimux-stream/ # SSE decoding
├── aimux-provider-utils/ # HTTP utilities: retry, backoff, error parsing, API-key loading, streamed tool-call tracking
├── aimux-ffi/ # C ABI (opaque handle + JSON + push callback) for non-native bindings
├── bindings/ # Node, Python, Swift, Kotlin, Flutter, Go, C — share one Rust core
├── contract-tests/ # Shared JSON fixtures exercised across languages
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ middleware, and telemetry per request).
aimux/
├── aimux-core # Core abstractions: LanguageModel / Provider / Message / StreamPart
├── aimux-providers # Provider implementations — registry-backed + typed (docs/api/providers.md)
├── aimux-stream # SSE / NDJSON stream parsing
├── aimux-provider-utils # One-exchange HTTP helpers, response handlers, API-key loading
├── aimux-stream # SSE decoding
├── aimux-provider-utils # One-exchange HTTP helpers, response handlers, API-key loading, streamed tool-call tracking
├── aimux-ffi # C ABI (opaque handles + JSON results + owned aimux_error_t *) for non-native bindings
└── tools/ # aimux-cli (cache probe) · aimux-replay · aimux-web (console)
```
Expand All @@ -122,8 +122,8 @@ cargo add aimux-core aimux-providers
|-------|-------------|-----------|
| `aimux-core` | Core abstractions: `LanguageModel` / `Provider` / `Message` / `StreamPart` | [crates.io](https://crates.io/crates/aimux-core) |
| `aimux-providers` | Provider implementations — [registry-backed + typed](docs/api/providers.md) | [crates.io](https://crates.io/crates/aimux-providers) |
| `aimux-stream` | SSE / NDJSON stream parsing | [crates.io](https://crates.io/crates/aimux-stream) |
| `aimux-provider-utils` | One-exchange HTTP helpers and typed response handlers | [crates.io](https://crates.io/crates/aimux-provider-utils) |
| `aimux-stream` | SSE decoding | [crates.io](https://crates.io/crates/aimux-stream) |
| `aimux-provider-utils` | One-exchange HTTP helpers, typed response handlers, streamed tool-call tracking | [crates.io](https://crates.io/crates/aimux-provider-utils) |
| `aimux-ffi` | C ABI for non-native bindings | [crates.io](https://crates.io/crates/aimux-ffi) |

**Node.js**:
Expand Down
1 change: 1 addition & 0 deletions aimux-provider-utils/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ reqwest = { workspace = true }
http = "1"
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
Expand Down
12 changes: 11 additions & 1 deletion aimux-provider-utils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
//! Shared utilities for provider implementations.
//!
//! Provides one-exchange HTTP helpers, response handlers, API key loading,
//! header management, and URL utilities — the Rust equivalents of
//! header management, URL utilities and the streamed tool-call tracker for
//! the OpenAI chat-completions wire format — the Rust equivalents of
//! `@ai-sdk/provider-utils`. Operation retry and timeout live in `aimux-core`.

pub mod api_key;
Expand All @@ -19,6 +20,8 @@ pub mod post_to_api;
pub mod read_response_with_size_limit;
pub mod response_handler;
pub mod retry;
pub mod streaming_tool_call_argument_state;
pub mod streaming_tool_call_tracker;
pub mod url;
/// WebSocket client for realtime provider APIs (RFC-0028). Empty unless the
/// `ws` feature is enabled.
Expand All @@ -43,4 +46,11 @@ pub use response_handler::{
stream_error_api_call,
};
pub use retry::RetryConfig;
pub use streaming_tool_call_argument_state::{
StreamingToolCallArgumentState, starts_with_structured_value,
};
pub use streaming_tool_call_tracker::{
StreamingToolCallDelta, StreamingToolCallFunction, StreamingToolCallTracker, TrackerError,
TypeValidation,
};
pub use url::{validate_base_url, without_trailing_slash, without_trailing_slash_opt};
4 changes: 2 additions & 2 deletions aimux-provider-utils/src/response_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,14 +392,14 @@ where
match item {
Ok(event) if event.data == "[DONE]" => continue,
Ok(event) => yield serde_json::from_str::<T>(&event.data).map_err(AiMuxError::from),
Err(aimux_stream::SseError::Stream(message)) => {
Err(aimux_stream::SseError::Stream(error)) => {
// Preserve response transport failures as ApiCallError
// items. Framing/parser failures remain JsonParse below.
yield Err(AiMuxError::ApiCall(Box::new(ApiCallError {
response_headers: Some(stream_headers.clone()),
is_retryable: true,
..ApiCallError::new(
message,
error.to_string(),
stream_url.clone(),
stream_request_body_values.clone(),
)
Expand Down
116 changes: 116 additions & 0 deletions aimux-provider-utils/src/streaming_tool_call_argument_state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! Incremental structure tracking for streamed tool-call arguments.
//!
//! Rust translation of `@ai-sdk/provider-utils`'s
//! `StreamingToolCallArgumentState`
//! (`packages/provider-utils/src/streaming-tool-call-argument-state.ts`).
//!
//! The state is intentionally *structural* rather than a JSON parse: a
//! currently parsable scalar can still be the prefix of a later value, but a
//! closed top-level `{…}` / `[…]` cannot be extended.

#[derive(Debug, Clone, PartialEq, Eq)]
enum ArgumentStructure {
Undetermined,
Other,
Structured {
stack: Vec<char>,
in_string: bool,
escaped: bool,
complete: bool,
},
}

/// Whether `value` (ignoring leading whitespace) starts with `{` or `[`.
#[must_use]
pub fn starts_with_structured_value(value: Option<&str>) -> bool {
value
.and_then(|v| v.trim_start().chars().next())
.is_some_and(|c| c == '{' || c == '[')
}

/// Incrementally tracks whether streamed tool-call arguments contain a
/// complete structured JSON value.
#[derive(Debug, Clone)]
pub struct StreamingToolCallArgumentState {
structure: ArgumentStructure,
}

impl StreamingToolCallArgumentState {
/// Create a state seeded with `initial_value`.
#[must_use]
pub fn new(initial_value: &str) -> Self {
let mut state = Self {
structure: ArgumentStructure::Undetermined,
};
state.append(initial_value);
state
}

/// `true` once a top-level `{…}` / `[…]` value has been closed.
#[must_use]
pub fn has_complete_structured_value(&self) -> bool {
matches!(
self.structure,
ArgumentStructure::Structured { complete: true, .. }
)
}

/// Feed the next argument fragment.
pub fn append(&mut self, delta: &str) {
for character in delta.chars() {
match &mut self.structure {
ArgumentStructure::Undetermined => {
if character.is_whitespace() {
continue;
}
self.structure = if character == '{' || character == '[' {
ArgumentStructure::Structured {
stack: vec![character],
in_string: false,
escaped: false,
complete: false,
}
} else {
ArgumentStructure::Other
};
}
ArgumentStructure::Other | ArgumentStructure::Structured { complete: true, .. } => {
}
ArgumentStructure::Structured {
stack,
in_string,
escaped,
complete,
} => {
if *in_string {
if *escaped {
*escaped = false;
} else if character == '\\' {
*escaped = true;
} else if character == '"' {
*in_string = false;
}
continue;
}

match character {
'"' => *in_string = true,
'{' | '[' => stack.push(character),
'}' | ']' => {
let expected = if character == '}' { '{' } else { '[' };
if stack.last() != Some(&expected) {
self.structure = ArgumentStructure::Other;
continue;
}
stack.pop();
if stack.is_empty() {
*complete = true;
}
}
_ => {}
}
}
}
}
}
}
Loading
Loading