Follow-up to #162 / #165. #165 landed the Core-owned parse_tool_call + optional ToolCallRepair (the AI SDK parseToolCall / repairToolCall shape). Two pieces of the original three-layer plan (wire → tracker → core) are still open, and one of them is the structural root of the P1 findings in the #165 review.
1. One input type, two meanings
StreamPart::ToolCall.input and ContentPart::ToolCall.input are both serde_json::Value. The doc comment on stream_part.rs states the contract literally: "Serialized argument text in a Value::String from do_stream; parsed input after stream_text." do_stream and stream_text share the same StreamResult/StreamPart, so the raw/parsed distinction lives in a comment, not in the type.
Consequences today:
- 17 provider sites wrap the raw argument text as
Value::String(...) (openai/model.rs:774, google/model.rs:438, xai/model.rs:506, replay.rs:863, …).
- Core undoes it in exactly one place,
parse_tool_call::raw_tool_input, with the rule "a string passes through, anything else re-serializes" — which is why a valid JSON string primitive and a malformed argument blob were indistinguishable (the "hello" → hello finding), and why null had to be special-cased.
The AI SDK keeps these apart by type: provider-side LanguageModelV4ToolCall.input: string, public-side TypedToolCall is parsed.
Proposal
Introduce one enum and use it in both StreamPart::ToolCall and ContentPart::ToolCall:
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
pub enum ToolInput {
Raw(String), // provider output, unparsed
Parsed(Value), // after Core parse/repair
}
- Providers may only construct
Raw; Core's parse_tool_call consumes Raw and emits Parsed; raw_tool_input is deleted.
- Wire shape is unchanged: bindings only ever observe
Parsed (via stream_text / generate_text), which serializes as the value itself.
- Scope: 2 type definitions, 17 mechanical provider edits (
Value::String(x) → ToolInput::Raw(x)), 1 Core site, plus the openai_output renderer which currently branches on Value::String.
The alternative — a separate provider-facing stream part type as in the AI SDK — is cleaner at the API surface but changes every do_stream signature plus moa/replay/openai_output; defer it until the provider trait has another reason to move.
2. StreamingToolCallTracker is dead code
aimux-stream/src/streaming_tool_call_tracker.rs (429 lines) has zero users in aimux-providers and aimux-core; the only references are its own tests and internal docs. Providers use aimux-stream for SseStream / SseError only. Each OpenAI-compatible provider keeps its own accumulator, and replay.rs carries a third (ToolCallAccumulator).
Proposal
Order
Two independent PRs: (a) tracker deletion first, pure removal; (b) ToolInput enum. Neither touches the C ABI or the binding wire format.
Refs: #162, #165, #167, RFC-0016.
Follow-up to #162 / #165. #165 landed the Core-owned
parse_tool_call+ optionalToolCallRepair(the AI SDKparseToolCall/repairToolCallshape). Two pieces of the original three-layer plan (wire → tracker → core) are still open, and one of them is the structural root of the P1 findings in the #165 review.1. One
inputtype, two meaningsStreamPart::ToolCall.inputandContentPart::ToolCall.inputare bothserde_json::Value. The doc comment onstream_part.rsstates the contract literally: "Serialized argument text in aValue::Stringfromdo_stream; parsed input afterstream_text."do_streamandstream_textshare the sameStreamResult/StreamPart, so the raw/parsed distinction lives in a comment, not in the type.Consequences today:
Value::String(...)(openai/model.rs:774,google/model.rs:438,xai/model.rs:506,replay.rs:863, …).parse_tool_call::raw_tool_input, with the rule "a string passes through, anything else re-serializes" — which is why a valid JSON string primitive and a malformed argument blob were indistinguishable (the"hello"→hellofinding), and whynullhad to be special-cased.The AI SDK keeps these apart by type: provider-side
LanguageModelV4ToolCall.input: string, public-sideTypedToolCallis parsed.Proposal
Introduce one enum and use it in both
StreamPart::ToolCallandContentPart::ToolCall:Raw; Core'sparse_tool_callconsumesRawand emitsParsed;raw_tool_inputis deleted.Parsed(viastream_text/generate_text), which serializes as the value itself.Value::String(x)→ToolInput::Raw(x)), 1 Core site, plus theopenai_outputrenderer which currently branches onValue::String.The alternative — a separate provider-facing stream part type as in the AI SDK — is cleaner at the API surface but changes every
do_streamsignature plus moa/replay/openai_output; defer it until the provider trait has another reason to move.2.
StreamingToolCallTrackeris dead codeaimux-stream/src/streaming_tool_call_tracker.rs(429 lines) has zero users inaimux-providersandaimux-core; the only references are its own tests and internal docs. Providers useaimux-streamforSseStream/SseErroronly. Each OpenAI-compatible provider keeps its own accumulator, andreplay.rscarries a third (ToolCallAccumulator).Proposal
StreamingToolCallTrackerand its tests. Per-provider accumulation is the accepted design (one accumulator per wire format); a shared tracker earns its place only if two providers converge on it, and none has.replay.rs's accumulator alone: it sits in Core, cannot depend on provider-side code, and disappears with Complete recording and replay (RFC-0023): protocol-generic replay, one record format, all bindings #167 (transport-level replay) anyway. Do not build a shared tracker to serve it.Order
Two independent PRs: (a) tracker deletion first, pure removal; (b)
ToolInputenum. Neither touches the C ABI or the binding wire format.Refs: #162, #165, #167, RFC-0016.