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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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`, `ToolCallStreamPart` and `TrackerError` are no
longer exported from `aimux-stream`; nothing in the workspace used them and
the AI SDK keeps the tracker in `@ai-sdk/provider-utils`, next to the
provider code that drives it. See `aimux-provider-utils` below.

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

- Gains `StreamingToolCallTracker` (plus `StreamingToolCallDelta`,
`TypeValidation`, `TrackerError`, `StreamingToolCallArgumentState` and
`starts_with_structured_value`), a port of the current
`@ai-sdk/provider-utils` tracker. The previous `aimux-stream` version was
a port of an older, index-only tracker. Deltas are correlated by wire
`id`, `index` and function name (ambiguous deltas are dropped), ids are
de-duplicated with `-N` suffixes, blank function names are ignored, and
`finish` orders calls by index only when every call has one. It emits
`aimux_core::StreamPart` tool-input parts directly (`ToolInputStart` /
`ToolInputDelta` / `ToolInputEnd` / `ToolCall`); there is no separate
`ToolCallStreamPart` event type. `StreamingToolCallDelta` borrows from the
provider's wire type and flattens upstream's `function` object;
`process` and `finish` append to a caller-owned `Vec<StreamPart>`, and
`finish` consumes the tracker. The upstream `extractMetadata` /
`buildToolCallProviderMetadata` hooks are replaced by
`StreamingToolCallDelta::provider_metadata`, read from the delta that
starts a call. `TrackerError` converts into
`AiMuxError::InvalidResponseData`.

**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

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 parsing, streamed tool-call tracking
├── aimux-provider-utils/ # HTTP utilities: retry, backoff, error parsing, API-key loading
├── aimux-stream/ # SSE parsing
├── 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 parsing, streamed tool-call tracking
├── aimux-provider-utils # One-exchange HTTP helpers, response handlers, API-key loading
├── aimux-stream # SSE parsing
├── 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 parsing, streamed tool-call tracking | [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 parsing | [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
11 changes: 10 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,10 @@ 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, StreamingToolCallTracker, TrackerError, TypeValidation,
};
pub use url::{validate_base_url, without_trailing_slash, without_trailing_slash_opt};
115 changes: 115 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,115 @@
//! Incremental structure tracking for streamed tool-call arguments.
//!
//! Port of `@ai-sdk/provider-utils`'s `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.

/// Whether `value` (ignoring leading whitespace) starts with `{` or `[`.
#[must_use]
pub fn starts_with_structured_value(value: Option<&str>) -> bool {
value.is_some_and(|v| matches!(v.trim_start().as_bytes().first(), Some(b'{' | b'[')))
}

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

#[derive(Debug, Clone)]
enum Structure {
/// Only whitespace so far.
Undetermined,
/// Not a structured value (a scalar, or a mismatched bracket).
Other,
/// Inside a top-level `{…}` / `[…]`; `stack` holds the open brackets.
Open {
stack: Vec<u8>,
in_string: bool,
escaped: bool,
},
/// The top-level value has been closed.
Complete,
}

impl StreamingToolCallArgumentState {
/// Create a state seeded with `initial_value`.
#[must_use]
pub fn new(initial_value: &str) -> Self {
let mut state = Self {
structure: Structure::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, Structure::Complete)
}

/// Feed the next argument fragment.
pub fn append(&mut self, delta: &str) {
let mut rest = delta;
if matches!(self.structure, Structure::Undetermined) {
rest = rest.trim_start();
match rest.as_bytes().first() {
None => return,
Some(b'{' | b'[') => {
self.structure = Structure::Open {
stack: Vec::new(),
in_string: false,
escaped: false,
};
}
Some(_) => {
self.structure = Structure::Other;
return;
}
}
}

let Structure::Open {
stack,
in_string,
escaped,
} = &mut self.structure
else {
return;
};

// Every structural character is ASCII and UTF-8 continuation bytes
// never collide with ASCII, so scanning bytes is exact.
for &byte in rest.as_bytes() {
if *in_string {
if *escaped {
*escaped = false;
} else if byte == b'\\' {
*escaped = true;
} else if byte == b'"' {
*in_string = false;
}
continue;
}
match byte {
b'"' => *in_string = true,
b'{' | b'[' => stack.push(byte),
b'}' | b']' => {
let opening = if byte == b'}' { b'{' } else { b'[' };
if stack.pop() != Some(opening) {
self.structure = Structure::Other;
return;
}
if stack.is_empty() {
self.structure = Structure::Complete;
return;
}
}
_ => {}
}
}
}
}
Loading
Loading