From b28c685da91ff9b6bdeeeb4dc6271dd96ecce286 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Mon, 24 Aug 2026 09:37:16 +0800 Subject: [PATCH 01/19] feat(core)!: parse and validate tool inputs at the Core boundary Providers deliver the model's raw tool-argument text; Core parses it as exact JSON and validates it against the function tool's JSON Schema, following the AI SDK parseToolCall contract. Invalid calls are returned as tool calls marked invalid with a typed error (NoSuchTool / InvalidToolInput / ToolCallRepair) instead of silently passing raw strings through. The optional repair_tool_call callback gets one attempt and its replacement is revalidated from scratch. Provider-executed dynamic calls bypass the tool-set lookup, as in the AI SDK. The C ABI reports the new variants under the existing Tool error code; Node types carry them fully. --- Cargo.toml | 3 + aimux-core/Cargo.toml | 1 + aimux-core/src/error.rs | 35 +- aimux-core/src/generate.rs | 125 +++++- aimux-core/src/lib.rs | 5 +- aimux-core/src/moa.rs | 2 + aimux-core/src/openai_output.rs | 31 +- aimux-core/src/replay.rs | 56 +-- aimux-core/src/result.rs | 2 + aimux-core/src/stream_part.rs | 8 + aimux-core/src/tool.rs | 313 +++++++++++++- aimux-core/tests/tool_input_test.rs | 408 ++++++++++++++++++ aimux-ffi/aimux-error.h | 22 +- aimux-ffi/src/lib.rs | 98 ++++- aimux-ffi/tests/exports_smoke_test.rs | 17 +- aimux-providers/src/anthropic/stream.rs | 31 +- aimux-providers/src/bedrock/model.rs | 14 +- aimux-providers/src/cohere/model.rs | 58 ++- aimux-providers/src/google/model.rs | 30 +- aimux-providers/src/huggingface/responses.rs | 19 +- aimux-providers/src/mistral/model.rs | 8 +- aimux-providers/src/open_responses.rs | 9 +- aimux-providers/src/openai/model.rs | 36 +- .../src/openai/responses/responses_convert.rs | 27 +- aimux-providers/src/vertex/anthropic_model.rs | 26 +- aimux-providers/src/vertex/model.rs | 20 +- aimux-providers/src/xai/model.rs | 13 +- aimux-providers/src/xai/responses/mod.rs | 20 +- aimux-providers/tests/alibaba_test.rs | 2 +- .../tests/anthropic_aws_model_test.rs | 7 +- aimux-providers/tests/anthropic_model_test.rs | 26 +- aimux-providers/tests/azure_model_test.rs | 4 +- aimux-providers/tests/azure_responses_test.rs | 5 +- aimux-providers/tests/bedrock_model_test.rs | 7 +- .../tests/bedrock_remaining_test.rs | 5 +- aimux-providers/tests/codex_test.rs | 2 +- aimux-providers/tests/cohere_model_test.rs | 21 +- .../tests/data_loss_regression_test.rs | 35 +- aimux-providers/tests/deepseek_chat_test.rs | 10 +- aimux-providers/tests/google_model_test.rs | 10 +- .../tests/google_provider_tools_test.rs | 12 +- aimux-providers/tests/groq_test.rs | 7 +- .../tests/huggingface_responses_test.rs | 14 +- aimux-providers/tests/mistral_model_test.rs | 14 +- aimux-providers/tests/open_responses_test.rs | 7 +- .../tests/openai_compatible_test.rs | 8 +- aimux-providers/tests/openai_model_test.rs | 19 +- .../tests/openai_responses_test.rs | 7 +- aimux-providers/tests/openrouter_test.rs | 4 +- aimux-providers/tests/vertex_model_test.rs | 8 +- aimux-providers/tests/xai_responses_test.rs | 4 +- aimux-providers/tests/xai_test.rs | 10 +- bindings/flutter/lib/errors.dart | 101 ++++- bindings/flutter/lib/types.dart | 19 + bindings/flutter/lib/types.g.dart | 7 + bindings/flutter/test/errors_test.dart | 21 +- .../flutter/test/typed_round_trip_test.dart | 21 + bindings/go/aimux.go | 15 + bindings/go/error.go | 41 +- bindings/go/error_test.go | 7 +- bindings/go/roundtrip_test.go | 6 +- bindings/go/types.go | 5 + .../ai/arcships/aimux/AimuxException.java | 154 ++++++- .../main/java/ai/arcships/aimux/AimuxFFI.java | 12 + .../java/ai/arcships/aimux/AimuxResult.java | 9 +- .../main/java/ai/arcships/aimux/Types.java | 60 ++- .../ai/arcships/aimux/AimuxExceptionTest.java | 16 +- .../ai/arcships/aimux/TypedModelTest.java | 20 +- .../main/kotlin/ai/arcships/aimux/Errors.kt | 106 ++++- .../main/kotlin/ai/arcships/aimux/Model.kt | 9 +- .../kotlin/ai/arcships/aimux/Multimodal.kt | 1 + .../main/kotlin/ai/arcships/aimux/Types.kt | 15 +- .../kotlin/ai/arcships/aimux/ErrorsTest.kt | 1 + .../ai/arcships/aimux/TypedModelTest.kt | 19 + bindings/node/__test__/e2e.test.ts | 4 +- bindings/node/__test__/wrapper.test.ts | 3 +- bindings/node/src/error.rs | 38 +- bindings/node/src/error.ts | 20 +- bindings/node/src/index.ts | 9 +- bindings/node/src/types/AiMuxError.ts | 2 +- bindings/node/src/types/GenerateContent.ts | 7 +- bindings/node/src/types/StreamPart.ts | 17 +- bindings/node/src/types/ToolCall.ts | 19 +- bindings/python/python/aimux/__init__.py | 8 +- bindings/python/python/aimux/wrapper.py | 9 + bindings/python/src/error.rs | 62 ++- bindings/python/tests/test_e2e.py | 4 +- bindings/python/tests/test_wrapper.py | 3 +- bindings/swift/Sources/Aimux/Aimux.swift | 67 ++- bindings/swift/Sources/Aimux/Types.swift | 27 +- .../swift/Tests/AimuxTests/WrapperTests.swift | 23 +- docs/API.md | 9 +- docs/api/c.md | 14 + docs/api/flutter.md | 18 +- docs/api/gaps.md | 25 ++ docs/api/go.md | 16 + docs/api/java.md | 22 +- docs/api/kotlin.md | 10 +- docs/api/node.md | 6 + docs/api/python.md | 6 + docs/api/swift.md | 19 +- tools/aimux-web/web/src/types/AiMuxError.ts | 2 +- 102 files changed, 2363 insertions(+), 456 deletions(-) create mode 100644 aimux-core/tests/tool_input_test.rs diff --git a/Cargo.toml b/Cargo.toml index d66751c5..b97a6175 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,9 @@ url = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# Schema validation +jsonschema = { version = "0.50.1", default-features = false } + # Internal crates aimux-core = { path = "aimux-core", version = "0.3.0" } aimux-providers = { path = "aimux-providers", version = "0.3.0" } diff --git a/aimux-core/Cargo.toml b/aimux-core/Cargo.toml index 0ad5927d..e41db186 100644 --- a/aimux-core/Cargo.toml +++ b/aimux-core/Cargo.toml @@ -27,6 +27,7 @@ tokio-util = "0.7" httpdate = { workspace = true } rand = { workspace = true } base64 = "0.22" +jsonschema = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt", "test-util"] } diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs index d8420941..3fdc23ce 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -178,8 +178,32 @@ pub enum AiMuxError { #[error("invalid response data: {0}")] InvalidResponseData(String), - #[error("tool error: {0}")] - Tool(String), + /// A model requested a tool that was not present in the call's tool set. + /// Message templates for the three tool variants match the AI SDK verbatim + /// (`NoSuchToolError` / `InvalidToolInputError` / `ToolCallRepairError`): + /// the AI SDK feeds these strings back to the model as tool-error content, + /// so the wording — including the available-tools list — is contract. + #[error("Model tried to call unavailable tool '{tool_name}'. {}", no_such_tool_availability(.available_tools))] + NoSuchTool { + tool_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, + }, + + /// A tool call could not be parsed or did not satisfy its input schema. + #[error("Invalid input for tool {tool_name}: {cause}")] + InvalidToolInput { + tool_name: String, + tool_input: String, + cause: String, + }, + + /// The optional repair callback failed while handling an invalid call. + #[error("Error repairing tool call: {cause}")] + ToolCallRepair { + original_error: Box, + cause: Box, + }, #[error("invalid argument: {0}")] InvalidArgument(String), @@ -228,6 +252,13 @@ pub enum AiMuxError { Other(String), } +fn no_such_tool_availability(available_tools: &Option>) -> String { + match available_tools { + Some(tools) => format!("Available tools: {}.", tools.join(", ")), + None => "No tools are available.".to_string(), + } +} + /// Canonical serde classification: syntactically broken/truncated JSON is a /// parse failure; well-formed JSON that violates the expected shape is invalid /// response data. `Io` has no transport context here, so it stays `JsonParse`; diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 0b545711..2b292445 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -27,7 +27,7 @@ use crate::result::{ StreamTextResultAggregated, }; use crate::stream_part::StreamPart; -use crate::tool::Tool; +use crate::tool::{RawToolCall, Tool, ToolCallRepair, parse_tool_call}; use crate::types::{FinishReason, ReasoningEffort, Usage, Warning}; use crate::{AbortSignal, retry, timeout}; @@ -93,6 +93,11 @@ pub struct GenerateTextOptions { pub abort_signal: Option, /// Emit raw provider stream chunks as `StreamPart::Raw` (debugging aid). pub include_raw_chunks: Option, + /// Optional one-shot repair callback for unknown, malformed, or + /// schema-invalid tool calls. + #[serde(skip)] + #[ts(skip)] + pub repair_tool_call: Option, } impl GenerateTextOptions { @@ -384,16 +389,24 @@ impl StreamTextResult { tool_call_id, tool_name, input, + provider_executed, + dynamic, thought_signature, + provider_metadata, + invalid, + error, .. } => { tool_calls.push(crate::tool::ToolCall { tool_call_id: tool_call_id.clone(), tool_name: tool_name.clone(), input: input.clone(), - provider_executed: None, - dynamic: None, + provider_executed, + dynamic, thought_signature: thought_signature.clone(), + provider_metadata, + invalid, + error, }); // Defer adding to response_content_parts — order is rebuilt // after the loop (reasoning → text → tool_calls). @@ -495,9 +508,9 @@ impl StreamTextResult { response_content_parts.push(ContentPart::ToolCall { tool_call_id: tc.tool_call_id.clone(), tool_name: tc.tool_name.clone(), - input: tc.input.clone(), + input: crate::tool::response_message_input(tc), thought_signature: tc.thought_signature.clone(), - provider_options: None, + provider_options: tc.provider_metadata.clone(), }); } let response_messages = if response_content_parts.is_empty() { @@ -567,7 +580,10 @@ pub async fn generate_text( options: GenerateTextOptions, ) -> Result { // 1. Convert user prompt to provider-facing prompt. - let (messages, instructions) = split_prompt(prompt.into(), options.instructions.as_deref()); + let repair_tool_call = options.repair_tool_call.clone(); + let tools = options.tools.clone(); + let operation_instructions = options.instructions.clone(); + let (messages, instructions) = split_prompt(prompt.into(), operation_instructions.as_deref()); let lm_prompt = convert_to_language_model_prompt(&messages, instructions); // 2. Build CallOptions. @@ -680,24 +696,36 @@ pub async fn generate_text( tool_call_id, tool_name, input, + provider_executed, + dynamic, thought_signature, + provider_metadata, .. } => { - tool_calls.push(crate::tool::ToolCall { - tool_call_id: tool_call_id.clone(), - tool_name: tool_name.clone(), - input: input.clone(), - provider_executed: None, - dynamic: None, - thought_signature: thought_signature.clone(), - }); + let parsed = parse_tool_call( + RawToolCall { + tool_call_id: tool_call_id.clone(), + tool_name: tool_name.clone(), + input: raw_tool_input(input), + provider_executed: *provider_executed, + dynamic: *dynamic, + thought_signature: thought_signature.clone(), + provider_metadata: provider_metadata.clone(), + }, + tools.as_deref(), + repair_tool_call.as_ref(), + &messages, + operation_instructions.as_deref(), + ) + .await; response_content_parts.push(ContentPart::ToolCall { - tool_call_id: tool_call_id.clone(), - tool_name: tool_name.clone(), - input: input.clone(), - thought_signature: thought_signature.clone(), - provider_options: None, + tool_call_id: parsed.tool_call_id.clone(), + tool_name: parsed.tool_name.clone(), + input: crate::tool::response_message_input(&parsed), + thought_signature: parsed.thought_signature.clone(), + provider_options: parsed.provider_metadata.clone(), }); + tool_calls.push(parsed); } GenerateContent::Reasoning { text: rtext, @@ -910,7 +938,10 @@ pub async fn stream_text( options: GenerateTextOptions, ) -> Result { // 1. Convert user prompt to provider-facing prompt. - let (messages, instructions) = split_prompt(prompt.into(), options.instructions.as_deref()); + let repair_tool_call = options.repair_tool_call.clone(); + let tools = options.tools.clone(); + let operation_instructions = options.instructions.clone(); + let (messages, instructions) = split_prompt(prompt.into(), operation_instructions.as_deref()); let lm_prompt = convert_to_language_model_prompt(&messages, instructions); // 2. Build CallOptions. @@ -1107,6 +1138,53 @@ pub async fn stream_text( } }) }; + let mut stream = stream; + let stream: Pin> + Send>> = + Box::pin(async_stream::stream! { + while let Some(item) = stream.next().await { + match item { + Ok(StreamPart::ToolCall { + tool_call_id, + tool_name, + input, + provider_executed, + dynamic, + thought_signature, + provider_metadata, + .. + }) => { + let parsed = parse_tool_call( + RawToolCall { + tool_call_id, + tool_name, + input: raw_tool_input(&input), + provider_executed, + dynamic, + thought_signature, + provider_metadata, + }, + tools.as_deref(), + repair_tool_call.as_ref(), + &messages, + operation_instructions.as_deref(), + ).await; + yield Ok(StreamPart::ToolCall { + tool_call_id: parsed.tool_call_id, + tool_name: parsed.tool_name, + input: parsed.input, + provider_executed: parsed.provider_executed, + dynamic: parsed.dynamic, + thought_signature: parsed.thought_signature, + invalid: parsed.invalid, + error: parsed.error, + provider_metadata: parsed.provider_metadata, + }); + } + item => yield item, + } + } + }); + // 录制开启时才包装(终结时写 outcome + 传输封闭);关闭时零成本透传。 let stream = crate::recording::RecordingOutcomeStream::new( stream, recorder.clone(), @@ -1140,6 +1218,13 @@ impl Drop for AbortOnDrop { } } +fn raw_tool_input(input: &Value) -> String { + match input { + Value::String(input) => input.clone(), + input => serde_json::to_string(input).expect("serializing serde_json::Value cannot fail"), + } +} + /// Run `do_generate` inside the RFC-0014 `generate` span and emit the /// `generate_end` event. A plain async fn (rather than an inline async block) /// so the `?` error type is pinned by the declared return type. diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index cbd5fc80..a1164ce3 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -106,7 +106,10 @@ pub mod prelude { }; pub use crate::speech_model::{SpeechCallOptions, SpeechModel, SpeechResult, generate_speech}; pub use crate::stream_part::StreamPart; - pub use crate::tool::{FunctionTool, ProviderTool, Tool, ToolCall, ToolResult}; + pub use crate::tool::{ + FunctionTool, ProviderTool, RawToolCall, Tool, ToolCall, ToolCallRepair, + ToolCallRepairContext, ToolResult, + }; pub use crate::transcription_model::{ AudioChunk, InputAudioFormat, TranscriptionCallOptions, TranscriptionModel, TranscriptionResult, TranscriptionStreamOptions, TranscriptionStreamPart, diff --git a/aimux-core/src/moa.rs b/aimux-core/src/moa.rs index 5813f0ef..71979d60 100644 --- a/aimux-core/src/moa.rs +++ b/aimux-core/src/moa.rs @@ -919,6 +919,8 @@ mod tests { provider_executed: None, dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }), Ok(StreamPart::Source { diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index a2be4c3c..a0ac31cc 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -245,10 +245,12 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio input, .. } => { - let arguments = if input.is_null() { - "{}".to_string() - } else { - input.to_string() + let arguments = match input { + // Provider results carry string-native arguments verbatim; + // serializing the Value again would add an extra JSON layer. + serde_json::Value::String(input) => input.clone(), + serde_json::Value::Null => "{}".to_string(), + input => input.to_string(), }; tool_calls.push(ChatCompletionToolCall { id: tool_call_id.clone(), @@ -1079,6 +1081,27 @@ mod tests { ); } + #[test] + fn test_raw_tool_call_arguments_are_not_double_encoded() { + let result = make_result(vec![GenerateContent::ToolCall { + tool_call_id: "call_raw".to_string(), + tool_name: "get_weather".to_string(), + input: json!(r#"{"city":"Tokyo"}"#), + provider_executed: None, + dynamic: None, + thought_signature: None, + provider_metadata: None, + }]); + + let completion = to_chat_completion(&result, "gpt-4o"); + assert_eq!( + completion.choices[0].message.tool_calls.as_ref().unwrap()[0] + .function + .arguments, + r#"{"city":"Tokyo"}"# + ); + } + #[test] fn test_tool_call_null_content() { // Tool call with no text → content should be null. diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index 46b88965..c523b1a0 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -536,16 +536,6 @@ fn parse_usage(v: &serde_json::Value) -> Result { }) } -/// 解析 OpenAI tool_call `arguments`(JSON 字符串)为 `Value`。 -/// -/// 非法 JSON → 回退为 `Value::String(raw)`,**不返回错误**:与 openai provider -/// 正向解析一致(`serde_json::from_str(args).unwrap_or_else(|_| Value::String(args))`)。 -/// 部分流式拼接偶发非完整 JSON,provider 侧同样容忍——回放须与正向解析同语义, -/// 否则同一录制在真实调用与回放间行为分叉。 -fn parse_tool_arguments(args: &str) -> serde_json::Value { - serde_json::from_str(args).unwrap_or_else(|_| serde_json::Value::String(args.to_string())) -} - /// 重建非流式结果。 /// /// 仅支持 OpenAI `chat.completions` 格式:`choices[0].message` 的 `content` @@ -601,7 +591,7 @@ fn rebuild_generate_result(rec: &Recording) -> Result Result { } } - // finish_reason:结束 reasoning/text/tool_calls,捕获 finish。 + // finish_reason:结束 reasoning/text 并捕获 finish。Tool calls + // 只在流结束时收尾,与 AI SDK streaming tracker 一致。 if let Some(fr) = choice.get("finish_reason").and_then(|x| x.as_str()) { if reasoning_started { parts.push(Ok(StreamPart::ReasoningEnd { @@ -841,26 +831,6 @@ fn rebuild_stream_result(rec: &Recording) -> Result { })); text_started = false; } - for &i in &tool_order { - if let Some(acc) = tool_calls.get(&i) { - parts.push(Ok(StreamPart::ToolInputEnd { - id: acc.id.clone(), - provider_metadata: None, - })); - let input = parse_tool_arguments(&acc.arguments); - parts.push(Ok(StreamPart::ToolCall { - tool_call_id: acc.id.clone(), - tool_name: acc.name.clone(), - input, - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - })); - } - } - tool_calls.clear(); - tool_order.clear(); let (unified, raw) = parse_finish(Some(fr)); final_finish = Some(FinishReason { unified, raw }); } @@ -868,7 +838,7 @@ fn rebuild_stream_result(rec: &Recording) -> Result { } } - // 收尾:结束未关闭的 reasoning/text/tool_calls(未见 finish_reason 时)。 + // 收尾:结束未关闭的 reasoning/text/tool_calls。 if reasoning_started { parts.push(Ok(StreamPart::ReasoningEnd { id: reasoning_id, @@ -887,14 +857,15 @@ fn rebuild_stream_result(rec: &Recording) -> Result { id: acc.id.clone(), provider_metadata: None, })); - let input = parse_tool_arguments(&acc.arguments); parts.push(Ok(StreamPart::ToolCall { tool_call_id: acc.id.clone(), tool_name: acc.name.clone(), - input, + input: serde_json::Value::String(acc.arguments.clone()), provider_executed: None, dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, })); } @@ -1033,6 +1004,7 @@ fn generate_options_from_call_options(o: CallOptions) -> GenerateTextOptions { session_id: o.session_id, abort_signal: None, include_raw_chunks: o.include_raw_chunks, + repair_tool_call: None, } } @@ -1649,13 +1621,13 @@ mod tests { }; assert_eq!(tool_call_id, "call_abc"); assert_eq!(tool_name, "get_weather"); - assert_eq!(input, &serde_json::json!({ "city": "SF" })); + assert_eq!(input, &serde_json::json!(r#"{"city":"SF"}"#)); assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } #[test] fn mock_model_tool_call_bad_json_arguments_falls_back_to_string() { - // C4-4:arguments 非法 JSON → 回退为字符串值(与 openai provider 正向解析一致)。 + // Provider-facing replay keeps arguments as their original string. let mut rec = openai_recording("t1", "ping", "x", "tool_calls"); let body = serde_json::json!({ "id": "chatcmpl-mock", @@ -1742,7 +1714,7 @@ mod tests { }) .collect(); assert_eq!(deltas, vec!["{\"city\":\"SF\"}"]); - // ToolInputEnd + ToolCall(finish_reason 触发)。 + // ToolInputEnd + ToolCall are emitted when the replay stream flushes. assert!( parts .iter() @@ -1763,7 +1735,7 @@ mod tests { assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, "call_1"); assert_eq!(calls[0].1, "get_weather"); - assert_eq!(calls[0].2, serde_json::json!({"city":"SF"})); + assert_eq!(calls[0].2, serde_json::json!(r#"{"city":"SF"}"#)); // finish_reason tool_calls。 assert!(parts.iter().any(|p| matches!( p, diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index 5c498ad8..7270f1f7 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -28,6 +28,8 @@ pub enum GenerateContent { ToolCall { tool_call_id: String, tool_name: String, + /// Raw provider input. Providers put serialized argument text in a + /// `Value::String`; `generate_text` parses and validates it. input: serde_json::Value, /// Whether the tool call will be executed by the provider. /// If false/unset, the tool call is executed by the client. diff --git a/aimux-core/src/stream_part.rs b/aimux-core/src/stream_part.rs index 74671979..6a083920 100644 --- a/aimux-core/src/stream_part.rs +++ b/aimux-core/src/stream_part.rs @@ -82,6 +82,8 @@ pub enum StreamPart { ToolCall { tool_call_id: String, tool_name: String, + /// Serialized argument text in a `Value::String` from `do_stream`; + /// parsed input after `stream_text`. input: Value, /// Whether the tool call will be executed by the provider. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -94,6 +96,12 @@ pub enum StreamPart { /// turn when the tool result is sent. #[serde(default, skip_serializing_if = "Option::is_none")] thought_signature: Option, + /// Set by Core when the call remains invalid after optional repair. + #[serde(default, skip_serializing_if = "Option::is_none")] + invalid: Option, + /// Typed lookup, parsing, schema, or repair failure. + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, diff --git a/aimux-core/src/tool.rs b/aimux-core/src/tool.rs index 055aa2df..c7bded36 100644 --- a/aimux-core/src/tool.rs +++ b/aimux-core/src/tool.rs @@ -1,11 +1,17 @@ //! Tool / function-calling types. use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; use serde::{Deserialize, Serialize}; use serde_json::Value; use ts_rs::TS; +use crate::error::AiMuxError; +use crate::types::ProviderMetadata; + /// A tool definition passed to the model in `CallOptions.tools`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] @@ -98,6 +104,300 @@ impl From for Tool { } } +/// Provider-facing tool call before Core parses and validates its input. +#[derive(Debug, Clone)] +pub struct RawToolCall { + pub tool_call_id: String, + pub tool_name: String, + pub input: String, + pub provider_executed: Option, + pub dynamic: Option, + pub thought_signature: Option, + pub provider_metadata: Option, +} + +/// Context supplied to a one-shot tool-call repair callback. +#[derive(Debug, Clone)] +pub struct ToolCallRepairContext { + pub instructions: Option, + /// Deprecated AI SDK-compatible alias for `instructions`. + pub system: Option, + pub messages: Vec, + pub tool_call: RawToolCall, + pub tools: Vec, + pub error: AiMuxError, +} + +impl ToolCallRepairContext { + /// Return the JSON Schema for a named function tool in this repair step. + /// + /// Never fails, matching the AI SDK's `inputSchema` repair argument: a + /// name that does not resolve to a function tool (unknown — the NoSuchTool + /// repair scenario — or a provider tool, which carries no schema at this + /// layer) yields the AI SDK's default empty-object schema. + #[must_use] + pub fn input_schema(&self, tool_name: &str) -> Value { + self.tools + .iter() + .find_map(|tool| match tool { + Tool::Function(tool) if tool.name == tool_name => Some(tool.input_schema.clone()), + _ => None, + }) + .unwrap_or_else(|| { + serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) + }) + } +} + +type ToolCallRepairFuture = + Pin, AiMuxError>> + Send>>; + +/// Async callback that may replace one invalid tool call. +/// +/// Core invokes this at most once, and parses and validates the returned call +/// from scratch. Returning `None` keeps the original validation error. +#[derive(Clone)] +pub struct ToolCallRepair(Arc ToolCallRepairFuture + Send + Sync>); + +impl ToolCallRepair { + #[must_use] + pub fn new(repair: F) -> Self + where + F: Fn(ToolCallRepairContext) -> Fut + Send + Sync + 'static, + Fut: Future, AiMuxError>> + Send + 'static, + { + Self(Arc::new(move |context| Box::pin(repair(context)))) + } + + async fn repair( + &self, + context: ToolCallRepairContext, + ) -> Result, AiMuxError> { + (self.0)(context).await + } +} + +impl std::fmt::Debug for ToolCallRepair { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ToolCallRepair()") + } +} + +/// Parse and validate a provider tool call using the AI SDK operation contract. +/// +/// JSON is parsed exactly; partial-JSON repair is deliberately not automatic. +/// When a tool set was supplied, lookup, parsing, or schema validation failure +/// gives the optional repair callback one attempt. As in AI SDK, calls made +/// without a tool set bypass repair. A remaining failure is represented on the +/// returned call so callers retain both the model output and its typed error. +pub async fn parse_tool_call( + tool_call: RawToolCall, + tools: Option<&[Tool]>, + repair_tool_call: Option<&ToolCallRepair>, + messages: &[crate::message::ModelMessage], + instructions: Option<&str>, +) -> ToolCall { + let Some(tools) = tools else { + let parsed = if tool_call.provider_executed == Some(true) && tool_call.dynamic == Some(true) + { + parse_json_input(&tool_call) + } else { + Err(AiMuxError::NoSuchTool { + tool_name: tool_call.tool_name.clone(), + available_tools: None, + }) + }; + return match parsed { + Ok(input) => valid_tool_call(tool_call, input, Some(true)), + Err(error) => invalid_tool_call(tool_call, error), + }; + }; + + match parse_and_validate_tool_call(&tool_call, tools) { + Ok((input, dynamic)) => valid_tool_call(tool_call, input, dynamic), + Err(original_error) => { + if let Some(repair_tool_call) = repair_tool_call { + let context = ToolCallRepairContext { + instructions: instructions.map(str::to_owned), + system: instructions.map(str::to_owned), + messages: messages.to_vec(), + tool_call: tool_call.clone(), + tools: tools.to_vec(), + error: original_error.clone(), + }; + match repair_tool_call.repair(context).await { + Ok(Some(repaired)) => match parse_and_validate_tool_call(&repaired, tools) { + Ok((input, dynamic)) => return valid_tool_call(repaired, input, dynamic), + Err(repaired_error) => { + return invalid_tool_call(tool_call, repaired_error); + } + }, + Ok(None) => {} + Err(repair_error) => { + return invalid_tool_call( + tool_call, + AiMuxError::ToolCallRepair { + original_error: Box::new(original_error), + cause: Box::new(repair_error), + }, + ); + } + } + } + invalid_tool_call(tool_call, original_error) + } + } +} + +fn parse_and_validate_tool_call( + tool_call: &RawToolCall, + tools: &[Tool], +) -> Result<(Value, Option), AiMuxError> { + let tool = tools.iter().find(|tool| match tool { + Tool::Function(tool) => tool.name == tool_call.tool_name, + Tool::Provider(tool) => tool.name == tool_call.tool_name, + }); + + let provider_dynamic = + tool_call.provider_executed == Some(true) && tool_call.dynamic == Some(true); + let Some(tool) = tool else { + if provider_dynamic { + return parse_json_input(tool_call).map(|input| (input, Some(true))); + } + return Err(AiMuxError::NoSuchTool { + tool_name: tool_call.tool_name.clone(), + available_tools: Some( + tools + .iter() + .map(|tool| match tool { + Tool::Function(tool) => tool.name.clone(), + Tool::Provider(tool) => tool.name.clone(), + }) + .collect(), + ), + }); + }; + + let input = parse_json_input(tool_call)?; + let Tool::Function(function_tool) = tool else { + return Ok((input, None)); + }; + let validator = jsonschema::validator_for(&function_tool.input_schema).map_err(|error| { + AiMuxError::InvalidToolInput { + tool_name: tool_call.tool_name.clone(), + tool_input: tool_call.input.clone(), + cause: format!("input schema is invalid: {error}"), + } + })?; + // Cause wording matches the AI SDK's `TypeValidationError` template; + // `Value` Display is compact JSON, the `JSON.stringify` equivalent. + validator + .validate(&input) + .map_err(|error| AiMuxError::InvalidToolInput { + tool_name: tool_call.tool_name.clone(), + tool_input: tool_call.input.clone(), + cause: format!("Type validation failed: Value: {input}.\nError message: {error}"), + })?; + Ok((input, None)) +} + +fn parse_json_input(tool_call: &RawToolCall) -> Result { + if tool_call.input.trim().is_empty() { + return Ok(Value::Object(serde_json::Map::new())); + } + // Cause wording matches the AI SDK's `JSONParseError` template. + let value: Value = + serde_json::from_str(&tool_call.input).map_err(|error| AiMuxError::InvalidToolInput { + tool_name: tool_call.tool_name.clone(), + tool_input: tool_call.input.clone(), + cause: format!( + "JSON parsing failed: Text: {}.\nError message: {error}", + tool_call.input + ), + })?; + if contains_forbidden_prototype(&value) { + return Err(AiMuxError::InvalidToolInput { + tool_name: tool_call.tool_name.clone(), + tool_input: tool_call.input.clone(), + cause: format!( + "JSON parsing failed: Text: {}.\nError message: Object contains forbidden prototype property", + tool_call.input + ), + }); + } + Ok(value) +} + +// Port of the AI SDK's secure JSON parse (fastify/secure-json-parse): a +// `__proto__` key, or a `constructor` object carrying a `prototype` key, +// anywhere in the tree marks the input invalid. Rust has no prototype +// pollution, but the parsed value crosses the FFI into JS and Python, and +// the valid/invalid classification must match upstream. +fn contains_forbidden_prototype(value: &Value) -> bool { + match value { + Value::Object(map) => { + let constructor_prototype = map + .get("constructor") + .and_then(Value::as_object) + .is_some_and(|constructor| constructor.contains_key("prototype")); + if map.contains_key("__proto__") || constructor_prototype { + return true; + } + map.values().any(contains_forbidden_prototype) + } + Value::Array(items) => items.iter().any(contains_forbidden_prototype), + _ => false, + } +} + +/// The input echoed back in the assistant response message. Mirrors the AI +/// SDK's `toResponseMessages`: an invalid call whose retained input is not an +/// object becomes `{}` so the follow-up turn stays provider-acceptable. JSON +/// null survives, as upstream's `typeof part.input !== 'object'` keeps it. +pub(crate) fn response_message_input(tool_call: &ToolCall) -> Value { + if tool_call.invalid == Some(true) + && !(tool_call.input.is_object() || tool_call.input.is_null()) + { + Value::Object(serde_json::Map::new()) + } else { + tool_call.input.clone() + } +} + +fn valid_tool_call(tool_call: RawToolCall, input: Value, dynamic: Option) -> ToolCall { + ToolCall { + tool_call_id: tool_call.tool_call_id, + tool_name: tool_call.tool_name, + input, + provider_executed: tool_call.provider_executed, + dynamic, + thought_signature: tool_call.thought_signature, + provider_metadata: tool_call.provider_metadata, + invalid: None, + error: None, + } +} + +fn invalid_tool_call(tool_call: RawToolCall, error: AiMuxError) -> ToolCall { + let input = serde_json::from_str(&tool_call.input) + .unwrap_or_else(|_| Value::String(tool_call.input.clone())); + ToolCall { + tool_call_id: tool_call.tool_call_id, + tool_name: tool_call.tool_name, + input, + provider_executed: tool_call.provider_executed, + dynamic: Some(true), + thought_signature: tool_call.thought_signature, + provider_metadata: tool_call.provider_metadata, + invalid: Some(true), + error: Some(error), + } +} + /// A tool call requested by the model. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] @@ -106,7 +406,8 @@ pub struct ToolCall { pub tool_call_id: String, /// Tool name. pub tool_name: String, - /// Arguments as a JSON value (usually an object). + /// Parsed arguments, or the original string when `invalid` is true and the + /// provider input was not valid JSON. pub input: Value, /// Whether the tool call will be executed by the provider. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -120,6 +421,16 @@ pub struct ToolCall { /// otherwise. #[serde(default, skip_serializing_if = "Option::is_none")] pub thought_signature: Option, + /// Additional provider-specific metadata associated with this call. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, + /// Set when lookup, JSON parsing, or schema validation still failed after + /// the optional repair attempt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invalid: Option, + /// Typed failure associated with an invalid tool call. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, } /// The result of executing a tool call, to be sent back to the model. diff --git a/aimux-core/tests/tool_input_test.rs b/aimux-core/tests/tool_input_test.rs new file mode 100644 index 00000000..e9a1cef8 --- /dev/null +++ b/aimux-core/tests/tool_input_test.rs @@ -0,0 +1,408 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use aimux_core::error::AiMuxError; +use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; +use aimux_core::language_model::LanguageModel; +use aimux_core::options::CallOptions; +use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::stream_part::StreamPart; +use aimux_core::tool::{FunctionTool, RawToolCall, Tool, ToolCallRepair, parse_tool_call}; +use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; +use async_trait::async_trait; +use futures::StreamExt; +use serde_json::json; + +fn weather_tool() -> Tool { + FunctionTool::new("weather", weather_tool_schema()).into() +} + +fn raw(name: &str, input: &str) -> RawToolCall { + RawToolCall { + tool_call_id: "call-1".into(), + tool_name: name.into(), + input: input.into(), + provider_executed: None, + dynamic: None, + thought_signature: None, + provider_metadata: None, + } +} + +#[tokio::test] +async fn parses_and_validates_exact_json() { + let mut tool_call = raw("weather", r#"{"city":"Singapore","days":3}"#); + tool_call.dynamic = Some(true); + let call = parse_tool_call(tool_call, Some(&[weather_tool()]), None, &[], None).await; + + assert_eq!(call.input, json!({ "city": "Singapore", "days": 3 })); + assert_eq!(call.dynamic, None); + assert_eq!(call.invalid, None); + assert!(call.error.is_none()); +} + +#[tokio::test] +async fn validates_empty_input_as_an_empty_object() { + let no_arg_tool = FunctionTool::new( + "ping", + json!({ "type": "object", "additionalProperties": false }), + ); + let call = parse_tool_call( + raw("ping", " \n"), + Some(&[no_arg_tool.into()]), + None, + &[], + None, + ) + .await; + + assert_eq!(call.input, json!({})); + assert_eq!(call.invalid, None); +} + +#[tokio::test] +async fn parses_a_json_string_without_confusing_it_with_the_raw_carrier() { + let echo_tool = FunctionTool::new("echo", json!({ "type": "string" })); + let call = parse_tool_call( + raw("echo", r#""hello""#), + Some(&[echo_tool.into()]), + None, + &[], + None, + ) + .await; + + assert_eq!(call.input, json!("hello")); + assert_eq!(call.invalid, None); +} + +#[tokio::test] +async fn does_not_apply_partial_json_repair_to_final_tool_calls() { + for input in [ + r#"{"city":"Singapore"#, + r#"{"city":"Singapore",}"#, + r#"{"city":"Singapore"},"days":3}"#, + ] { + let call = parse_tool_call( + raw("weather", input), + Some(&[weather_tool()]), + None, + &[], + None, + ) + .await; + + assert_eq!(call.input, json!(input)); + assert_eq!(call.invalid, Some(true)); + assert!(matches!( + call.error, + Some(AiMuxError::InvalidToolInput { .. }) + )); + } +} + +#[tokio::test] +async fn preserves_parsed_input_when_schema_validation_fails() { + let input = r#"{"city":7}"#; + let call = parse_tool_call( + raw("weather", input), + Some(&[weather_tool()]), + None, + &[], + None, + ) + .await; + + assert_eq!(call.input, json!({ "city": 7 })); + assert_eq!(call.invalid, Some(true)); + assert!(matches!( + call.error, + Some(AiMuxError::InvalidToolInput { .. }) + )); +} + +#[tokio::test] +async fn unknown_tool_is_an_invalid_dynamic_call_with_available_tools() { + let call = parse_tool_call( + raw("forecast", "{}"), + Some(&[weather_tool()]), + None, + &[], + None, + ) + .await; + + assert_eq!(call.dynamic, Some(true)); + assert_eq!(call.invalid, Some(true)); + assert!(matches!( + call.error, + Some(AiMuxError::NoSuchTool { available_tools, .. }) + if available_tools == Some(vec!["weather".to_string()]) + )); +} + +#[tokio::test] +async fn repair_runs_once_and_the_replacement_is_fully_revalidated() { + let calls = Arc::new(AtomicUsize::new(0)); + let repair_calls = Arc::clone(&calls); + let repair = ToolCallRepair::new(move |context| { + repair_calls.fetch_add(1, Ordering::SeqCst); + async move { + assert!(matches!(context.error, AiMuxError::InvalidToolInput { .. })); + assert_eq!(context.instructions.as_deref(), Some("Use metric units")); + assert_eq!(context.system, context.instructions); + Ok(Some(RawToolCall { + tool_name: "weather".into(), + input: r#"{"city":"Singapore","days":3}"#.into(), + ..context.tool_call + })) + } + }); + + let call = parse_tool_call( + raw("weather", r#"{"city":"Singapore"#), + Some(&[weather_tool()]), + Some(&repair), + &[], + Some("Use metric units"), + ) + .await; + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(call.input, json!({ "city": "Singapore", "days": 3 })); + assert_eq!(call.invalid, None); +} + +#[tokio::test] +async fn an_invalid_repair_is_not_repaired_again() { + let calls = Arc::new(AtomicUsize::new(0)); + let repair_calls = Arc::clone(&calls); + let repair = ToolCallRepair::new(move |context| { + repair_calls.fetch_add(1, Ordering::SeqCst); + async move { + Ok(Some(RawToolCall { + input: r#"{"city":7}"#.into(), + ..context.tool_call + })) + } + }); + + let call = parse_tool_call( + raw("weather", "{"), + Some(&[weather_tool()]), + Some(&repair), + &[], + None, + ) + .await; + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(call.input, json!("{")); + assert_eq!(call.invalid, Some(true)); + assert!(matches!( + call.error, + Some(AiMuxError::InvalidToolInput { tool_input, .. }) + if tool_input == r#"{"city":7}"# + )); +} + +#[tokio::test] +async fn missing_tools_bypasses_repair_like_ai_sdk() { + let calls = Arc::new(AtomicUsize::new(0)); + let repair_calls = Arc::clone(&calls); + let repair = ToolCallRepair::new(move |_| { + repair_calls.fetch_add(1, Ordering::SeqCst); + async { Ok(None) } + }); + + let call = parse_tool_call(raw("weather", "{}"), None, Some(&repair), &[], None).await; + + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert!(matches!( + call.error, + Some(AiMuxError::NoSuchTool { + available_tools: None, + .. + }) + )); +} + +#[tokio::test] +async fn repair_returning_none_keeps_the_original_failure() { + let repair = ToolCallRepair::new(|_| async { Ok(None) }); + let original_input = "{"; + let call = parse_tool_call( + raw("weather", original_input), + Some(&[weather_tool()]), + Some(&repair), + &[], + None, + ) + .await; + + assert_eq!(call.input, json!(original_input)); + assert!(matches!( + call.error, + Some(AiMuxError::InvalidToolInput { tool_input, .. }) + if tool_input == original_input + )); +} + +#[tokio::test] +async fn repair_failure_keeps_both_typed_errors() { + let repair = ToolCallRepair::new(|context| async move { + assert_eq!(context.input_schema("weather"), weather_tool_schema()); + Err(AiMuxError::Other("repair model failed".into())) + }); + + let call = parse_tool_call( + raw("weather", "{"), + Some(&[weather_tool()]), + Some(&repair), + &[], + None, + ) + .await; + + assert!(matches!( + call.error, + Some(AiMuxError::ToolCallRepair { original_error, cause }) + if matches!(*original_error, AiMuxError::InvalidToolInput { .. }) + && matches!(*cause, AiMuxError::Other(_)) + )); +} + +#[tokio::test] +async fn provider_executed_dynamic_calls_do_not_require_a_local_tool() { + let mut tool_call = raw("provider_search", r#"{"query":"rust"}"#); + tool_call.provider_executed = Some(true); + tool_call.dynamic = Some(true); + + let call = parse_tool_call(tool_call, None, None, &[], None).await; + + assert_eq!(call.input, json!({ "query": "rust" })); + assert_eq!(call.invalid, None); +} + +fn weather_tool_schema() -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "city": { "type": "string" }, + "days": { "type": "integer" } + }, + "required": ["city"], + "additionalProperties": false + }) +} + +struct RawToolModel; + +#[async_trait] +impl LanguageModel for RawToolModel { + fn provider(&self) -> &str { + "mock" + } + + fn model_id(&self) -> &str { + "raw-tool-model" + } + + async fn do_generate(&self, _options: &CallOptions) -> Result { + Ok(GenerateResult { + content: vec![GenerateContent::ToolCall { + tool_call_id: "call-1".into(), + tool_name: "weather".into(), + input: json!(r#"{"city":"Singapore"}"#), + provider_executed: None, + dynamic: None, + thought_signature: None, + provider_metadata: None, + }], + finish_reason: FinishReason { + unified: FinishReasonUnified::ToolCalls, + raw: Some("tool_calls".into()), + }, + usage: Usage::default(), + warnings: vec![], + provider_metadata: None, + response: Default::default(), + request_body: None, + response_headers: None, + }) + } + + async fn do_stream(&self, _options: &CallOptions) -> Result { + Ok(StreamResult { + stream: Box::pin(futures::stream::iter([ + Ok(StreamPart::StreamStart { warnings: vec![] }), + Ok(StreamPart::ToolCall { + tool_call_id: "call-1".into(), + tool_name: "weather".into(), + input: json!(r#"{"city":"Singapore"}"#), + provider_executed: None, + dynamic: None, + thought_signature: None, + invalid: None, + error: None, + provider_metadata: None, + }), + Ok(StreamPart::Finish { + finish_reason: FinishReason { + unified: FinishReasonUnified::ToolCalls, + raw: Some("tool_calls".into()), + }, + usage: Usage::default(), + provider_metadata: None, + }), + ])), + request_body: None, + response_headers: None, + }) + } +} + +#[tokio::test] +async fn generate_text_parses_provider_raw_input_at_the_core_boundary() { + let result = generate_text( + &RawToolModel, + "weather", + GenerateTextOptions { + tools: Some(vec![weather_tool()]), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(result.tool_calls[0].input, json!({ "city": "Singapore" })); + assert_eq!(result.tool_calls[0].invalid, None); + assert!(matches!( + &result.raw.content[0], + GenerateContent::ToolCall { input, .. } + if input == &json!(r#"{"city":"Singapore"}"#) + )); +} + +#[tokio::test] +async fn stream_text_parses_provider_raw_input_at_the_core_boundary() { + let mut result = stream_text( + &RawToolModel, + "weather", + GenerateTextOptions { + tools: Some(vec![weather_tool()]), + ..Default::default() + }, + ) + .await + .unwrap(); + + while let Some(part) = result.stream.next().await { + if let StreamPart::ToolCall { input, invalid, .. } = part.unwrap() { + assert_eq!(input, json!({ "city": "Singapore" })); + assert_eq!(invalid, None); + return; + } + } + panic!("expected a parsed tool call"); +} diff --git a/aimux-ffi/aimux-error.h b/aimux-ffi/aimux-error.h index 5cc92bf0..06396e67 100644 --- a/aimux-ffi/aimux-error.h +++ b/aimux-ffi/aimux-error.h @@ -40,11 +40,10 @@ typedef struct aimux_error aimux_error_t; typedef enum aimux_error_code { AIMUX_OK = 0, - /* AiMuxError: 1..14. */ + /* AiMuxError: 1..17, except retired code 4. */ AIMUX_E_OTHER = 1, AIMUX_E_JSON_PARSE = 2, AIMUX_E_INVALID_RESPONSE_DATA = 3, - AIMUX_E_TOOL = 4, AIMUX_E_INVALID_ARGUMENT = 5, AIMUX_E_INVALID_PROMPT = 6, AIMUX_E_TOKEN_EXPIRED = 7, @@ -58,6 +57,9 @@ typedef enum aimux_error_code { * ABI break means no pre-unification caller can link, so nothing can * misread it. */ AIMUX_E_RETRY = 14, + AIMUX_E_NO_SUCH_TOOL = 15, + AIMUX_E_INVALID_TOOL_INPUT = 16, + AIMUX_E_TOOL_CALL_REPAIR = 17, /* RecordingError: 100..105. */ AIMUX_E_RECORDING_INIT = 100, @@ -147,6 +149,22 @@ int32_t aimux_error_retry_count(const aimux_error_t *error); * NULL when `index` is out of range or under any other code. */ aimux_error_t *aimux_error_retry_error_at(const aimux_error_t *error, int32_t index); +/* Tool-contract errors — returned strings are caller-owned. */ + +/** AIMUX_E_NO_SUCH_TOOL / AIMUX_E_INVALID_TOOL_INPUT: the tool name called. */ +char *aimux_error_tool_name(const aimux_error_t *error); +/** + * AIMUX_E_NO_SUCH_TOOL: the available tool names as a JSON string array, + * or NULL when no tool set was supplied. + */ +char *aimux_error_available_tools(const aimux_error_t *error); +/** AIMUX_E_INVALID_TOOL_INPUT: the raw argument text the model produced. */ +char *aimux_error_tool_input(const aimux_error_t *error); +/** + * AIMUX_E_TOOL_CALL_REPAIR: the original lookup/parse/validation error as + * externally-tagged wire JSON (the same encoding as `ToolCall.error`). + */ +char *aimux_error_original_error(const aimux_error_t *error); #ifdef __cplusplus } diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index 0eb72557..d2ab0497 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -414,7 +414,9 @@ pub const AIMUX_OK: i32 = 0; pub const AIMUX_E_OTHER: i32 = 1; pub const AIMUX_E_JSON_PARSE: i32 = 2; pub const AIMUX_E_INVALID_RESPONSE_DATA: i32 = 3; -pub const AIMUX_E_TOOL: i32 = 4; +// 4 is retired: it was the legacy catch-all `Tool` variant, which nothing +// ever produced; the typed tool-contract codes are 15..17. 14 is claimed by +// the in-flight request-pipeline change (`Retry`). pub const AIMUX_E_INVALID_ARGUMENT: i32 = 5; pub const AIMUX_E_INVALID_PROMPT: i32 = 6; pub const AIMUX_E_TOKEN_EXPIRED: i32 = 7; @@ -427,6 +429,9 @@ pub const AIMUX_E_ABORTED: i32 = 13; // `Retry` (newest variant) reclaims the slot the pre-unification `Other` // vacated — the opaque-pointer ABI break means no old caller can misread it. pub const AIMUX_E_RETRY: i32 = 14; +pub const AIMUX_E_NO_SUCH_TOOL: i32 = 15; +pub const AIMUX_E_INVALID_TOOL_INPUT: i32 = 16; +pub const AIMUX_E_TOOL_CALL_REPAIR: i32 = 17; // 100..105 preserve `RecordingError` as a separate high-level type while C // uses one code space for every returned error. @@ -458,7 +463,9 @@ fn aimux_error_code_of(err: &AiMuxError) -> i32 { AiMuxError::Retry(_) => AIMUX_E_RETRY, AiMuxError::JsonParse(_) => AIMUX_E_JSON_PARSE, AiMuxError::InvalidResponseData(_) => AIMUX_E_INVALID_RESPONSE_DATA, - AiMuxError::Tool(_) => AIMUX_E_TOOL, + AiMuxError::NoSuchTool { .. } => AIMUX_E_NO_SUCH_TOOL, + AiMuxError::InvalidToolInput { .. } => AIMUX_E_INVALID_TOOL_INPUT, + AiMuxError::ToolCallRepair { .. } => AIMUX_E_TOOL_CALL_REPAIR, AiMuxError::InvalidArgument(_) => AIMUX_E_INVALID_ARGUMENT, AiMuxError::InvalidPrompt(_) => AIMUX_E_INVALID_PROMPT, AiMuxError::TokenExpired(_) => AIMUX_E_TOKEN_EXPIRED, @@ -737,6 +744,63 @@ pub extern "C" fn aimux_error_provider_id(err: *const aimux_error_t) -> *mut c_c ) } +/// `AIMUX_E_NO_SUCH_TOOL` / `AIMUX_E_INVALID_TOOL_INPUT`: the tool name the +/// model called. +#[unsafe(no_mangle)] +pub extern "C" fn aimux_error_tool_name(err: *const aimux_error_t) -> *mut c_char { + opt_cstring( + map_aimux_error(err, |e| match e { + AiMuxError::NoSuchTool { tool_name, .. } + | AiMuxError::InvalidToolInput { tool_name, .. } => Some(tool_name.clone()), + _ => None, + }) + .flatten(), + ) +} + +/// `AIMUX_E_NO_SUCH_TOOL`: the available tool names as a JSON string array, +/// or NULL when no tool set was supplied. +#[unsafe(no_mangle)] +pub extern "C" fn aimux_error_available_tools(err: *const aimux_error_t) -> *mut c_char { + opt_cstring( + map_aimux_error(err, |e| match e { + AiMuxError::NoSuchTool { + available_tools: Some(tools), + .. + } => serde_json::to_string(tools).ok(), + _ => None, + }) + .flatten(), + ) +} + +/// `AIMUX_E_INVALID_TOOL_INPUT`: the raw argument text the model produced. +#[unsafe(no_mangle)] +pub extern "C" fn aimux_error_tool_input(err: *const aimux_error_t) -> *mut c_char { + opt_cstring( + map_aimux_error(err, |e| match e { + AiMuxError::InvalidToolInput { tool_input, .. } => Some(tool_input.clone()), + _ => None, + }) + .flatten(), + ) +} + +/// `AIMUX_E_TOOL_CALL_REPAIR`: the original lookup/parse/validation error as +/// externally-tagged wire JSON — the same encoding as `ToolCall.error`. +#[unsafe(no_mangle)] +pub extern "C" fn aimux_error_original_error(err: *const aimux_error_t) -> *mut c_char { + opt_cstring( + map_aimux_error(err, |e| match e { + AiMuxError::ToolCallRepair { original_error, .. } => { + serde_json::to_string(original_error).ok() + } + _ => None, + }) + .flatten(), + ) +} + // ───────────────────────────────────────────────────────────────────────────── // Argument helpers // ───────────────────────────────────────────────────────────────────────────── @@ -3264,7 +3328,7 @@ mod tests { fn expect_aimux_error(e: *mut aimux_error_t) -> (i32, String) { assert!(!e.is_null(), "expected a returned error"); let code = aimux_error_code(e); - if !(AIMUX_E_OTHER..=AIMUX_E_RETRY).contains(&code) { + if !(AIMUX_E_OTHER..=AIMUX_E_TOOL_CALL_REPAIR).contains(&code) { panic!("expected an AiMuxError code, got {code}: {}", msg(e)); } let out = (code, take(aimux_error_message(e)).unwrap()); @@ -3620,7 +3684,7 @@ mod tests { ); } - /// Pin the full 14-variant → code mapping. + /// Pin the full 16-variant → code mapping. #[test] fn error_code_mapping_covers_all_variants() { let s = |t: &str| t.to_string(); @@ -3645,7 +3709,31 @@ mod tests { AiMuxError::InvalidResponseData(s("x")), AIMUX_E_INVALID_RESPONSE_DATA, ), - (AiMuxError::Tool(s("x")), AIMUX_E_TOOL), + ( + AiMuxError::NoSuchTool { + tool_name: s("t"), + available_tools: None, + }, + AIMUX_E_NO_SUCH_TOOL, + ), + ( + AiMuxError::InvalidToolInput { + tool_name: s("t"), + tool_input: s("{}"), + cause: s("x"), + }, + AIMUX_E_INVALID_TOOL_INPUT, + ), + ( + AiMuxError::ToolCallRepair { + original_error: Box::new(AiMuxError::NoSuchTool { + tool_name: s("t"), + available_tools: None, + }), + cause: Box::new(AiMuxError::Other(s("x"))), + }, + AIMUX_E_TOOL_CALL_REPAIR, + ), ( AiMuxError::InvalidArgument(s("x")), AIMUX_E_INVALID_ARGUMENT, diff --git a/aimux-ffi/tests/exports_smoke_test.rs b/aimux-ffi/tests/exports_smoke_test.rs index 14786b25..4f9fb4d1 100644 --- a/aimux-ffi/tests/exports_smoke_test.rs +++ b/aimux-ffi/tests/exports_smoke_test.rs @@ -50,11 +50,12 @@ use aimux_ffi::{ aimux_codex_refresh, aimux_cohere_embedding_new, aimux_cohere_embedding_new_with_base, aimux_cohere_new, aimux_cohere_new_with_base, aimux_cohere_reranking_new, aimux_cohere_reranking_new_with_base, aimux_consume_stream_text, aimux_drop_handle, - aimux_embed, aimux_error_code, aimux_error_free, aimux_error_message, aimux_error_model_id, - aimux_error_model_type, aimux_error_provider_code, aimux_error_provider_id, - aimux_error_provider_message, aimux_error_response_body, aimux_error_retry_ms, - aimux_error_retryable, aimux_error_status, aimux_error_t, aimux_file_upload, aimux_free_string, - aimux_generate_object, aimux_generate_text, aimux_generate_text_as_openai, + aimux_embed, aimux_error_available_tools, aimux_error_code, aimux_error_free, + aimux_error_message, aimux_error_model_id, aimux_error_model_type, aimux_error_original_error, + aimux_error_provider_code, aimux_error_provider_id, aimux_error_provider_message, + aimux_error_response_body, aimux_error_retry_ms, aimux_error_retryable, aimux_error_status, + aimux_error_t, aimux_error_tool_input, aimux_error_tool_name, aimux_file_upload, + aimux_free_string, aimux_generate_object, aimux_generate_text, aimux_generate_text_as_openai, aimux_get_model_specs, aimux_google_embedding_new, aimux_google_embedding_new_with_base, aimux_google_image_new, aimux_google_image_new_with_base, aimux_google_video_new, aimux_google_video_new_with_base, aimux_image_generate, aimux_init_logging, aimux_init_proxy, @@ -155,7 +156,7 @@ fn header_and_exports_agree() { exports.sort(); assert_eq!( exports.len(), - 115, + 119, "export count changed; update the headers" ); @@ -991,6 +992,10 @@ fn utility_exports_return_clean_values() { aimux_error_model_id, aimux_error_model_type, aimux_error_provider_id, + aimux_error_tool_name, + aimux_error_available_tools, + aimux_error_tool_input, + aimux_error_original_error, ] { assert!(get(e).is_null(), "AiMuxError payload getter must be NULL"); } diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index 7a53b146..8a7569bb 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -474,7 +474,7 @@ pub(crate) fn parse_anthropic_content( content.push(GenerateContent::ToolCall { tool_call_id: id.clone(), tool_name: name.clone(), - input: input.clone(), + input: Value::String(input.to_string()), provider_executed: None, dynamic: None, thought_signature: None, @@ -498,8 +498,8 @@ pub(crate) fn parse_anthropic_content( content.push(GenerateContent::ToolCall { tool_call_id: id.clone(), tool_name: name.clone(), - input: input.clone(), - provider_executed: None, + input: Value::String(input.to_string()), + provider_executed: Some(true), dynamic: None, thought_signature: None, provider_metadata: None, @@ -515,7 +515,7 @@ pub(crate) fn parse_anthropic_content( content.push(GenerateContent::ToolCall { tool_call_id: id.clone(), tool_name: name.clone(), - input: input.clone(), + input: Value::String(input.to_string()), provider_executed: Some(true), dynamic: Some(true), thought_signature: None, @@ -915,10 +915,12 @@ pub(crate) async fn anthropic_stream_core( tool_name: tool_names .to_custom_tool_name(&name) .to_string(), - input: input.clone(), + input: Value::String(input.to_string()), provider_executed: Some(true), dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } @@ -929,10 +931,12 @@ pub(crate) async fn anthropic_stream_core( yield Ok(StreamPart::ToolCall { tool_call_id: id.clone(), tool_name: name.clone(), - input: input.clone(), + input: Value::String(input.to_string()), provider_executed: Some(true), dynamic: Some(true), thought_signature: None, + invalid: None, + error: None, provider_metadata: Some(json!({ "anthropic": { "type": "mcp-tool-use", @@ -1112,14 +1116,13 @@ pub(crate) async fn anthropic_stream_core( id: id.clone(), provider_metadata: None, }); - let input: serde_json::Value = if accumulated_json - .is_empty() - { - serde_json::json!({}) + // Empty input normalizes to "{}" per + // the upstream provider. + let input = Value::String(if accumulated_json.is_empty() { + "{}".to_string() } else { - serde_json::from_str(&accumulated_json) - .unwrap_or(serde_json::json!({})) - }; + accumulated_json + }); yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: name, @@ -1127,6 +1130,8 @@ pub(crate) async fn anthropic_stream_core( provider_executed: None, dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } diff --git a/aimux-providers/src/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index d7ad90f0..8c29d159 100644 --- a/aimux-providers/src/bedrock/model.rs +++ b/aimux-providers/src/bedrock/model.rs @@ -478,11 +478,13 @@ impl LanguageModel for BedrockModel { if let Some((id, name, acc)) = tool_blocks.remove(&idx) { yield Ok(StreamPart::ToolInputEnd { id: id.clone(), provider_metadata: None}); - let input: serde_json::Value = if acc.is_empty() { - serde_json::json!({}) + // Empty input normalizes to "{}" per the upstream + // provider. + let input = serde_json::Value::String(if acc.is_empty() { + "{}".to_string() } else { - serde_json::from_str(&acc).unwrap_or(serde_json::json!({})) - }; + acc + }); yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: name, @@ -490,6 +492,8 @@ impl LanguageModel for BedrockModel { provider_executed: None, dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } else if reasoning_id.is_some() { @@ -648,7 +652,7 @@ fn extract_content(block: &BedrockContentBlock, content: &mut Vec(text) { + Ok(parsed_args) => { + // `Value` Display is compact JSON — the + // `JSON.stringify` equivalent, infallible. + let input = Value::String(parsed_args.to_string()); + yield Ok(StreamPart::ToolCall { + tool_call_id: ptc.id, + tool_name: ptc.name, + input, + provider_executed: None, + dynamic: None, + thought_signature: None, + invalid: None, + error: None, + provider_metadata: None, + }); + } + Err(e) => { + yield Ok(StreamPart::Error { error: e.into() }); + stream_errored = true; + break; + } + } } } diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index 62a303f7..0545a7c1 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -431,10 +431,12 @@ impl LanguageModel for GoogleModel { yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: name.to_string(), - input: args, + input: Value::String(args.to_string()), provider_executed: None, dynamic: None, thought_signature, + invalid: None, + error: None, provider_metadata: thought_sig_meta.clone(), }); has_tool_calls = true; @@ -452,10 +454,12 @@ impl LanguageModel for GoogleModel { yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: "code_execution".to_string(), - input: ec.clone(), - provider_executed: None, + input: Value::String(ec.to_string()), + provider_executed: Some(true), dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); // provider-executed → does NOT set has_tool_calls @@ -506,10 +510,12 @@ impl LanguageModel for GoogleModel { yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), - input: args, - provider_executed: None, - dynamic: None, + input: Value::String(args.to_string()), + provider_executed: Some(true), + dynamic: Some(true), thought_signature: None, + invalid: None, + error: None, provider_metadata: Some(server_meta), }); // provider-executed → does NOT set has_tool_calls @@ -700,8 +706,8 @@ fn extract_content_from_candidate(candidate: &Candidate) -> (Vec (Vec (Vec Result, AiMu .get("arguments") .and_then(|v| v.as_str()) .unwrap_or("{}"); - let input: Value = serde_json::from_str(arguments) - .unwrap_or_else(|_| Value::String(arguments.to_string())); + let input = Value::String(arguments.to_string()); content.push(GenerateContent::ToolCall { tool_call_id: call_id.to_string(), tool_name: name.to_string(), @@ -1160,13 +1158,12 @@ fn build_generate_content(response: &Value) -> Result, AiMu .get("arguments") .and_then(|v| v.as_str()) .unwrap_or("{}"); - let input: Value = serde_json::from_str(arguments) - .unwrap_or_else(|_| Value::String(arguments.to_string())); + let input = Value::String(arguments.to_string()); content.push(GenerateContent::ToolCall { tool_call_id: id.to_string(), tool_name: name.to_string(), input, - provider_executed: None, + provider_executed: Some(true), dynamic: None, thought_signature: None, provider_metadata: None, @@ -1191,8 +1188,8 @@ fn build_generate_content(response: &Value) -> Result, AiMu content.push(GenerateContent::ToolCall { tool_call_id: id.to_string(), tool_name: "list_tools".to_string(), - input: json!({ "server_label": server_label }), - provider_executed: None, + input: Value::String(json!({ "server_label": server_label }).to_string()), + provider_executed: Some(true), dynamic: None, thought_signature: None, provider_metadata: None, diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index 547a877a..ef7e7020 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -274,8 +274,7 @@ impl LanguageModel for MistralModel { // Tool calls. if let Some(tool_calls) = choice.message.tool_calls { for tc in tool_calls { - let input: Value = serde_json::from_str(&tc.function.arguments) - .unwrap_or_else(|_| Value::String(tc.function.arguments.clone())); + let input = Value::String(tc.function.arguments); content.push(GenerateContent::ToolCall { tool_call_id: tc.id, tool_name: tc.function.name, @@ -521,8 +520,7 @@ impl LanguageModel for MistralModel { provider_metadata: None, }); - let input: Value = serde_json::from_str(&args) - .unwrap_or_else(|_| Value::String(args.clone())); + let input = Value::String(args); yield Ok(StreamPart::ToolCall { tool_call_id: tool_id, tool_name, @@ -530,6 +528,8 @@ impl LanguageModel for MistralModel { provider_executed: None, dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } diff --git a/aimux-providers/src/open_responses.rs b/aimux-providers/src/open_responses.rs index 1675befc..0921b99d 100644 --- a/aimux-providers/src/open_responses.rs +++ b/aimux-providers/src/open_responses.rs @@ -395,8 +395,7 @@ impl LanguageModel for OpenResponsesModel { .get("arguments") .and_then(|a| a.as_str()) .unwrap_or("{}"); - let input: Value = serde_json::from_str(arguments) - .unwrap_or_else(|_| Value::String(arguments.to_string())); + let input = Value::String(arguments.to_string()); content.push(GenerateContent::ToolCall { tool_call_id: call_id, tool_name: name, @@ -682,9 +681,7 @@ impl LanguageModel for OpenResponsesModel { .map(std::string::ToString::to_string) }) .unwrap_or_default(); - let input: Value = - serde_json::from_str(&arguments) - .unwrap_or_else(|_| Value::String(arguments)); + let input = Value::String(arguments); yield Ok(StreamPart::ToolCall { tool_call_id, tool_name, @@ -692,6 +689,8 @@ impl LanguageModel for OpenResponsesModel { provider_executed: None, dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); has_tool_calls = true; diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 59837557..002f04aa 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -325,8 +325,7 @@ pub async fn execute_generate( } if let Some(tool_calls) = choice.message.tool_calls { for tc in tool_calls { - let input: Value = serde_json::from_str(&tc.function.arguments) - .unwrap_or_else(|_| Value::String(tc.function.arguments.clone())); + let input = Value::String(tc.function.arguments); content.push(GenerateContent::ToolCall { tool_call_id: tc.id, tool_name: tc.function.name, @@ -734,30 +733,6 @@ pub async fn execute_stream( text_started = false; } - // Close any open tool calls. - for &idx in &tool_call_order { - if let Some(acc) = tool_calls.get(&idx) { - yield Ok(StreamPart::ToolInputEnd { - id: acc.id.clone(), - provider_metadata: None, - }); - let args = &acc.arguments; - let input: Value = serde_json::from_str(args) - .unwrap_or_else(|_| Value::String(args.clone())); - yield Ok(StreamPart::ToolCall { - tool_call_id: acc.id.clone(), - tool_name: acc.name.clone(), - input, - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - }); - } - } - tool_calls.clear(); - tool_call_order.clear(); - final_finish_reason = Some(parse_finish_reason(&reason)); } } @@ -788,16 +763,15 @@ pub async fn execute_stream( }); } - // Close any remaining tool calls (no finish_reason was received). + // A parsable argument buffer can still be a prefix of a longer input. + // Match AI SDK's tracker by finalizing only when the stream flushes. for &idx in &tool_call_order { if let Some(acc) = tool_calls.get(&idx) { yield Ok(StreamPart::ToolInputEnd { id: acc.id.clone(), provider_metadata: None, }); - let args = &acc.arguments; - let input: Value = serde_json::from_str(args) - .unwrap_or_else(|_| Value::String(args.clone())); + let input = Value::String(acc.arguments.clone()); yield Ok(StreamPart::ToolCall { tool_call_id: acc.id.clone(), tool_name: acc.name.clone(), @@ -805,6 +779,8 @@ pub async fn execute_stream( provider_executed: None, dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_convert.rs index 7b8203c8..01ef3cd1 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_convert.rs @@ -189,8 +189,7 @@ pub fn build_responses_generate_result( .and_then(|v| v.as_str()) .unwrap_or("{}") .to_string(); - let input: Value = serde_json::from_str(&arguments) - .unwrap_or_else(|_| Value::String(arguments.clone())); + let input = Value::String(arguments); content.push(GenerateContent::ToolCall { tool_call_id: call_id, tool_name: name, @@ -218,8 +217,10 @@ pub fn build_responses_generate_result( .unwrap_or("") .to_string(); let input_str = part.get("input").and_then(|v| v.as_str()).unwrap_or("{}"); - let input: Value = serde_json::from_str(input_str) - .unwrap_or_else(|_| Value::String(input_str.to_string())); + let input = Value::String( + serde_json::to_string(input_str) + .expect("serializing a custom-tool input string cannot fail"), + ); content.push(GenerateContent::ToolCall { tool_call_id: call_id, tool_name: name, @@ -794,10 +795,7 @@ where id: call_id.clone(), provider_metadata: None, }); - let input: Value = serde_json::from_str(&arguments) - .unwrap_or_else(|_| { - Value::String(arguments.clone()) - }); + let input = Value::String(arguments); yield Ok(StreamPart::ToolCall { tool_call_id: call_id, tool_name: name, @@ -805,6 +803,8 @@ where provider_executed: None, dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } @@ -829,10 +829,11 @@ where id: call_id.clone(), provider_metadata: None, }); - let input: Value = serde_json::from_str(input_str) - .unwrap_or_else(|_| { - Value::String(input_str.to_string()) - }); + let input = Value::String( + serde_json::to_string(input_str).expect( + "serializing a custom-tool input string cannot fail", + ), + ); yield Ok(StreamPart::ToolCall { tool_call_id: call_id, tool_name: name, @@ -840,6 +841,8 @@ where provider_executed: None, dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } diff --git a/aimux-providers/src/vertex/anthropic_model.rs b/aimux-providers/src/vertex/anthropic_model.rs index afcccf47..470bc5a8 100644 --- a/aimux-providers/src/vertex/anthropic_model.rs +++ b/aimux-providers/src/vertex/anthropic_model.rs @@ -327,10 +327,12 @@ impl LanguageModel for VertexAnthropicModel { tool_name: tool_names .to_custom_tool_name(&name) .to_string(), - input: input.clone(), + input: Value::String(input.to_string()), provider_executed: Some(true), dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } @@ -340,10 +342,12 @@ impl LanguageModel for VertexAnthropicModel { yield Ok(StreamPart::ToolCall { tool_call_id: id.clone(), tool_name: name.clone(), - input: input.clone(), + input: Value::String(input.to_string()), provider_executed: Some(true), dynamic: Some(true), thought_signature: None, + invalid: None, + error: None, provider_metadata: Some(json!({ "anthropic": { "type": "mcp-tool-use", @@ -462,14 +466,14 @@ impl LanguageModel for VertexAnthropicModel { accumulated_json, } => { yield Ok(StreamPart::ToolInputEnd { id: id.clone(), provider_metadata: None}); - let input: serde_json::Value = if accumulated_json - .is_empty() - { - serde_json::json!({}) - } else { - serde_json::from_str(&accumulated_json) - .unwrap_or(serde_json::json!({})) - }; + // Empty input normalizes to "{}" + // per the upstream provider. + let input = + Value::String(if accumulated_json.is_empty() { + "{}".to_string() + } else { + accumulated_json + }); yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: name, @@ -477,6 +481,8 @@ impl LanguageModel for VertexAnthropicModel { provider_executed: None, dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index 2b8b5d91..86e6feb0 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -415,10 +415,12 @@ impl LanguageModel for VertexModel { yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: name.to_string(), - input: args, + input: Value::String(args.to_string()), provider_executed: None, dynamic: None, thought_signature, + invalid: None, + error: None, provider_metadata: None, }); has_tool_calls = true; @@ -436,10 +438,12 @@ impl LanguageModel for VertexModel { yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: "code_execution".to_string(), - input: ec.clone(), - provider_executed: None, + input: Value::String(ec.to_string()), + provider_executed: Some(true), dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); // provider-executed → does NOT set has_tool_calls @@ -484,10 +488,12 @@ impl LanguageModel for VertexModel { yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), - input: args, - provider_executed: None, - dynamic: None, + input: Value::String(args.to_string()), + provider_executed: Some(true), + dynamic: Some(true), thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); // provider-executed → does NOT set has_tool_calls @@ -633,7 +639,7 @@ fn extract_content_from_candidate(candidate: &Candidate) -> (Vec { assert_eq!(tool_call_id, "call_abc"); assert_eq!(tool_name, "get-weather"); - assert_eq!(input, &json!({"city": "SF"})); + assert_eq!(input, &Value::String(r#"{"city":"SF"}"#.into())); } other => panic!("expected ToolCall, got {other:?}"), } diff --git a/aimux-providers/tests/anthropic_aws_model_test.rs b/aimux-providers/tests/anthropic_aws_model_test.rs index b612f692..e44175ad 100644 --- a/aimux-providers/tests/anthropic_aws_model_test.rs +++ b/aimux-providers/tests/anthropic_aws_model_test.rs @@ -189,7 +189,7 @@ async fn anthropic_aws_generate_tool_call() { let (id, name, input) = as_tool_call(&result.content[1]); assert_eq!(id, "toolu_01A"); assert_eq!(name, "getWeather"); - assert_eq!(input["location"], "Paris"); + assert_eq!(input, &json!(r#"{"location":"Paris"}"#)); assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } @@ -386,7 +386,10 @@ async fn anthropic_aws_stream_tool_call() { assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0].0, "toolu_01B"); assert_eq!(tool_calls[0].1, "getWeather"); - assert_eq!(tool_calls[0].2["location"], "Berlin"); + assert_eq!( + tool_calls[0].2, + Value::String(r#"{"location":"Berlin"}"#.into()) + ); } /// Test: SigV4 authentication adds Authorization header. diff --git a/aimux-providers/tests/anthropic_model_test.rs b/aimux-providers/tests/anthropic_model_test.rs index 8619ce18..aeefc3ae 100644 --- a/aimux-providers/tests/anthropic_model_test.rs +++ b/aimux-providers/tests/anthropic_model_test.rs @@ -298,7 +298,7 @@ mod do_generate { let (id, name, input) = as_tool_call(&result.content[1]); assert_eq!(id, "toolu_1"); assert_eq!(name, "test-tool"); - assert_eq!(input, &json!({ "value": "example value" })); + assert_eq!(input, &json!(r#"{"value":"example value"}"#)); assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); assert_eq!(result.finish_reason.raw.as_deref(), Some("tool_use")); @@ -459,11 +459,11 @@ mod do_generate { let (id_a, name_a, input_a) = as_tool_call(&result.content[0]); assert_eq!(id_a, "toolu_a"); assert_eq!(name_a, "tool-a"); - assert_eq!(input_a, &json!({ "x": 1 })); + assert_eq!(input_a, &json!(r#"{"x":1}"#)); let (id_b, name_b, input_b) = as_tool_call(&result.content[1]); assert_eq!(id_b, "toolu_b"); assert_eq!(name_b, "tool-b"); - assert_eq!(input_b, &json!({ "y": 2 })); + assert_eq!(input_b, &json!(r#"{"y":2}"#)); } /// Tool input may be a nested object. @@ -502,10 +502,7 @@ mod do_generate { let (_, _, input) = as_tool_call(&result.content[0]); assert_eq!( input, - &json!({ - "nested": { "arr": [1, 2, 3], "flag": true }, - "items": ["a", "b"], - }) + &json!(r#"{"nested":{"arr":[1,2,3],"flag":true},"items":["a","b"]}"#) ); } @@ -1113,7 +1110,7 @@ mod do_stream { if id == "toolu_01DBsB4vvYLnBDzZ5rBSxSLs" ))); - // Final ToolCall carries the parsed accumulated JSON object. + // Provider output stays raw until the Core stream wrapper parses it. let tool_call = parts .iter() .find_map(|p| match p { @@ -1128,7 +1125,10 @@ mod do_stream { .expect("a ToolCall part"); assert_eq!(tool_call.0, "toolu_01DBsB4vvYLnBDzZ5rBSxSLs"); assert_eq!(tool_call.1, "test-tool"); - assert_eq!(tool_call.2, &json!({ "value": "Sparkle Day" })); + assert_eq!( + tool_call.2, + &Value::String(r#"{"value":"Sparkle Day"}"#.into()) + ); // Finish reason reflects tool_use. let finish = parts @@ -1192,7 +1192,7 @@ mod do_stream { _ => None, }) .expect("a ToolCall part"); - assert_eq!(tool_call, json!({})); + assert_eq!(tool_call, Value::String("{}".into())); } // ── misc SSE scenarios ────────────────────────────────────────────────── @@ -1347,7 +1347,7 @@ mod do_stream { .unwrap(); assert_eq!(tool_call.0, "toolu_1"); assert_eq!(tool_call.1, "test-tool"); - assert_eq!(tool_call.2, &json!({ "value": "x" })); + assert_eq!(tool_call.2, &Value::String(r#"{"value":"x"}"#.into())); } /// Two parallel tool_use blocks (index 0 and index 1) each produce their @@ -1396,9 +1396,9 @@ mod do_stream { .collect(); assert_eq!(tool_calls.len(), 2); assert_eq!(tool_calls[0].0, "toolu_a"); - assert_eq!(tool_calls[0].1, json!({ "a": 1 })); + assert_eq!(tool_calls[0].1, Value::String(r#"{"a":1}"#.into())); assert_eq!(tool_calls[1].0, "toolu_b"); - assert_eq!(tool_calls[1].1, json!({ "b": 2 })); + assert_eq!(tool_calls[1].1, Value::String(r#"{"b":2}"#.into())); } // ── pre-stream HTTP errors ────────────────────────────────────────────── diff --git a/aimux-providers/tests/azure_model_test.rs b/aimux-providers/tests/azure_model_test.rs index 50a78c84..a1da57d9 100644 --- a/aimux-providers/tests/azure_model_test.rs +++ b/aimux-providers/tests/azure_model_test.rs @@ -674,7 +674,7 @@ async fn should_extract_tool_call() { } => { assert_eq!(tool_call_id, "call_abc"); assert_eq!(tool_name, "get-weather"); - assert_eq!(input, &json!({"city": "SF"})); + assert_eq!(input, &Value::String(r#"{"city":"SF"}"#.into())); } other => panic!("expected ToolCall, got {other:?}"), } @@ -716,7 +716,7 @@ async fn should_stream_tool_call() { let (id, name, input) = tool_call.expect("should have ToolCall"); assert_eq!(id, "call_abc"); assert_eq!(name, "get-weather"); - assert_eq!(input, json!({"city": "SF"})); + assert_eq!(input, Value::String(r#"{"city":"SF"}"#.into())); } /// TS: "should send a json_schema response format for structured output" diff --git a/aimux-providers/tests/azure_responses_test.rs b/aimux-providers/tests/azure_responses_test.rs index 40be55e6..f3b5de54 100644 --- a/aimux-providers/tests/azure_responses_test.rs +++ b/aimux-providers/tests/azure_responses_test.rs @@ -537,7 +537,10 @@ async fn should_extract_tool_call_content() { assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0].0, "call_abc123"); assert_eq!(tool_calls[0].1, "getWeather"); - assert_eq!(tool_calls[0].2["location"], "San Francisco"); + assert_eq!( + tool_calls[0].2, + Value::String(r#"{"location": "San Francisco"}"#.into()) + ); } /// Usage is extracted from the `usage` field. diff --git a/aimux-providers/tests/bedrock_model_test.rs b/aimux-providers/tests/bedrock_model_test.rs index 7bad2881..28f652f8 100644 --- a/aimux-providers/tests/bedrock_model_test.rs +++ b/aimux-providers/tests/bedrock_model_test.rs @@ -175,7 +175,7 @@ async fn bedrock_generate_tool_call() { let (id, name, input) = as_tool_call(&result.content[1]); assert_eq!(id, "tool_use_123"); assert_eq!(name, "getWeather"); - assert_eq!(input["location"], "San Francisco"); + assert_eq!(input, &json!(r#"{"location":"San Francisco"}"#)); assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } @@ -385,7 +385,10 @@ async fn bedrock_stream_tool_call() { assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0].0, "tool_1"); assert_eq!(tool_calls[0].1, "getWeather"); - assert_eq!(tool_calls[0].2["location"], "SF"); + assert_eq!( + tool_calls[0].2, + Value::String(r#"{"location":"SF"}"#.into()) + ); } /// Test: SigV4 authentication adds Authorization header. diff --git a/aimux-providers/tests/bedrock_remaining_test.rs b/aimux-providers/tests/bedrock_remaining_test.rs index cbb8c119..5ca80bb9 100644 --- a/aimux-providers/tests/bedrock_remaining_test.rs +++ b/aimux-providers/tests/bedrock_remaining_test.rs @@ -946,8 +946,7 @@ async fn stream_tool_call_empty_input() { assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0].0, "tool_1"); assert_eq!(tool_calls[0].1, "updateIssueList"); - // TS expects input == {} (empty object), not null. - assert_eq!(tool_calls[0].2, json!({})); + assert_eq!(tool_calls[0].2, Value::String("{}".into())); } // ── omit toolConfig ────────────────────────────────────────────────────────── @@ -1138,7 +1137,7 @@ async fn generate_tool_call_empty_input() { assert_eq!(id, "tool_1"); assert_eq!(name, "updateIssueList"); // TS expects input == {} (empty object), not null. - assert_eq!(input, &json!({})); + assert_eq!(input, &json!("{}")); } // ── doGenerate: basic text + finish reason (sanity, already covered but diff --git a/aimux-providers/tests/codex_test.rs b/aimux-providers/tests/codex_test.rs index 7ce2e17d..c0974477 100644 --- a/aimux-providers/tests/codex_test.rs +++ b/aimux-providers/tests/codex_test.rs @@ -275,7 +275,7 @@ async fn api_key_streams_tool_calls() { } => { assert_eq!(tool_call_id, "call_done"); assert_eq!(tool_name, "weather"); - assert_eq!(input["location"], "Rome"); + assert_eq!(input, &Value::String(r#"{"location":"Rome"}"#.into())); } other => panic!("expected ToolCall, got {other:?}"), } diff --git a/aimux-providers/tests/cohere_model_test.rs b/aimux-providers/tests/cohere_model_test.rs index 54472adc..250fac63 100644 --- a/aimux-providers/tests/cohere_model_test.rs +++ b/aimux-providers/tests/cohere_model_test.rs @@ -1,4 +1,4 @@ -//! Wiremock tests for the Cohere provider. +//! Wiremock tests for the Cohere provider. //! //! Translated from `packages/cohere/src/cohere-chat-language-model.test.ts`, //! focusing on the cases that the Rust data model can express: @@ -264,7 +264,10 @@ async fn should_extract_tool_calls() { tool_name, input, .. } => { assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!({"location": "San Francisco"})); + assert_eq!( + input, + &Value::String(r#"{"location":"San Francisco"}"#.into()) + ); } other => panic!("expected ToolCall, got {other:?}"), } @@ -273,7 +276,7 @@ async fn should_extract_tool_calls() { tool_name, input, .. } => { assert_eq!(tool_name, "cityAttractions"); - assert_eq!(input, &json!({"city": "San Francisco"})); + assert_eq!(input, &Value::String(r#"{"city":"San Francisco"}"#.into())); } other => panic!("expected ToolCall, got {other:?}"), } @@ -321,7 +324,7 @@ async fn should_handle_null_tool_call_arguments() { match &result.content[0] { GenerateContent::ToolCall { input, .. } => { // "null" should be replaced with "{}". - assert_eq!(input, &json!({})); + assert_eq!(input, &Value::String("{}".into())); } other => panic!("expected ToolCall, got {other:?}"), } @@ -548,7 +551,12 @@ async fn should_stream_tool_call_deltas() { let (id, name, input) = tool_call.expect("should have ToolCall"); assert_eq!(id, "weather_e8p4pn45zt0t"); assert_eq!(name, "weather"); - assert_eq!(input, &json!({"location": "San Francisco"})); + // The flush parses and re-serializes compactly, so interior whitespace + // from the deltas is dropped. + assert_eq!( + input, + &Value::String(r#"{"location":"San Francisco"}"#.into()) + ); // Verify the accumulated deltas. let deltas: Vec = parts @@ -1477,8 +1485,7 @@ async fn should_stream_empty_tool_call_arguments() { let (id, name, input) = tool_call.expect("should have ToolCall"); assert_eq!(id, "tc_empty"); assert_eq!(name, "doThing"); - // Empty arguments → empty object. - assert_eq!(input, json!({})); + assert_eq!(input, Value::String("{}".into())); let finish = parts.last().expect("should have finish"); match finish { diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index 1cbf5b30..7b1f2b76 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -200,7 +200,7 @@ fn reasonings(content: &[GenerateContent]) -> Vec<(&str, Option<&Value>)> { type ToolCallView<'a> = ( &'a str, &'a str, - &'a Value, + Value, Option, Option, Option<&'a str>, @@ -219,15 +219,24 @@ fn tool_calls(content: &[GenerateContent]) -> Vec> { dynamic, thought_signature, provider_metadata, - } => Some(( - tool_call_id.as_str(), - tool_name.as_str(), - input, - *provider_executed, - *dynamic, - thought_signature.as_deref(), - provider_metadata.as_ref(), - )), + } => { + // Provider results intentionally carry the exact wire string; + // parse only in this assertion helper so nested data-loss + // checks remain readable without weakening that boundary. + let parsed_input = input + .as_str() + .and_then(|raw| serde_json::from_str(raw).ok()) + .unwrap_or_else(|| input.clone()); + Some(( + tool_call_id.as_str(), + tool_name.as_str(), + parsed_input, + *provider_executed, + *dynamic, + thought_signature.as_deref(), + provider_metadata.as_ref(), + )) + } _ => None, }) .collect() @@ -555,7 +564,7 @@ async fn finding_5_gemini_function_call_thought_signature_round_trips() { let calls = tool_calls(&result.content); assert_eq!(calls.len(), 1); assert_eq!(calls[0].1, "add"); - assert_eq!(calls[0].2, &json!({ "x": 1, "y": 1 })); + assert_eq!(calls[0].2, json!({ "x": 1, "y": 1 })); assert_eq!( calls[0].5, Some("signature_REDACTED_1"), @@ -878,7 +887,7 @@ async fn finding_2_anthropic_web_fetch_result_mapped() { let calls = tool_calls(&result.content); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, "srvtoolu_01So85wNUocinTvFfgKCfQeb"); - assert_eq!(calls[0].2, &json!({ "url": "https://ai.pydantic.dev" })); + assert_eq!(calls[0].2, json!({ "url": "https://ai.pydantic.dev" })); // The thinking block on the same response keeps its signature. let r = reasonings(&result.content); @@ -1023,7 +1032,7 @@ async fn finding_2_anthropic_mcp_tool_use_and_result_are_dynamic_and_named() { // mcp_tool_use → provider-executed + dynamic + serverName metadata. let calls = tool_calls(&result.content); assert_eq!(calls.len(), 1); - let (id, name, input, provider_executed, dynamic, _, meta) = calls[0]; + let (id, name, ref input, provider_executed, dynamic, _, meta) = calls[0]; assert_eq!(id, "mcptoolu_01SAss3KEwASziHZoMR6HcZU"); assert_eq!(name, "ask_question"); assert_eq!(input["repoName"], json!("pydantic/pydantic-ai")); diff --git a/aimux-providers/tests/deepseek_chat_test.rs b/aimux-providers/tests/deepseek_chat_test.rs index c85cdf25..36729ff0 100644 --- a/aimux-providers/tests/deepseek_chat_test.rs +++ b/aimux-providers/tests/deepseek_chat_test.rs @@ -417,7 +417,10 @@ async fn should_extract_tool_call_content() { } => { assert_eq!(tool_call_id, "call_00_9V0vrf86Pc9aelHCJMZqnJBo"); assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!({ "location": "San Francisco" })); + assert_eq!( + input, + &Value::String(r#"{"location": "San Francisco"}"#.into()) + ); } other => panic!("expected ToolCall, got {other:?}"), } @@ -754,7 +757,10 @@ async fn should_stream_tool_call() { assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0].0, "call_1"); assert_eq!(tool_calls[0].1, "weather"); - assert_eq!(tool_calls[0].2, &json!({ "location": "San Francisco" })); + assert_eq!( + tool_calls[0].2, + &Value::String(r#"{"location": "San Francisco"}"#.into()) + ); // The last part should be Finish with ToolCalls. match parts.last() { diff --git a/aimux-providers/tests/google_model_test.rs b/aimux-providers/tests/google_model_test.rs index 452ce1f2..9176e1c9 100644 --- a/aimux-providers/tests/google_model_test.rs +++ b/aimux-providers/tests/google_model_test.rs @@ -426,7 +426,7 @@ mod do_generate { } => { assert_eq!(tool_call_id, "call-1"); assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!({ "location": "San Francisco" })); + assert_eq!(input, &json!(r#"{"location":"San Francisco"}"#)); } other => panic!("expected ToolCall, got {other:?}"), } @@ -482,7 +482,7 @@ mod do_generate { } => { assert_eq!(tool_call_id, "call-1"); assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!({ "location": "San Francisco" })); + assert_eq!(input, &json!(r#"{"location":"San Francisco"}"#)); assert_eq!( thought_signature.as_deref(), Some("EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH") @@ -878,7 +878,7 @@ mod do_generate { } => { assert_eq!(tool_call_id, ""); assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!({ "location": "SF" })); + assert_eq!(input, &json!(r#"{"location":"SF"}"#)); } other => panic!("expected ToolCall, got {other:?}"), } @@ -938,7 +938,7 @@ mod do_generate { } => { assert_eq!(tool_call_id, "call-2"); assert_eq!(tool_name, "calendar"); - assert_eq!(input, &json!({ "date": "2024-01-01" })); + assert_eq!(input, &json!(r#"{"date":"2024-01-01"}"#)); } other => panic!("expected second ToolCall, got {other:?}"), } @@ -1174,7 +1174,7 @@ mod do_stream { matches!(p, StreamPart::ToolCall { tool_call_id, tool_name, input, .. } if tool_call_id == "call-1" && tool_name == "weather" - && input == &json!({"location": "San Francisco"})) + && input == &json!(r#"{"location":"San Francisco"}"#)) }); assert!( tool_call.is_some(), diff --git a/aimux-providers/tests/google_provider_tools_test.rs b/aimux-providers/tests/google_provider_tools_test.rs index 9e49d2f8..b6318c21 100644 --- a/aimux-providers/tests/google_provider_tools_test.rs +++ b/aimux-providers/tests/google_provider_tools_test.rs @@ -1,4 +1,4 @@ -//! Tests for Google provider-defined tools (`google_search`, `code_execution`, +//! Tests for Google provider-defined tools (`google_search`, `code_execution`, //! `url_context`, `google_maps`) and their response metadata (`groundingMetadata`, //! `urlContextMetadata`). //! @@ -1140,7 +1140,7 @@ mod do_generate { let calls = gen_tool_calls(&result.content); let has_call = calls.iter().any(|(_, name, input)| { name == "code_execution" - && *input == json!({ "language": "PYTHON", "code": "print(1+1)" }) + && *input == json!(r#"{"language":"PYTHON","code":"print(1+1)"}"#) }); assert!( has_call, @@ -1191,7 +1191,7 @@ mod do_generate { assert_eq!(calls[0].1, "code_execution"); assert_eq!( calls[0].2, - json!({ "language": "PYTHON", "code": "print(1+1)" }) + json!(r#"{"language":"PYTHON","code":"print(1+1)"}"#) ); let results = gen_tool_results(&result.content); @@ -1478,7 +1478,7 @@ mod do_generate { assert_eq!(calls.len(), 1, "one toolCall part → one tool-call"); assert_eq!(calls[0].0, "server-call-1", "the server-assigned id"); assert_eq!(calls[0].1, "server:GOOGLE_SEARCH_WEB"); - assert_eq!(calls[0].2, json!({ "query": "San Francisco weather" })); + assert_eq!(calls[0].2, json!(r#"{"query":"San Francisco weather"}"#)); // The matching toolResponse part. let results = gen_tool_results(&result.content); @@ -1830,7 +1830,7 @@ mod do_stream { let calls = stream_tool_calls(&parts); let has_call = calls.iter().any(|(_, name, input)| { name == "code_execution" - && *input == json!({ "language": "PYTHON", "code": "print(\"hello\")" }) + && *input == json!(r#"{"language":"PYTHON","code":"print(\"hello\")"}"#) }); assert!( has_call, @@ -2024,7 +2024,7 @@ mod do_stream { assert_eq!(calls.len(), 1, "one toolCall part → one streamed tool-call"); assert_eq!(calls[0].0, "server-call-1"); assert_eq!(calls[0].1, "server:GOOGLE_SEARCH_WEB"); - assert_eq!(calls[0].2, json!({ "query": "San Francisco weather" })); + assert_eq!(calls[0].2, json!(r#"{"query":"San Francisco weather"}"#)); let results = stream_tool_results(&parts); assert_eq!( diff --git a/aimux-providers/tests/groq_test.rs b/aimux-providers/tests/groq_test.rs index 7a79526a..276f3294 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -1736,7 +1736,7 @@ mod do_stream { let (id, name, input) = tool_call.expect("should have tool call"); assert_eq!(id, "call_abc"); assert_eq!(name, "test-tool"); - assert_eq!(input, json!({"value": "Sparkle Day"})); + assert_eq!(input, Value::String(r#"{"value":"Sparkle Day"}"#.into())); // Should have tool-calls finish reason let finish = parts.iter().find_map(|p| match p { @@ -1793,7 +1793,10 @@ mod do_stream { StreamPart::ToolCall { input, .. } => Some(input.clone()), _ => None, }); - assert_eq!(tool_call.unwrap(), json!({"value": "Sparkle Day"})); + assert_eq!( + tool_call.unwrap(), + Value::String(r#"{"value":"Sparkle Day"}"#.into()) + ); } /// TS: "should stream usage from x_groq.usage" diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index cd5420d0..c5ebf629 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -1,4 +1,4 @@ -//! Hugging Face Responses API tests, translated from the Vercel AI SDK +//! Hugging Face Responses API tests, translated from the Vercel AI SDK //! TypeScript suite. //! //! Translation source: @@ -543,7 +543,10 @@ async fn should_handle_mcp_tools_with_annotations() { } => { assert_eq!(tool_call_id, "mcp_search_test"); assert_eq!(tool_name, "search"); - assert_eq!(input, &json!({ "query": "San Francisco tech events" })); + assert_eq!( + input, + &Value::String(r#"{"query": "San Francisco tech events"}"#.into()) + ); } other => panic!("expected ToolCall at [0], got {other:?}"), } @@ -1104,7 +1107,7 @@ async fn should_handle_function_call_tool_responses() { } => { assert_eq!(tool_call_id, "call_123"); assert_eq!(tool_name, "getWeather"); - assert_eq!(input, &json!({ "location": "New York" })); + assert_eq!(input, &Value::String(r#"{"location": "New York"}"#.into())); } other => panic!("expected ToolCall at [0], got {other:?}"), } @@ -1186,7 +1189,10 @@ async fn should_stream_tool_calls() { } => { assert_eq!(tool_call_id, "call_456"); assert_eq!(tool_name, "calculator"); - assert_eq!(input, &json!({ "operation": "add", "a": 5, "b": 3 })); + assert_eq!( + input, + &Value::String(r#"{"operation": "add", "a": 5, "b": 3}"#.into()) + ); } other => panic!("expected ToolCall, got {other:?}"), } diff --git a/aimux-providers/tests/mistral_model_test.rs b/aimux-providers/tests/mistral_model_test.rs index cc24a260..e5583b71 100644 --- a/aimux-providers/tests/mistral_model_test.rs +++ b/aimux-providers/tests/mistral_model_test.rs @@ -310,7 +310,10 @@ async fn should_extract_tool_call() { } => { assert_eq!(tool_call_id, "gSIMJiOkT"); assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!({"location": "San Francisco"})); + assert_eq!( + input, + &Value::String(r#"{"location": "San Francisco"}"#.into()) + ); } other => panic!("expected ToolCall, got {other:?}"), } @@ -609,7 +612,10 @@ async fn should_stream_tool_call() { let (id, name, input) = tool_call.expect("should have a ToolCall"); assert_eq!(id, "gSIMJiOkT"); assert_eq!(name, "weather"); - assert_eq!(input, &json!({"location": "San Francisco"})); + assert_eq!( + input, + &Value::String(r#"{"location": "San Francisco"}"#.into()) + ); // Should also have ToolInputStart, ToolInputDelta, ToolInputEnd. assert!( @@ -1223,7 +1229,7 @@ async fn should_extract_multiple_tool_calls() { } => { assert_eq!(tool_call_id, "call-1"); assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!({"city": "SF"})); + assert_eq!(input, &Value::String(r#"{"city": "SF"}"#.into())); } other => panic!("expected ToolCall, got {other:?}"), } @@ -1236,7 +1242,7 @@ async fn should_extract_multiple_tool_calls() { } => { assert_eq!(tool_call_id, "call-2"); assert_eq!(tool_name, "time"); - assert_eq!(input, &json!({"zone": "PST"})); + assert_eq!(input, &Value::String(r#"{"zone": "PST"}"#.into())); } other => panic!("expected ToolCall, got {other:?}"), } diff --git a/aimux-providers/tests/open_responses_test.rs b/aimux-providers/tests/open_responses_test.rs index c0645338..f2821d69 100644 --- a/aimux-providers/tests/open_responses_test.rs +++ b/aimux-providers/tests/open_responses_test.rs @@ -1447,7 +1447,10 @@ mod do_generate_tests { } => { assert_eq!(tool_call_id, "call_2866856768160095"); assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!({"location": "San Francisco"})); + assert_eq!( + input, + &Value::String(r#"{"location":"San Francisco"}"#.into()) + ); } other => panic!("expected ToolCall, got {other:?}"), } @@ -2018,7 +2021,7 @@ mod do_stream_tests { let (tc_id, tc_name, tc_input) = tool_call.unwrap(); assert_eq!(tc_id, "call_1"); assert_eq!(tc_name, "weather"); - assert_eq!(tc_input, &json!({"location": "SF"})); + assert_eq!(tc_input, &Value::String(r#"{"location":"SF"}"#.into())); // Finish with tool-calls reason let finish = parts.iter().find_map(|p| match p { diff --git a/aimux-providers/tests/openai_compatible_test.rs b/aimux-providers/tests/openai_compatible_test.rs index 4a5f201d..8e49e65c 100644 --- a/aimux-providers/tests/openai_compatible_test.rs +++ b/aimux-providers/tests/openai_compatible_test.rs @@ -595,7 +595,7 @@ macro_rules! openai_compatible_tool_tests { GenerateContent::ToolCall { tool_call_id, tool_name, input, .. } => { assert_eq!(tool_call_id, "call_abc"); assert_eq!(tool_name, "get-weather"); - assert_eq!(input, &json!({"city": "SF"})); + assert_eq!(input, &Value::String(r#"{"city":"SF"}"#.into())); } other => panic!("expected ToolCall, got {:?}", other), } @@ -638,7 +638,7 @@ macro_rules! openai_compatible_tool_tests { let (id, name, input) = tool_call.expect("should have ToolCall"); assert_eq!(id, "call_abc"); assert_eq!(name, "get-weather"); - assert_eq!(input, json!({"city": "SF"})); + assert_eq!(input, Value::String(r#"{"city":"SF"}"#.into())); } } } @@ -691,7 +691,7 @@ macro_rules! openai_compatible_tool_tests { GenerateContent::ToolCall { tool_call_id, tool_name, input, .. } => { assert_eq!(tool_call_id, "call_abc"); assert_eq!(tool_name, "get-weather"); - assert_eq!(input, &json!({"city": "SF"})); + assert_eq!(input, &Value::String(r#"{"city":"SF"}"#.into())); } other => panic!("expected ToolCall, got {:?}", other), } @@ -734,7 +734,7 @@ macro_rules! openai_compatible_tool_tests { let (id, name, input) = tool_call.expect("should have ToolCall"); assert_eq!(id, "call_abc"); assert_eq!(name, "get-weather"); - assert_eq!(input, json!({"city": "SF"})); + assert_eq!(input, Value::String(r#"{"city":"SF"}"#.into())); } } } diff --git a/aimux-providers/tests/openai_model_test.rs b/aimux-providers/tests/openai_model_test.rs index 16e413ac..abe14067 100644 --- a/aimux-providers/tests/openai_model_test.rs +++ b/aimux-providers/tests/openai_model_test.rs @@ -742,7 +742,7 @@ mod do_generate { } => { assert_eq!(tool_call_id, "call_O17Uplv4lJvD6DVdIvFFeRMw"); assert_eq!(tool_name, "test-tool"); - assert_eq!(input, &json!({"value": "Spark"})); + assert_eq!(input, &Value::String(r#"{"value":"Spark"}"#.into())); } other => panic!("expected ToolCall, got {other:?}"), } @@ -991,7 +991,7 @@ mod do_stream { matches!(p, StreamPart::ToolCall { tool_call_id, tool_name, input, .. } if tool_call_id == "call_O17Uplv4lJvD6DVdIvFFeRMw" && tool_name == "test-tool" - && input == &json!({"value": "Sparkle Day"})) + && input == &Value::String(r#"{"value":"Sparkle Day"}"#.into())) }); assert!( tool_call.is_some(), @@ -1071,7 +1071,7 @@ mod do_stream { let tool_call = parts.iter().find(|p| { matches!(p, StreamPart::ToolCall { tool_call_id, input, .. } if tool_call_id == "call_O17Uplv4lJvD6DVdIvFFeRMw" - && input == &json!({"value": "Sparkle Day"})) + && input == &Value::String(r#"{"value":"Sparkle Day"}"#.into())) }); assert!( tool_call.is_some(), @@ -1150,7 +1150,10 @@ mod do_stream { "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa" ); assert_eq!(tool_name, "searchGoogle"); - assert_eq!(input, &json!({"query": "latest news on ai"})); + assert_eq!( + input, + &Value::String(r#"{"query": "latest news on ai"}"#.into()) + ); } other => panic!("expected ToolCall, got {other:?}"), } @@ -1174,10 +1177,10 @@ mod do_stream { r#"{"id":"chatcmpl-early","object":"chat.completion.chunk","created":1733162241,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":""}}]},"finish_reason":null}]}"#, ), &sse_event( - r#"{"id":"chatcmpl-early","object":"chat.completion.chunk","created":1733162241,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":", \"limit\": 10}"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-early","object":"chat.completion.chunk","created":1733162241,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#, ), &sse_event( - r#"{"id":"chatcmpl-early","object":"chat.completion.chunk","created":1733162241,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#, + r#"{"id":"chatcmpl-early","object":"chat.completion.chunk","created":1733162241,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":", \"limit\": 10}"}}]},"finish_reason":null}]}"#, ), ]); mock_sse_response(&server, &body).await; @@ -1265,7 +1268,7 @@ mod do_stream { let tool_call = parts.iter().find(|p| { matches!(p, StreamPart::ToolCall { tool_call_id, input, .. } if tool_call_id == "call_abc123" - && input == &json!({"value": "hello"})) + && input == &Value::String(r#"{"value":"hello"}"#.into())) }); assert!( tool_call.is_some(), @@ -1322,7 +1325,7 @@ mod do_stream { assert!(parts.iter().any(|p| { matches!(p, StreamPart::ToolCall { tool_call_id, input, .. } if tool_call_id == "call_O17Uplv4lJvD6DVdIvFFeRMw" - && input == &json!({"value": "Sparkle Day"})) + && input == &Value::String(r#"{"value":"Sparkle Day"}"#.into())) })); } diff --git a/aimux-providers/tests/openai_responses_test.rs b/aimux-providers/tests/openai_responses_test.rs index 38c29f2b..07c01a1c 100644 --- a/aimux-providers/tests/openai_responses_test.rs +++ b/aimux-providers/tests/openai_responses_test.rs @@ -869,7 +869,10 @@ mod do_generate_response { } => { assert_eq!(tool_call_id, "call_abc"); assert_eq!(tool_name, "weather"); - assert_eq!(input["location"], "San Francisco"); + assert_eq!( + input, + &Value::String(r#"{"location":"San Francisco"}"#.into()) + ); } other => panic!("expected ToolCall, got {other:?}"), } @@ -1144,7 +1147,7 @@ mod do_stream { } => { assert_eq!(tool_call_id, "call_done"); assert_eq!(tool_name, "weather"); - assert_eq!(input["location"], "Rome"); + assert_eq!(input, &Value::String(r#"{"location":"Rome"}"#.into())); } other => panic!("expected ToolCall, got {other:?}"), } diff --git a/aimux-providers/tests/openrouter_test.rs b/aimux-providers/tests/openrouter_test.rs index 4a75fc44..320979af 100644 --- a/aimux-providers/tests/openrouter_test.rs +++ b/aimux-providers/tests/openrouter_test.rs @@ -407,7 +407,7 @@ async fn do_generate_extracts_tool_call() { } => { assert_eq!(tool_call_id, "call_abc"); assert_eq!(tool_name, "get-weather"); - assert_eq!(input, &json!({"city": "SF"})); + assert_eq!(input, &Value::String(r#"{"city":"SF"}"#.into())); } other => panic!("expected ToolCall, got {other:?}"), } @@ -538,7 +538,7 @@ async fn do_stream_emits_tool_call() { let (id, name, input) = tool_call.expect("should have ToolCall"); assert_eq!(id, "call_abc"); assert_eq!(name, "get-weather"); - assert_eq!(input, json!({"city": "SF"})); + assert_eq!(input, Value::String(r#"{"city":"SF"}"#.into())); } /// A 401 response maps to `AiMuxError::ApiCall` (401 in `status_code`). diff --git a/aimux-providers/tests/vertex_model_test.rs b/aimux-providers/tests/vertex_model_test.rs index 33d093e6..5205efeb 100644 --- a/aimux-providers/tests/vertex_model_test.rs +++ b/aimux-providers/tests/vertex_model_test.rs @@ -197,7 +197,7 @@ async fn vertex_generate_tool_call() { let (id, name, input) = as_tool_call(&result.content[0]); assert_eq!(id, "call_1"); assert_eq!(name, "getWeather"); - assert_eq!(input["location"], "Tokyo"); + assert_eq!(input, &json!(r#"{"location":"Tokyo"}"#)); // STOP with tool calls → ToolCalls assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } @@ -249,7 +249,7 @@ async fn vertex_generate_tool_call_with_thought_signature() { } => { assert_eq!(tool_call_id, "call_1"); assert_eq!(tool_name, "getWeather"); - assert_eq!(input["location"], "Tokyo"); + assert_eq!(input, &json!(r#"{"location":"Tokyo"}"#)); assert_eq!( thought_signature.as_deref(), Some("EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH") @@ -567,7 +567,7 @@ async fn vertex_stream_tool_call() { let (id, name, input) = tool_call.expect("should have ToolCall"); assert_eq!(id, "call_1"); assert_eq!(name, "getWeather"); - assert_eq!(input["location"], "Tokyo"); + assert_eq!(input, json!(r#"{"location":"Tokyo"}"#)); } /// TS: response headers are exposed on the stream result. @@ -747,7 +747,7 @@ async fn vertex_stream_code_execution_tool_calls_and_results() { let calls = stream_tool_calls(&parts); let has_call = calls.iter().any(|(_, name, input)| { name == "code_execution" - && *input == json!({ "language": "PYTHON", "code": "print(\"hello\")" }) + && *input == json!(r#"{"language":"PYTHON","code":"print(\"hello\")"}"#) }); assert!( has_call, diff --git a/aimux-providers/tests/xai_responses_test.rs b/aimux-providers/tests/xai_responses_test.rs index b2f14b68..ba25f913 100644 --- a/aimux-providers/tests/xai_responses_test.rs +++ b/aimux-providers/tests/xai_responses_test.rs @@ -1509,7 +1509,7 @@ mod tools { } => { assert_eq!(tool_call_id, "call_123"); assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!({ "location": "sf" })); + assert_eq!(input, &Value::String(r#"{"location":"sf"}"#.into())); } other => panic!("expected ToolCall, got {other:?}"), } @@ -2003,7 +2003,7 @@ mod do_stream { { assert_eq!(tool_call_id, "call_123"); assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!({ "location": "sf" })); + assert_eq!(input, &Value::String(r#"{"location":"sf"}"#.into())); } // Finish reason should be tool-calls. diff --git a/aimux-providers/tests/xai_test.rs b/aimux-providers/tests/xai_test.rs index 94a92d1b..71a4dfb4 100644 --- a/aimux-providers/tests/xai_test.rs +++ b/aimux-providers/tests/xai_test.rs @@ -1412,7 +1412,10 @@ mod do_generate { let (id, name, input) = tool_call.expect("should have ToolCall"); assert_eq!(id, "call_93562515"); assert_eq!(name, "weather"); - assert_eq!(input, json!({"location": "San Francisco"})); + assert_eq!( + input, + Value::String(r#"{"location":"San Francisco"}"#.into()) + ); assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } @@ -2008,7 +2011,10 @@ mod do_stream { let (id, name, input) = tool_call.expect("should have ToolCall"); assert_eq!(id, "call_55117580"); assert_eq!(name, "weather"); - assert_eq!(input, json!({"location": "San Francisco"})); + assert_eq!( + input, + Value::String(r#"{"location":"San Francisco"}"#.into()) + ); } /// TS: should pass the messages (stream request body) diff --git a/bindings/flutter/lib/errors.dart b/bindings/flutter/lib/errors.dart index b034a25d..a69744a8 100644 --- a/bindings/flutter/lib/errors.dart +++ b/bindings/flutter/lib/errors.dart @@ -4,7 +4,7 @@ // Transport (aimux-error.h): every fallible C call returns // `aimux_error_t *` — NULL on success (the result is in the trailing // out-param), non-NULL on failure (out-param at its sentinel: 0 / NULL). The -// unified code selects AiMuxError (1..14), RecordingError (100..105), or a +// unified code selects AiMuxError (1..13, 15..17), RecordingError (100..105), or a // C ABI failure (200..206). The last range maps to // StateError('aimux ffi: …'); Dart does not expose seven additional classes. // Every field is copied before the error is released with `aimux_error_free` @@ -22,9 +22,9 @@ import 'package:ffi/ffi.dart'; // ───────────────────────────────────────────────────────────────────────────── /// Machine-readable codes. Values match C `aimux_error_code_t` / Go `Code`. -/// 14 live variant codes: 1–14 (1 is the catch-all, [retry] at 14 reclaims -/// the slot the pre-unification `Other` vacated). A code outside that -/// set is a header/library mismatch and fails with [StateError], not an +/// 15 variant codes: 1–13 plus 15–17 (1 is the catch-all; 4 is retired, 14 is +/// reserved). A code outside +/// that set is a header/library mismatch and fails with [StateError], not an /// error type. Every HTTP-shaped failure /// arrives as [apiCall], classified /// by [AimuxException.status] (401 auth, 404 model, 429 rate limit; @@ -34,7 +34,6 @@ abstract final class AimuxErrorCode { static const int ok = 0; static const int jsonParse = 2; static const int invalidResponseData = 3; - static const int tool = 4; static const int invalidArgument = 5; static const int invalidPrompt = 6; static const int tokenExpired = 7; @@ -44,6 +43,9 @@ abstract final class AimuxErrorCode { static const int apiCall = 11; static const int timeout = 12; static const int aborted = 13; + static const int noSuchTool = 15; + static const int invalidToolInput = 16; + static const int toolCallRepair = 17; static const int other = 1; static const int retry = 14; @@ -51,7 +53,6 @@ abstract final class AimuxErrorCode { ok: 'OK', jsonParse: 'JsonParse', invalidResponseData: 'InvalidResponseData', - tool: 'Tool', invalidArgument: 'InvalidArgument', invalidPrompt: 'InvalidPrompt', tokenExpired: 'TokenExpired', @@ -61,6 +62,9 @@ abstract final class AimuxErrorCode { apiCall: 'ApiCall', timeout: 'Timeout', aborted: 'Aborted', + noSuchTool: 'NoSuchTool', + invalidToolInput: 'InvalidToolInput', + toolCallRepair: 'ToolCallRepair', other: 'Other', retry: 'Retry', }; @@ -75,7 +79,7 @@ abstract final class AimuxErrorCode { /// Decode the `aimux_error_t *` [e] returned by a call that can fail in /// `AiMuxError` (`[AiMuxError]` in aimux-ffi.h). NULL → returns (success). -/// Codes 1..14 become [AimuxException]; 200..206 become [StateError]. +/// Codes 1..13 / 15..17 become [AimuxException]; 200..206 become [StateError]. /// The returned error is always freed. void expectAimuxError(Pointer e, String context) { if (e == nullptr) return; @@ -308,6 +312,10 @@ final Pointer Function(Pointer, int) _errorRetryErrorAt = _errLib.lookupFunction Function(Pointer, Int32), Pointer Function(Pointer, int)>( 'aimux_error_retry_error_at'); +final _StrOfPtr _errorToolName = _strGetter('aimux_error_tool_name'); +final _StrOfPtr _errorAvailableTools = _strGetter('aimux_error_available_tools'); +final _StrOfPtr _errorToolInput = _strGetter('aimux_error_tool_input'); +final _StrOfPtr _errorOriginalError = _strGetter('aimux_error_original_error'); /// Read an owned getter string for [error]; frees it; null when absent. String? _errStr(_StrOfPtr getter, Pointer error) { @@ -474,6 +482,8 @@ int construct2( /// `responseHeaders`/`responseBody`/`data`), [RetryError] (`reason`/`errors`), /// [NoSuchModelError] (`modelId`/`modelType`), [NoSuchProviderError] /// (`providerId`). +/// [NoSuchToolError] (`toolName`/`availableTools`), [InvalidToolInputError] +/// (`toolName`/`toolInput`), [ToolCallRepairError] (`originalError`). class AimuxException implements Exception { /// Human-readable failure text. final String message; @@ -504,7 +514,7 @@ class AimuxException implements Exception { /// Build the typed subclass from a returned `const aimux_error_t *` [error] /// via the `aimux_error_*` getters (payload getters only under the owning /// code; getter strings freed here). The caller ([expectAimuxError]) - /// frees it. A code outside the published set (1..14) is a + /// frees it. A code outside 1..13 / 15..17 is a /// contract violation and throws [StateError]. factory AimuxException._decode(Pointer error, String context) { final code = _errorCode(error); @@ -540,6 +550,25 @@ class AimuxException implements Exception { return NoSuchProviderError(message, retryable: retryable, providerId: _errStr(_errorProviderId, error) ?? ''); + case AimuxErrorCode.noSuchTool: + // The accessor delivers the tool set as a JSON string array, or NULL + // when no tool set was supplied. + final tools = _errStr(_errorAvailableTools, error); + return NoSuchToolError(message, + retryable: retryable, + toolName: _errStr(_errorToolName, error) ?? '', + availableTools: + tools == null ? null : (jsonDecode(tools) as List).cast()); + case AimuxErrorCode.invalidToolInput: + return InvalidToolInputError(message, + retryable: retryable, + toolName: _errStr(_errorToolName, error) ?? '', + toolInput: _errStr(_errorToolInput, error) ?? ''); + case AimuxErrorCode.toolCallRepair: + final original = _errStr(_errorOriginalError, error); + return ToolCallRepairError(message, + retryable: retryable, + originalError: original == null ? null : jsonDecode(original)); default: try { return AimuxException.fromCode(code, message, retryable: retryable); @@ -562,8 +591,6 @@ class AimuxException implements Exception { return JSONParseError(message, status: status, retryMs: retryMs, retryable: retryable); case AimuxErrorCode.invalidResponseData: return InvalidResponseDataError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.tool: - return ToolError(message, status: status, retryMs: retryMs, retryable: retryable); case AimuxErrorCode.invalidArgument: return InvalidArgumentError(message, status: status, retryMs: retryMs, retryable: retryable); case AimuxErrorCode.invalidPrompt: @@ -595,6 +622,12 @@ class AimuxException implements Exception { return AimuxTimeoutError(message, status: status, retryMs: retryMs, retryable: retryable); case AimuxErrorCode.aborted: return RequestAbortedError(message, status: status, retryMs: retryMs, retryable: retryable); + case AimuxErrorCode.noSuchTool: + return NoSuchToolError(message, status: status, retryMs: retryMs, retryable: retryable); + case AimuxErrorCode.invalidToolInput: + return InvalidToolInputError(message, status: status, retryMs: retryMs, retryable: retryable); + case AimuxErrorCode.toolCallRepair: + return ToolCallRepairError(message, status: status, retryMs: retryMs, retryable: retryable); case AimuxErrorCode.other: return OtherError(message, status: status, retryMs: retryMs, retryable: retryable); default: @@ -624,10 +657,50 @@ class InvalidResponseDataError extends AimuxException { : super(code: AimuxErrorCode.invalidResponseData); } -/// Tool-related failure. -class ToolError extends AimuxException { - ToolError(super.message, {super.status, super.retryMs, super.retryable}) - : super(code: AimuxErrorCode.tool); +/// The model called a tool that is not in the supplied tool set. +class NoSuchToolError extends AimuxException { + /// The tool name the model called. + final String toolName; + + /// The available tool names, or null when no tool set was supplied. + final List? availableTools; + + NoSuchToolError(super.message, + {super.status, + super.retryMs, + super.retryable, + this.toolName = '', + this.availableTools}) + : super(code: AimuxErrorCode.noSuchTool); +} + +/// The model produced tool arguments that fail to parse or validate. +class InvalidToolInputError extends AimuxException { + /// The tool name the model called. + final String toolName; + + /// The raw argument text the model produced. + final String toolInput; + + InvalidToolInputError(super.message, + {super.status, + super.retryMs, + super.retryable, + this.toolName = '', + this.toolInput = ''}) + : super(code: AimuxErrorCode.invalidToolInput); +} + +/// A `repairToolCall` hook itself failed. +class ToolCallRepairError extends AimuxException { + /// The original lookup/parse/validation error the hook was repairing, + /// decoded from its externally-tagged wire JSON (the same shape as + /// `ToolCall.error`). + final dynamic originalError; + + ToolCallRepairError(super.message, + {super.status, super.retryMs, super.retryable, this.originalError}) + : super(code: AimuxErrorCode.toolCallRepair); } /// Invalid argument (null args, invalid or expired handles, …). diff --git a/bindings/flutter/lib/types.dart b/bindings/flutter/lib/types.dart index 2fe7b7b3..a21ec5ff 100644 --- a/bindings/flutter/lib/types.dart +++ b/bindings/flutter/lib/types.dart @@ -161,6 +161,12 @@ class ToolCall { final bool? isDynamic; @JsonKey(name: 'thought_signature') final String? thoughtSignature; + @JsonKey(name: 'provider_metadata') + final Map? providerMetadata; + /// Set by Core when the tool call stays invalid after optional repair. + final bool? invalid; + /// The typed lookup, parse, schema, or repair failure for an invalid call. + final dynamic error; ToolCall({ required this.toolCallId, @@ -169,6 +175,9 @@ class ToolCall { this.providerExecuted, this.isDynamic, this.thoughtSignature, + this.providerMetadata, + this.invalid, + this.error, }); factory ToolCall.fromJson(Map json) => @@ -1251,6 +1260,10 @@ final class StreamPartToolCall extends StreamPart { final bool? isDynamic; final String? thoughtSignature; final Map? providerMetadata; + /// Set by Core when the tool call stays invalid after optional repair. + final bool? invalid; + /// The typed lookup, parse, schema, or repair failure for an invalid call. + final dynamic error; StreamPartToolCall({ required this.toolCallId, @@ -1260,6 +1273,8 @@ final class StreamPartToolCall extends StreamPart { this.isDynamic, this.thoughtSignature, this.providerMetadata, + this.invalid, + this.error, }); factory StreamPartToolCall.fromJson(Map json) => @@ -1272,6 +1287,8 @@ final class StreamPartToolCall extends StreamPart { thoughtSignature: json['thought_signature'] as String?, providerMetadata: json['provider_metadata'] as Map?, + invalid: json['invalid'] as bool?, + error: json['error'], ); @override @@ -1284,6 +1301,8 @@ final class StreamPartToolCall extends StreamPart { if (isDynamic != null) 'dynamic': isDynamic, if (thoughtSignature != null) 'thought_signature': thoughtSignature, if (providerMetadata != null) 'provider_metadata': providerMetadata, + if (invalid != null) 'invalid': invalid, + if (error != null) 'error': error, }, }; } diff --git a/bindings/flutter/lib/types.g.dart b/bindings/flutter/lib/types.g.dart index 21013b26..b938b25f 100644 --- a/bindings/flutter/lib/types.g.dart +++ b/bindings/flutter/lib/types.g.dart @@ -58,6 +58,10 @@ ToolCall _$ToolCallFromJson(Map json) => ToolCall( providerExecuted: json['provider_executed'] as bool?, isDynamic: json['dynamic'] as bool?, thoughtSignature: json['thought_signature'] as String?, + providerMetadata: + json['provider_metadata'] as Map?, + invalid: json['invalid'] as bool?, + error: json['error'], ); Map _$ToolCallToJson(ToolCall instance) => { @@ -67,6 +71,9 @@ Map _$ToolCallToJson(ToolCall instance) => { 'provider_executed': instance.providerExecuted, 'dynamic': instance.isDynamic, 'thought_signature': instance.thoughtSignature, + 'provider_metadata': instance.providerMetadata, + 'invalid': instance.invalid, + 'error': instance.error, }; FunctionTool _$FunctionToolFromJson(Map json) => FunctionTool( diff --git a/bindings/flutter/test/errors_test.dart b/bindings/flutter/test/errors_test.dart index 3a2a4ba9..66a989d7 100644 --- a/bindings/flutter/test/errors_test.dart +++ b/bindings/flutter/test/errors_test.dart @@ -63,7 +63,6 @@ void main() { final cases = { AimuxErrorCode.jsonParse: JSONParseError, AimuxErrorCode.invalidResponseData: InvalidResponseDataError, - AimuxErrorCode.tool: ToolError, AimuxErrorCode.invalidArgument: InvalidArgumentError, AimuxErrorCode.invalidPrompt: InvalidPromptError, AimuxErrorCode.tokenExpired: TokenExpiredError, @@ -73,6 +72,9 @@ void main() { AimuxErrorCode.apiCall: APICallError, AimuxErrorCode.timeout: AimuxTimeoutError, AimuxErrorCode.aborted: RequestAbortedError, + AimuxErrorCode.noSuchTool: NoSuchToolError, + AimuxErrorCode.invalidToolInput: InvalidToolInputError, + AimuxErrorCode.toolCallRepair: ToolCallRepairError, AimuxErrorCode.other: OtherError, AimuxErrorCode.retry: RetryError, }; @@ -86,9 +88,10 @@ void main() { test('unknown code is rejected with StateError', () { // A code outside the published table is an ABI mismatch, not an error // kind. 1 is AIMUX_E_OTHER now because Other inherited the old UNKNOWN - // slot, so it resolves; 15 is the first unassigned value. + // slot, so it resolves; 4 is retired and 14 is reserved. expect(() => AimuxException.fromCode(999, 'future'), throwsStateError); - expect(() => AimuxException.fromCode(15, 'unused'), throwsStateError); + expect(() => AimuxException.fromCode(4, 'retired'), throwsStateError); + expect(() => AimuxException.fromCode(14, 'reserved'), throwsStateError); }); test('bare retry code synthesizes a single-attempt RetryError', () { @@ -180,6 +183,18 @@ void main() { expect(AimuxErrorCode.aborted, 13); expect(AimuxErrorCode.retry, 14); expect(AimuxErrorCode.name(AimuxErrorCode.retry), 'Retry'); + // The AIMUX_E_UNKNOWN catch-all is gone and Other took its slot; the + // engine codes are 1–13 (4 retired) plus 15–17 (14 reserved). + expect(AimuxErrorCode.other, 1); + expect(AimuxErrorCode.aborted, 13); + expect(AimuxErrorCode.noSuchTool, 15); + expect(AimuxErrorCode.name(AimuxErrorCode.noSuchTool), 'NoSuchTool'); + expect(AimuxErrorCode.invalidToolInput, 16); + expect(AimuxErrorCode.name(AimuxErrorCode.invalidToolInput), + 'InvalidToolInput'); + expect(AimuxErrorCode.toolCallRepair, 17); + expect(AimuxErrorCode.name(AimuxErrorCode.toolCallRepair), + 'ToolCallRepair'); }); }); diff --git a/bindings/flutter/test/typed_round_trip_test.dart b/bindings/flutter/test/typed_round_trip_test.dart index 6f62bbf4..c1ede0a3 100644 --- a/bindings/flutter/test/typed_round_trip_test.dart +++ b/bindings/flutter/test/typed_round_trip_test.dart @@ -33,6 +33,27 @@ Map deepFlatten(Map json) => jsonDecode(jsonEncode(json)) as Map; void main() { + test('ToolCall preserves provider_metadata', () { + final original = ToolCall( + toolCallId: 'call_1', + toolName: 'get_weather', + input: {'location': 'Tokyo'}, + providerMetadata: { + 'openai': {'item_id': 'item_1'}, + }, + ); + + final json = original.toJson(); + expect(json['provider_metadata'], { + 'openai': {'item_id': 'item_1'}, + }); + + final decoded = ToolCall.fromJson(json); + expect(decoded.providerMetadata, { + 'openai': {'item_id': 'item_1'}, + }); + }); + // ───────────────────────────────────────────────────────────────────────── // GenerateContent (6 variants + Unknown) // ───────────────────────────────────────────────────────────────────────── diff --git a/bindings/go/aimux.go b/bindings/go/aimux.go index 7d0d671b..9c40baa7 100644 --- a/bindings/go/aimux.go +++ b/bindings/go/aimux.go @@ -1151,6 +1151,7 @@ func expectFfiError(e *C.aimux_error_t) error { } // expectAimuxError decodes an [AiMuxError] call: nil → nil; 1..14 → +// expectAimuxError decodes an [AiMuxError] call: nil → nil; 1..13, 15..17 → // *Error; 200..206 → plain C ABI error. Any other code is an ABI contract // violation. func expectAimuxError(e *C.aimux_error_t) error { @@ -1226,6 +1227,20 @@ func aimuxErrorFromC(e *C.aimux_error_t) *Error { err.Errors = append(err.Errors, aimuxErrorFromC(child)) C.aimux_error_free(child) } + case CodeNoSuchTool: + err.ToolName = str(C.aimux_error_tool_name(e)) + // NULL (→ "") means the core did not report the list; the string + // itself is serde-serialized JSON, so Unmarshal cannot fail short + // of an ABI mismatch, and nil is the right fallback either way. + if tools := str(C.aimux_error_available_tools(e)); tools != "" { + _ = json.Unmarshal([]byte(tools), &err.AvailableTools) + } + case CodeInvalidToolInput: + err.ToolInput = str(C.aimux_error_tool_input(e)) + case CodeToolCallRepair: + if orig := str(C.aimux_error_original_error(e)); orig != "" { + err.OriginalError = json.RawMessage(orig) + } } // TokenExpired carries a 401 by contract even if C reports -1; every // other status is the observed one (ApiCall without a status = no HTTP diff --git a/bindings/go/error.go b/bindings/go/error.go index 43a7a858..8c374430 100644 --- a/bindings/go/error.go +++ b/bindings/go/error.go @@ -10,9 +10,9 @@ import ( ) // Code is the machine-readable Aimux error code. Values match -// aimux-ffi aimux_error_code_t (1..14 = core AiMuxError variants; -// 1 is the catch-all Other, 14 = Retry). -// A code outside that set is a header/library mismatch and expectAimuxError +// aimux-ffi aimux_error_code_t (1..3, 5..17; 4 is retired). Retry is 14; +// tool-call errors occupy 15..17. A code outside that set is a header/library +// mismatch and expectAimuxError // panics rather than inventing an "unknown" variant. Recording failures are // a different type: see RecordingError. // @@ -23,11 +23,11 @@ import ( type Code int const ( - CodeOK Code = 0 - CodeOther Code = 1 - CodeJSONParse Code = 2 - CodeInvalidResponseData Code = 3 - CodeTool Code = 4 + CodeOK Code = 0 + CodeOther Code = 1 + CodeJSONParse Code = 2 + CodeInvalidResponseData Code = 3 + // 4 is retired (the legacy Tool variant); 14 is reserved. CodeInvalidArgument Code = 5 CodeInvalidPrompt Code = 6 CodeTokenExpired Code = 7 @@ -38,6 +38,9 @@ const ( CodeTimeout Code = 12 CodeAborted Code = 13 CodeRetry Code = 14 + CodeNoSuchTool Code = 15 + CodeInvalidToolInput Code = 16 + CodeToolCallRepair Code = 17 ) // String returns the core error_type name (e.g. "ApiCall", "TokenExpired"). @@ -50,8 +53,6 @@ func (c Code) String() string { return "JsonParse" case CodeInvalidResponseData: return "InvalidResponseData" - case CodeTool: - return "Tool" case CodeInvalidArgument: return "InvalidArgument" case CodeInvalidPrompt: @@ -70,6 +71,12 @@ func (c Code) String() string { return "Timeout" case CodeAborted: return "Aborted" + case CodeNoSuchTool: + return "NoSuchTool" + case CodeInvalidToolInput: + return "InvalidToolInput" + case CodeToolCallRepair: + return "ToolCallRepair" case CodeOther: return "Other" case CodeRetry: @@ -79,10 +86,10 @@ func (c Code) String() string { } } -// codeFromC maps a C aimux_error_code_t (1..14); false for any other -// value. +// codeFromC maps a C aimux_error_code_t (1..3, 5..17); false for any other +// value, including retired code 4. func codeFromC(code int) (Code, bool) { - if code >= int(CodeOther) && code <= int(CodeRetry) { + if code >= int(CodeOther) && code <= int(CodeToolCallRepair) && code != 4 { return Code(code), true } return 0, false @@ -114,6 +121,10 @@ func codeFromC(code int) (Code, bool) { // - ProviderID: CodeNoSuchProvider payload // - Reason / Errors: CodeRetry payload — why retrying stopped, and the // per-attempt history +// - ToolName / AvailableTools: CodeNoSuchTool payload (ToolName is shared +// with CodeInvalidToolInput) +// - ToolInput: CodeInvalidToolInput payload +// - OriginalError: CodeToolCallRepair payload type Error struct { Code Code Message string @@ -149,6 +160,10 @@ type Error struct { // with the full per-code payload). CodeRetry only. Reason RetryErrorReason Errors []*Error + ToolName string // CodeNoSuchTool / CodeInvalidToolInput: tool name + AvailableTools []string + ToolInput string // CodeInvalidToolInput: raw argument text + OriginalError json.RawMessage // CodeToolCallRepair: unrepaired error } // RetryErrorReason explains why operation retry stopped. Values are the diff --git a/bindings/go/error_test.go b/bindings/go/error_test.go index ddda2f58..f562cf57 100644 --- a/bindings/go/error_test.go +++ b/bindings/go/error_test.go @@ -471,8 +471,8 @@ func TestEmbedRejectsRawPassThroughOpts(t *testing.T) { } func TestCodeFromCRejectsOutOfRange(t *testing.T) { - // 15 is the first unassigned value. - for _, bad := range []int{0, 15, 16, 999} { + // 4 is retired; 14 is Retry; 15..17 are tool-call errors. + for _, bad := range []int{0, 4, 18, 999} { if _, ok := codeFromC(bad); ok { t.Fatalf("%d is not an AiMuxError variant", bad) } @@ -514,4 +514,7 @@ func TestRetryErrorHistory(t *testing.T) { if e.Reason != "errorNotRetryable" || RetryMaxRetriesExceeded != "maxRetriesExceeded" { t.Fatalf("reason wire names changed: %q", e.Reason) } + if c, ok := codeFromC(17); !ok || c != CodeToolCallRepair { + t.Fatalf("17 → %v, %v", c, ok) + } } diff --git a/bindings/go/roundtrip_test.go b/bindings/go/roundtrip_test.go index d086e193..54843bb9 100644 --- a/bindings/go/roundtrip_test.go +++ b/bindings/go/roundtrip_test.go @@ -116,6 +116,7 @@ func TestToolCallRoundTrip(t *testing.T) { Input: json.RawMessage(`{"location":"Tokyo"}`), ProviderExecuted: &pe, Dynamic: &dyn, + ProviderMetadata: json.RawMessage(`{"openai":{"item_id":"item_1"}}`), } b, err := json.Marshal(original) if err != nil { @@ -123,7 +124,7 @@ func TestToolCallRoundTrip(t *testing.T) { } // Verify wire field names match Kotlin (snake_case). s := string(b) - for _, want := range []string{`"tool_call_id"`, `"tool_name"`, `"input"`, `"provider_executed"`, `"dynamic"`} { + for _, want := range []string{`"tool_call_id"`, `"tool_name"`, `"input"`, `"provider_executed"`, `"dynamic"`, `"provider_metadata"`} { if !contains(s, want) { t.Errorf("expected %s in wire JSON, got %s", want, s) } @@ -144,6 +145,9 @@ func TestToolCallRoundTrip(t *testing.T) { if decoded.Dynamic == nil || *decoded.Dynamic != false { t.Error("dynamic did not round-trip") } + if string(decoded.ProviderMetadata) != string(original.ProviderMetadata) { + t.Errorf("provider_metadata mismatch: got %s, want %s", decoded.ProviderMetadata, original.ProviderMetadata) + } } // ── ModelMessage round-trip ────────────────────────────────────────────────── diff --git a/bindings/go/types.go b/bindings/go/types.go index 3c4bd62a..ffb3aee6 100644 --- a/bindings/go/types.go +++ b/bindings/go/types.go @@ -109,6 +109,11 @@ type ToolCall struct { ProviderExecuted *bool `json:"provider_executed,omitempty"` Dynamic *bool `json:"dynamic,omitempty"` ThoughtSignature *string `json:"thought_signature,omitempty"` + ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + // Invalid is set by Core when the tool call stays invalid after optional repair. + Invalid *bool `json:"invalid,omitempty"` + // Error is the typed lookup, parse, schema, or repair failure for an invalid call. + Error json.RawMessage `json:"error,omitempty"` } // ContentPart is a single content part in the raw response. diff --git a/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java index db259467..27c2ce28 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java @@ -11,7 +11,7 @@ /** * AiMuxError hierarchy (OpenAI Java / Vercel AI SDK style). * - *

Raised when a fallible C ABI call returns an AiMuxError code (1–14). + *

Raised when a fallible C ABI call returns an AiMuxError code (1–13, 15–17). * Recording failures use the * independent {@link RecordingException} type; C ABI failures (bad raw * wire JSON, use-after-close, re-entrant call) surface as plain @@ -29,8 +29,7 @@ * } * } * - *

Every instance carries {@link #getCode()} (C {@code aimux_error_code_t} - * 1–14, where {@link #AIMUX_E_RETRY} = 14), + *

Every instance carries {@link #getCode()} (C {@code aimux_error_code_t} 1–13, 15–17), * {@link #getStatusCode()} (HTTP or {@code -1}), {@link #getRetryMs()} (hint * or {@code -1}; {@code 0} = retry now) and {@link #isRetryable()}. Message * text comes from the C layer. @@ -45,6 +44,8 @@ public class AimuxException extends RuntimeException { // ── aimux_error_code_t (aimux-error.h) ────────────────────────────────── // 14 variant codes (1–14; 1 is the catch-all OTHER, 14 = RETRY reclaiming // the slot the pre-unification Other vacated); every HTTP-shaped failure + // 15 variant codes (1–13 and 15–17; 1 is the catch-all OTHER; 4 is retired — + // the legacy Tool variant — and 14 is reserved); every HTTP-shaped failure // arrives as AIMUX_E_API_CALL. A code outside that set is a header/library // mismatch and fails with IllegalStateException, never an AimuxException. // Recording failures are a different type: see RecordingException. @@ -52,7 +53,6 @@ public class AimuxException extends RuntimeException { public static final int AIMUX_OK = 0; public static final int AIMUX_E_JSON_PARSE = 2; public static final int AIMUX_E_INVALID_RESPONSE_DATA = 3; - public static final int AIMUX_E_TOOL = 4; public static final int AIMUX_E_INVALID_ARGUMENT = 5; public static final int AIMUX_E_INVALID_PROMPT = 6; public static final int AIMUX_E_TOKEN_EXPIRED = 7; @@ -62,6 +62,9 @@ public class AimuxException extends RuntimeException { public static final int AIMUX_E_API_CALL = 11; public static final int AIMUX_E_TIMEOUT = 12; public static final int AIMUX_E_ABORTED = 13; + public static final int AIMUX_E_NO_SUCH_TOOL = 15; + public static final int AIMUX_E_INVALID_TOOL_INPUT = 16; + public static final int AIMUX_E_TOOL_CALL_REPAIR = 17; public static final int AIMUX_E_OTHER = 1; public static final int AIMUX_E_RETRY = 14; @@ -71,6 +74,7 @@ public class AimuxException extends RuntimeException { // Set once by the fromC construction path; false for local / synthesized // failures. Not a constructor param so the subclass constructors keep + // failures. Not a constructor param so the 15 subclass constructors keep // their public signatures. private boolean retryable; @@ -103,6 +107,7 @@ public AimuxException(String message, int code, int status, long retryMs, Throwa // ── Accessors ─────────────────────────────────────────────────────────── /** C {@code aimux_error_code_t} value (1–14). */ + /** C {@code aimux_error_code_t} value (1–13, 15–17). */ public int getCode() { return code; } @@ -140,6 +145,8 @@ public boolean isRetryable() { * ({@link AimuxResult#expectAimuxError}) frees the returned error afterwards * (retry attempt errors are new owned copies and are freed here). * A code outside 1–14 is a header/library mismatch → + * ({@link AimuxResult#expectAimuxError}) frees the returned error afterwards. + * A code outside 1–13 / 15–17 is a header/library mismatch → * {@link IllegalStateException}. */ static AimuxException fromC(Pointer error, String prefix) { @@ -179,6 +186,20 @@ static AimuxException fromC(Pointer error, String prefix) { ex = new NoSuchProviderError(msg, -1, -1L, AimuxResult.takeString(ffi.aimux_error_provider_id(error))); break; + case AIMUX_E_NO_SUCH_TOOL: + ex = new NoSuchToolError(msg, -1, -1L, + AimuxResult.takeString(ffi.aimux_error_tool_name(error)), + parseStringList(AimuxResult.takeString(ffi.aimux_error_available_tools(error)))); + break; + case AIMUX_E_INVALID_TOOL_INPUT: + ex = new InvalidToolInputError(msg, -1, -1L, + AimuxResult.takeString(ffi.aimux_error_tool_name(error)), + AimuxResult.takeString(ffi.aimux_error_tool_input(error))); + break; + case AIMUX_E_TOOL_CALL_REPAIR: + ex = new ToolCallRepairError(msg, -1, -1L, + parseWireJson(AimuxResult.takeString(ffi.aimux_error_original_error(error)))); + break; default: ex = createByCode(code, msg, -1, -1L); } @@ -186,6 +207,32 @@ static AimuxException fromC(Pointer error, String prefix) { return ex; } + // The FFI guarantees well-formed JSON in these payloads; a parse failure is + // a header/library mismatch, same as an unknown code. + + private static List parseStringList(String json) { + if (json == null) { + return null; + } + try { + return Types.AimuxJson.MAPPER.readValue(json, + new com.fasterxml.jackson.core.type.TypeReference>() {}); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new IllegalStateException("aimux ffi: invalid error payload JSON: " + e.getOriginalMessage(), e); + } + } + + private static JsonNode parseWireJson(String json) { + if (json == null) { + return null; + } + try { + return Types.AimuxJson.MAPPER.readTree(json); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new IllegalStateException("aimux ffi: invalid error payload JSON: " + e.getOriginalMessage(), e); + } + } + /** * Decode the per-attempt history of an {@link #AIMUX_E_RETRY} error. * Each attempt is a new owned {@code aimux_error_t *} (index 0 = oldest) @@ -257,8 +304,6 @@ private static AimuxException createByCode(int code, String message, int status, return new JSONParseError(message, status, retryMs); case AIMUX_E_INVALID_RESPONSE_DATA: return new InvalidResponseDataError(message, status, retryMs); - case AIMUX_E_TOOL: - return new ToolError(message, status, retryMs); case AIMUX_E_INVALID_ARGUMENT: return new InvalidArgumentError(message, status, retryMs); case AIMUX_E_INVALID_PROMPT: @@ -280,6 +325,12 @@ private static AimuxException createByCode(int code, String message, int status, return new TimeoutError(message, status, retryMs); case AIMUX_E_ABORTED: return new RequestAbortedError(message, status, retryMs); + case AIMUX_E_NO_SUCH_TOOL: + return new NoSuchToolError(message, status, retryMs); + case AIMUX_E_INVALID_TOOL_INPUT: + return new InvalidToolInputError(message, status, retryMs); + case AIMUX_E_TOOL_CALL_REPAIR: + return new ToolCallRepairError(message, status, retryMs); case AIMUX_E_OTHER: return new OtherError(message, status, retryMs); default: @@ -296,8 +347,6 @@ public static String codeName(int code) { return "JsonParse"; case AIMUX_E_INVALID_RESPONSE_DATA: return "InvalidResponseData"; - case AIMUX_E_TOOL: - return "Tool"; case AIMUX_E_INVALID_ARGUMENT: return "InvalidArgument"; case AIMUX_E_INVALID_PROMPT: @@ -316,6 +365,12 @@ public static String codeName(int code) { return "Timeout"; case AIMUX_E_ABORTED: return "Aborted"; + case AIMUX_E_NO_SUCH_TOOL: + return "NoSuchTool"; + case AIMUX_E_INVALID_TOOL_INPUT: + return "InvalidToolInput"; + case AIMUX_E_TOOL_CALL_REPAIR: + return "ToolCallRepair"; case AIMUX_E_OTHER: return "Other"; case AIMUX_E_RETRY: @@ -339,12 +394,6 @@ public InvalidResponseDataError(String message, int status, long retryMs) { } } - public static class ToolError extends AimuxException { - public ToolError(String message, int status, long retryMs) { - super(message, AIMUX_E_TOOL, status, retryMs); - } - } - public static class InvalidArgumentError extends AimuxException { public InvalidArgumentError(String message, int status, long retryMs) { super(message, AIMUX_E_INVALID_ARGUMENT, status, retryMs); @@ -539,6 +588,83 @@ public RequestAbortedError() { } } + /** The model called a tool that is not in the supplied tool set. */ + public static class NoSuchToolError extends AimuxException { + private final String toolName; + private final List availableTools; + + public NoSuchToolError(String message, int status, long retryMs) { + this(message, status, retryMs, null, null); + } + + public NoSuchToolError(String message, int status, long retryMs, + String toolName, List availableTools) { + super(message, AIMUX_E_NO_SUCH_TOOL, status, retryMs); + this.toolName = toolName; + this.availableTools = availableTools; + } + + /** The tool name the model called, or {@code null} for local failures. */ + public String getToolName() { + return toolName; + } + + /** The available tool names, or {@code null} when no tool set was supplied. */ + public List getAvailableTools() { + return availableTools; + } + } + + /** The model's tool arguments failed to parse or validate against the schema. */ + public static class InvalidToolInputError extends AimuxException { + private final String toolName; + private final String toolInput; + + public InvalidToolInputError(String message, int status, long retryMs) { + this(message, status, retryMs, null, null); + } + + public InvalidToolInputError(String message, int status, long retryMs, + String toolName, String toolInput) { + super(message, AIMUX_E_INVALID_TOOL_INPUT, status, retryMs); + this.toolName = toolName; + this.toolInput = toolInput; + } + + /** The tool name the model called, or {@code null} for local failures. */ + public String getToolName() { + return toolName; + } + + /** The raw argument text the model produced, or {@code null} for local failures. */ + public String getToolInput() { + return toolInput; + } + } + + /** Tool-call repair itself failed. */ + public static class ToolCallRepairError extends AimuxException { + private final JsonNode originalError; + + public ToolCallRepairError(String message, int status, long retryMs) { + this(message, status, retryMs, null); + } + + public ToolCallRepairError(String message, int status, long retryMs, JsonNode originalError) { + super(message, AIMUX_E_TOOL_CALL_REPAIR, status, retryMs); + this.originalError = originalError; + } + + /** + * The original lookup/parse/validation error as externally-tagged wire + * JSON (the same encoding as {@code ToolCall.error}), or {@code null} + * for local failures. + */ + public JsonNode getOriginalError() { + return originalError; + } + } + public static class OtherError extends AimuxException { public OtherError(String message, int status, long retryMs) { super(message, AIMUX_E_OTHER, status, retryMs); diff --git a/bindings/java/src/main/java/ai/arcships/aimux/AimuxFFI.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxFFI.java index 21c1a96a..27e214c0 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/AimuxFFI.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/AimuxFFI.java @@ -216,6 +216,18 @@ Pointer aimux_stream_text_as_openai(long handle, String promptJson, String optsJ /** AIMUX_E_NO_SUCH_PROVIDER: owned string or NULL. */ Pointer aimux_error_provider_id(Pointer err); + /** AIMUX_E_NO_SUCH_TOOL / AIMUX_E_INVALID_TOOL_INPUT: owned string or NULL. */ + Pointer aimux_error_tool_name(Pointer err); + + /** AIMUX_E_NO_SUCH_TOOL: owned JSON string array, or NULL when no tool set was supplied. */ + Pointer aimux_error_available_tools(Pointer err); + + /** AIMUX_E_INVALID_TOOL_INPUT: owned string or NULL. */ + Pointer aimux_error_tool_input(Pointer err); + + /** AIMUX_E_TOOL_CALL_REPAIR: owned externally-tagged wire JSON, or NULL. */ + Pointer aimux_error_original_error(Pointer err); + // ── Embedding ─────────────────────────────────────────────────────────── Pointer aimux_openai_embedding_new(String apiKey, String modelId, LongByReference outHandle); diff --git a/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java index f4eca39e..90bf8470 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java @@ -10,6 +10,7 @@ *

Every fallible C call returns an {@code aimux_error_t *} ({@code null} * = success, result in the out-parameter). Its code identifies an AiMuxError * (1–14), RecordingError (100–105), or a failure detected by the C ABI + * (1–13, 15–17), RecordingError (100–105), or a failure detected by the C ABI * (200–206). The last range collapses to {@link IllegalStateException} * ({@code "aimux ffi: "} + message); Java does not expose seven additional * exception types. Each helper frees the pointer exactly once. User-triggerable @@ -66,6 +67,8 @@ private static String prefix(String context) { /** * Decode an error from a call that may return {@code AiMuxError}: 1–14 → * {@link AimuxException}; 200–206 → {@link IllegalStateException}. + * Decode an error from a call that may return {@code AiMuxError}: 1–13 / + * 15–17 → {@link AimuxException}; 200–206 → {@link IllegalStateException}. * Frees {@code e}. */ static RuntimeException expectAimuxError(Pointer e, String context) { @@ -79,8 +82,10 @@ static RuntimeException expectAimuxError(Pointer e, String context) { if (isFfiCode(code)) { return ffiError(e, prefix); } - if ((code < AimuxException.AIMUX_E_OTHER || code > AimuxException.AIMUX_E_ABORTED) - && code != AimuxException.AIMUX_E_RETRY) { + // Code 4 is retired; Retry is 14 and tool errors are 15..17. + if (code < AimuxException.AIMUX_E_OTHER + || code > AimuxException.AIMUX_E_TOOL_CALL_REPAIR + || code == 4) { return codeMismatch(code, prefix, "AiMuxError"); } return AimuxException.fromC(e, prefix); diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Types.java b/bindings/java/src/main/java/ai/arcships/aimux/Types.java index 534171d6..024b67ff 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Types.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Types.java @@ -397,7 +397,8 @@ public int hashCode() { * A tool call requested by the model. * * Mirrors `ToolCall.ts`: `{ tool_call_id, tool_name, input: JsonValue, - * provider_executed?: bool | null, dynamic?: bool | null }`. + * provider_executed?: bool | null, dynamic?: bool | null, + * provider_metadata?: JsonValue | null }`. * * `input` is a {@link JsonNode} because it is usually an arbitrary JSON object * (the tool arguments) whose shape is tool-specific. @@ -408,16 +409,23 @@ public static class ToolCall { @JsonProperty("input") private JsonNode input = emptyObject(); @JsonProperty("provider_executed") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; + @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("invalid") private Boolean invalid; + @JsonProperty("error") private JsonNode error; @JsonCreator ToolCall() {} - private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean providerExecuted, Boolean dynamic) { + private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean providerExecuted, Boolean dynamic, + JsonNode providerMetadata, Boolean invalid, JsonNode error) { this.toolCallId = toolCallId; this.toolName = toolName; this.input = input; this.providerExecuted = providerExecuted; this.dynamic = dynamic; + this.providerMetadata = providerMetadata; + this.invalid = invalid; + this.error = error; } public String getToolCallId() { return toolCallId; } @@ -425,6 +433,11 @@ private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean pro public JsonNode getInput() { return input; } public Boolean getProviderExecuted() { return providerExecuted; } public Boolean getDynamic() { return dynamic; } + public JsonNode getProviderMetadata() { return providerMetadata; } + /** Set by Core when the tool call stays invalid after optional repair. */ + public Boolean getInvalid() { return invalid; } + /** The typed lookup, parse, schema, or repair failure for an invalid call. */ + public JsonNode getError() { return error; } public static Builder builder() { return new Builder(); } @@ -434,14 +447,23 @@ public static class Builder { private JsonNode input = emptyObject(); private Boolean providerExecuted; private Boolean dynamic; + private JsonNode providerMetadata; + private Boolean invalid; + private JsonNode error; public Builder toolCallId(String v) { this.toolCallId = v; return this; } public Builder toolName(String v) { this.toolName = v; return this; } public Builder input(JsonNode v) { this.input = v; return this; } public Builder providerExecuted(Boolean v) { this.providerExecuted = v; return this; } public Builder dynamic(Boolean v) { this.dynamic = v; return this; } + public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } + public Builder invalid(Boolean v) { this.invalid = v; return this; } + public Builder error(JsonNode v) { this.error = v; return this; } - public ToolCall build() { return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic); } + public ToolCall build() { + return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata, invalid, + error); + } } @Override @@ -453,12 +475,16 @@ public boolean equals(Object o) { && Objects.equals(toolName, that.toolName) && Objects.equals(input, that.input) && Objects.equals(providerExecuted, that.providerExecuted) - && Objects.equals(dynamic, that.dynamic); + && Objects.equals(dynamic, that.dynamic) + && Objects.equals(providerMetadata, that.providerMetadata) + && Objects.equals(invalid, that.invalid) + && Objects.equals(error, that.error); } @Override public int hashCode() { - return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic); + return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata, invalid, + error); } } @@ -3361,18 +3387,22 @@ public static class ToolCall extends StreamPart { @JsonProperty("provider_executed") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("invalid") private Boolean invalid; + @JsonProperty("error") private JsonNode error; @JsonCreator ToolCall() {} private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean providerExecuted, - Boolean dynamic, JsonNode providerMetadata) { + Boolean dynamic, JsonNode providerMetadata, Boolean invalid, JsonNode error) { this.toolCallId = toolCallId; this.toolName = toolName; this.input = input; this.providerExecuted = providerExecuted; this.dynamic = dynamic; this.providerMetadata = providerMetadata; + this.invalid = invalid; + this.error = error; } public String getToolCallId() { return toolCallId; } @@ -3381,6 +3411,10 @@ private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean pro public Boolean getProviderExecuted() { return providerExecuted; } public Boolean getDynamic() { return dynamic; } public JsonNode getProviderMetadata() { return providerMetadata; } + /** Set by Core when the tool call stays invalid after optional repair. */ + public Boolean getInvalid() { return invalid; } + /** The typed lookup, parse, schema, or repair failure for an invalid call. */ + public JsonNode getError() { return error; } public static Builder builder() { return new Builder(); } @@ -3391,6 +3425,8 @@ public static class Builder { private Boolean providerExecuted; private Boolean dynamic; private JsonNode providerMetadata; + private Boolean invalid; + private JsonNode error; public Builder toolCallId(String v) { this.toolCallId = v; return this; } public Builder toolName(String v) { this.toolName = v; return this; } @@ -3398,9 +3434,12 @@ public static class Builder { public Builder providerExecuted(Boolean v) { this.providerExecuted = v; return this; } public Builder dynamic(Boolean v) { this.dynamic = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } + public Builder invalid(Boolean v) { this.invalid = v; return this; } + public Builder error(JsonNode v) { this.error = v; return this; } public ToolCall build() { - return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata); + return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata, + invalid, error); } } @@ -3414,12 +3453,15 @@ public boolean equals(Object o) { && Objects.equals(input, that.input) && Objects.equals(providerExecuted, that.providerExecuted) && Objects.equals(dynamic, that.dynamic) - && Objects.equals(providerMetadata, that.providerMetadata); + && Objects.equals(providerMetadata, that.providerMetadata) + && Objects.equals(invalid, that.invalid) + && Objects.equals(error, that.error); } @Override public int hashCode() { - return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata); + return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata, + invalid, error); } } diff --git a/bindings/java/src/test/java/ai/arcships/aimux/AimuxExceptionTest.java b/bindings/java/src/test/java/ai/arcships/aimux/AimuxExceptionTest.java index 0cb38965..3822b530 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/AimuxExceptionTest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/AimuxExceptionTest.java @@ -198,8 +198,12 @@ void ofMapsEveryVariantToExpectedClass() { .isInstanceOf(AimuxException.JSONParseError.class); assertThat(AimuxException.of(AimuxException.AIMUX_E_INVALID_RESPONSE_DATA, "m")) .isInstanceOf(AimuxException.InvalidResponseDataError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_TOOL, "m")) - .isInstanceOf(AimuxException.ToolError.class); + assertThat(AimuxException.of(AimuxException.AIMUX_E_NO_SUCH_TOOL, "m")) + .isInstanceOf(AimuxException.NoSuchToolError.class); + assertThat(AimuxException.of(AimuxException.AIMUX_E_INVALID_TOOL_INPUT, "m")) + .isInstanceOf(AimuxException.InvalidToolInputError.class); + assertThat(AimuxException.of(AimuxException.AIMUX_E_TOOL_CALL_REPAIR, "m")) + .isInstanceOf(AimuxException.ToolCallRepairError.class); assertThat(AimuxException.of(AimuxException.AIMUX_E_INVALID_ARGUMENT, "m")) .isInstanceOf(AimuxException.InvalidArgumentError.class); assertThat(AimuxException.of(AimuxException.AIMUX_E_INVALID_PROMPT, "m")) @@ -226,8 +230,12 @@ void ofMapsEveryVariantToExpectedClass() { @Test void codesOutsideTheRustEnumAreRejected() { - // Out-of-range (15, the first unassigned value) is a header/library mismatch. - assertThatThrownBy(() -> AimuxException.of(15, "")) + // Tool errors occupy 15..17; retired code 4 and values beyond 17 are + // header/library mismatches. + assertThat(AimuxException.of(15, "")).isInstanceOf(AimuxException.NoSuchToolError.class); + assertThatThrownBy(() -> AimuxException.of(4, "")) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> AimuxException.of(18, "")) .isInstanceOf(IllegalStateException.class); assertThatThrownBy(() -> AimuxException.of(999, "m")) .isInstanceOf(IllegalStateException.class); diff --git a/bindings/java/src/test/java/ai/arcships/aimux/TypedModelTest.java b/bindings/java/src/test/java/ai/arcships/aimux/TypedModelTest.java index 50420db5..6ffbb0bc 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/TypedModelTest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/TypedModelTest.java @@ -153,6 +153,25 @@ private static Types.Tool weatherTool() { // ── Tests ─────────────────────────────────────────────────────────── + @Test + void topLevelToolCallProviderMetadataRoundTrips() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode metadata = mapper.createObjectNode(); + metadata.set("google", mapper.createObjectNode().put("cache_id", "cache-1")); + Types.ToolCall original = Types.ToolCall.builder() + .toolCallId("call_1") + .toolName("get_weather") + .providerMetadata(metadata) + .build(); + + String json = Types.AimuxJson.MAPPER.writeValueAsString(original); + Types.ToolCall decoded = Types.AimuxJson.MAPPER.readValue(json, Types.ToolCall.class); + + assertThat(json).contains("\"provider_metadata\""); + assertThat(decoded.getProviderMetadata()).isEqualTo(metadata); + assertThat(decoded).isEqualTo(original); + } + @Test void generateTextReturnsTypedGenerateTextResultWithTextAndRawContent() { server.setResponseBody(plainOpenAiResponse()); @@ -377,4 +396,3 @@ void generateTextInvalidPromptThrowsAimuxException() { // via a single-response mock; terminal stream failures throw AimuxException // from the C return/err path (same as raw Model.streamTextStream). } - diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt index 85d6a500..3ddc6fda 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt @@ -5,18 +5,19 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer /** * Machine-readable codes matching aimux-ffi `aimux_error_code_t` (aimux-error.h). - * 1..14 mirror the 14 core variants (1 is the catch-all `Other`, 14 is `Retry`). - * The per-status codes (Provider, Http, RateLimited, Auth, - * ModelNotFound) are gone, every HTTP-shaped failure arrives as - * [AIMUX_E_API_CALL]. + * 1..13 and 15..17 mirror the 15 core variants (1 is the catch-all `Other`; + * 4 is retired — the legacy `Tool` variant — and 14 is reserved). The + * per-status codes (Provider, Http, RateLimited, Auth, ModelNotFound) are + * gone, every HTTP-shaped failure arrives as [AIMUX_E_API_CALL]. */ const val AIMUX_OK: Int = 0 const val AIMUX_E_JSON_PARSE: Int = 2 const val AIMUX_E_INVALID_RESPONSE_DATA: Int = 3 -const val AIMUX_E_TOOL: Int = 4 const val AIMUX_E_INVALID_ARGUMENT: Int = 5 const val AIMUX_E_INVALID_PROMPT: Int = 6 const val AIMUX_E_TOKEN_EXPIRED: Int = 7 @@ -26,6 +27,9 @@ const val AIMUX_E_NO_SUCH_PROVIDER: Int = 10 const val AIMUX_E_API_CALL: Int = 11 const val AIMUX_E_TIMEOUT: Int = 12 const val AIMUX_E_ABORTED: Int = 13 +const val AIMUX_E_NO_SUCH_TOOL: Int = 15 +const val AIMUX_E_INVALID_TOOL_INPUT: Int = 16 +const val AIMUX_E_TOOL_CALL_REPAIR: Int = 17 const val AIMUX_E_OTHER: Int = 1 const val AIMUX_E_RETRY: Int = 14 @@ -52,6 +56,7 @@ const val AIMUX_E_RETRY: Int = 14 * ``` * * Transport: Rust → C `aimux_error_t *` with code 1..14 → [fromC]. + * Transport: Rust → C `aimux_error_t *` with code 1..13 / 15..17 → [fromC]. * Primary path is not a JSON * error envelope. */ @@ -83,6 +88,9 @@ sealed class AimuxException( * the returned error afterwards (retry attempt errors are new owned * copies and are freed here). Code [AIMUX_OK] or a code outside * 1..14 is a header/library mismatch and throws [IllegalStateException]. + * the returned error afterwards. Code [AIMUX_OK] or a code outside + * 1..13 / 15..17 is a header/library mismatch and throws + * [IllegalStateException]. */ @JvmStatic internal fun fromC(error: Pointer, prefix: String = ""): AimuxException { @@ -126,6 +134,28 @@ sealed class AimuxException( retryable = retryable, providerId = takeString(lib.aimux_error_provider_id(error)) ?: "", ) + AIMUX_E_NO_SUCH_TOOL -> createByCode( + code, + msg, + retryable = retryable, + toolName = takeString(lib.aimux_error_tool_name(error)) ?: "", + availableTools = takeString(lib.aimux_error_available_tools(error)) + ?.let { AimuxJson.decodeFromString(ListSerializer(String.serializer()), it) }, + ) + AIMUX_E_INVALID_TOOL_INPUT -> createByCode( + code, + msg, + retryable = retryable, + toolName = takeString(lib.aimux_error_tool_name(error)) ?: "", + toolInput = takeString(lib.aimux_error_tool_input(error)) ?: "", + ) + AIMUX_E_TOOL_CALL_REPAIR -> createByCode( + code, + msg, + retryable = retryable, + originalError = takeString(lib.aimux_error_original_error(error)) + ?.let(AimuxJson::parseToJsonElement), + ) else -> createByCode(code, msg, retryable = retryable) } } @@ -154,16 +184,13 @@ sealed class AimuxException( json?.let(Json::parseToJsonElement) } catch (_: Exception) { null - } - /** Response headers arrive as one JSON object string of string→string pairs. */ private fun headerMap(json: String?): Map? = (parseJson(json) as? JsonObject)?.mapValues { (_, value) -> (value as? JsonPrimitive)?.content ?: value.toString() - } - /** * Build the subclass for a core / C error code (1..14). + * Build the subclass for a core / C error code (1..13 / 15..17). * * Any other code — [AIMUX_OK] on a failure path or a code this binding * does not know — is a header/library mismatch and throws @@ -189,10 +216,13 @@ sealed class AimuxException( providerId: String = "", retryReason: RetryErrorReason = RetryErrorReason.MAX_RETRIES_EXCEEDED, retryErrors: List = emptyList(), + toolName: String = "", + availableTools: List? = null, + toolInput: String = "", + originalError: JsonElement? = null, ): AimuxException = when (code) { AIMUX_E_JSON_PARSE -> JSONParseError(message, status, retryMs, cause, retryable) AIMUX_E_INVALID_RESPONSE_DATA -> InvalidResponseDataError(message, status, retryMs, cause, retryable) - AIMUX_E_TOOL -> ToolError(message, status, retryMs, cause, retryable) AIMUX_E_INVALID_ARGUMENT -> InvalidArgumentError(message, status, retryMs, cause, retryable) AIMUX_E_INVALID_PROMPT -> InvalidPromptError(message, status, retryMs, cause, retryable) AIMUX_E_TOKEN_EXPIRED -> TokenExpiredError( @@ -219,6 +249,9 @@ sealed class AimuxException( ) AIMUX_E_TIMEOUT -> TimeoutError(message, status, retryMs, cause, retryable) AIMUX_E_ABORTED -> RequestAbortedError(message, status, retryMs, cause, retryable) + AIMUX_E_NO_SUCH_TOOL -> NoSuchToolError(message, status, retryMs, cause, retryable, toolName, availableTools) + AIMUX_E_INVALID_TOOL_INPUT -> InvalidToolInputError(message, status, retryMs, cause, retryable, toolName, toolInput) + AIMUX_E_TOOL_CALL_REPAIR -> ToolCallRepairError(message, status, retryMs, cause, retryable, originalError) AIMUX_E_OTHER -> OtherError(message, status, retryMs, cause, retryable) else -> throw IllegalStateException("Unknown aimux_error_code_t: $code") } @@ -229,7 +262,6 @@ sealed class AimuxException( AIMUX_OK -> "OK" AIMUX_E_JSON_PARSE -> "JsonParse" AIMUX_E_INVALID_RESPONSE_DATA -> "InvalidResponseData" - AIMUX_E_TOOL -> "Tool" AIMUX_E_INVALID_ARGUMENT -> "InvalidArgument" AIMUX_E_INVALID_PROMPT -> "InvalidPrompt" AIMUX_E_TOKEN_EXPIRED -> "TokenExpired" @@ -239,6 +271,9 @@ sealed class AimuxException( AIMUX_E_API_CALL -> "ApiCall" AIMUX_E_TIMEOUT -> "Timeout" AIMUX_E_ABORTED -> "Aborted" + AIMUX_E_NO_SUCH_TOOL -> "NoSuchTool" + AIMUX_E_INVALID_TOOL_INPUT -> "InvalidToolInput" + AIMUX_E_TOOL_CALL_REPAIR -> "ToolCallRepair" AIMUX_E_OTHER -> "Other" AIMUX_E_RETRY -> "Retry" else -> "Code($code)" @@ -262,14 +297,6 @@ class InvalidResponseDataError( retryable: Boolean = false, ) : AimuxException(message, AIMUX_E_INVALID_RESPONSE_DATA, status, retryMs, cause, retryable) -class ToolError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, -) : AimuxException(message, AIMUX_E_TOOL, status, retryMs, cause, retryable) - class InvalidArgumentError( message: String, status: Int = -1, @@ -408,6 +435,47 @@ class RequestAbortedError( retryable: Boolean = false, ) : AimuxException(message, AIMUX_E_ABORTED, status, retryMs, cause, retryable) +/** The model called a tool that is not in the supplied tool set. */ +class NoSuchToolError( + message: String, + status: Int = -1, + retryMs: Long = -1, + cause: Throwable? = null, + retryable: Boolean = false, + /** The tool name the model called ("" when synthesized locally). */ + val toolName: String = "", + /** The available tool names, or null when no tool set was supplied. */ + val availableTools: List? = null, +) : AimuxException(message, AIMUX_E_NO_SUCH_TOOL, status, retryMs, cause, retryable) + +/** The model's tool arguments failed to parse or validate against the schema. */ +class InvalidToolInputError( + message: String, + status: Int = -1, + retryMs: Long = -1, + cause: Throwable? = null, + retryable: Boolean = false, + /** The tool name the model called ("" when synthesized locally). */ + val toolName: String = "", + /** The raw argument text the model produced ("" when synthesized locally). */ + val toolInput: String = "", +) : AimuxException(message, AIMUX_E_INVALID_TOOL_INPUT, status, retryMs, cause, retryable) + +/** Tool-call repair itself failed. */ +class ToolCallRepairError( + message: String, + status: Int = -1, + retryMs: Long = -1, + cause: Throwable? = null, + retryable: Boolean = false, + /** + * The original lookup/parse/validation error as externally-tagged wire + * JSON (the same encoding as [ToolCall.error]); null when synthesized + * locally. + */ + val originalError: JsonElement? = null, +) : AimuxException(message, AIMUX_E_TOOL_CALL_REPAIR, status, retryMs, cause, retryable) + class OtherError( message: String, status: Int = -1, diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt index d5b19f79..4cd8a20e 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt @@ -9,6 +9,7 @@ * null = success, result written to the trailing out-parameter * ([LongByReference] for handles, [PointerByReference] for JSON strings); * non-null = failure. Its unified code identifies [AimuxException] (1..14), + * non-null = failure. Its unified code identifies [AimuxException] (1..13 / 15..17), * [RecordingException] (100..105), or a C ABI failure (200..206). The last * range maps to `IllegalStateException("aimux ffi: …")`. A decoder releases * the returned pointer with `aimux_error_free`. @@ -130,6 +131,10 @@ internal interface AimuxFFI : Library { fun aimux_error_model_id(error: Pointer?): Pointer? fun aimux_error_model_type(error: Pointer?): Pointer? fun aimux_error_provider_id(error: Pointer?): Pointer? + fun aimux_error_tool_name(error: Pointer?): Pointer? + fun aimux_error_available_tools(error: Pointer?): Pointer? + fun aimux_error_tool_input(error: Pointer?): Pointer? + fun aimux_error_original_error(error: Pointer?): Pointer? // ── Embedding ────────────────────────────────────────────────────────── fun aimux_openai_embedding_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? @@ -260,6 +265,7 @@ private fun ffiError(e: Pointer, prefix: String): IllegalStateException { /** * Decode an error from a call that may return `AiMuxError`: 1..14 → + * Decode an error from a call that may return `AiMuxError`: 1..13 / 15..17 → * [AimuxException]; 200..206 → [IllegalStateException]. Frees [e]. */ internal fun expectAimuxError(e: Pointer, context: String = ""): RuntimeException { @@ -267,7 +273,8 @@ internal fun expectAimuxError(e: Pointer, context: String = ""): RuntimeExceptio try { val code = FFI.lib.aimux_error_code(e) if (isFfiCode(code)) return ffiError(e, prefix) - check(code in AIMUX_E_OTHER..AIMUX_E_ABORTED || code == AIMUX_E_RETRY) { + // Code 4 is retired; Retry is 14 and tool errors are 15..17. + check(code in AIMUX_E_OTHER..AIMUX_E_TOOL_CALL_REPAIR && code != 4) { "${prefix}aimux ffi: expected AiMuxError code, got $code" } return AimuxException.fromC(e, prefix) diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt index 8cdeb6bc..56700f0f 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt @@ -9,6 +9,7 @@ * * Every fallible C call returns an `aimux_error_t *` (null = success) that * [expectAimuxError] decodes codes 1..14 as [AimuxException] and 200..206 as + * [expectAimuxError] decodes codes 1..13 / 15..17 as [AimuxException] and 200..206 as * [IllegalStateException] `"aimux ffi: …"` (malformed raw JSON is * caught before the C call by [requireJson] as [IllegalArgumentException]). * No JSON envelope on the primary path. diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt index 15980811..fc107968 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt @@ -149,10 +149,14 @@ data class ResponseMetadata( * A tool call requested by the model. * * Mirrors `ToolCall.ts`: `{ tool_call_id, tool_name, input: JsonValue, - * provider_executed?: bool | null, dynamic?: bool | null }`. + * provider_executed?: bool | null, dynamic?: bool | null, + * provider_metadata?: JsonValue | null }`. * * `input` is a [JsonElement] because it is usually an arbitrary JSON object * (the tool arguments) whose shape is tool-specific. + * + * `invalid` is set by Core when the tool call stays invalid after optional + * repair; `error` is the typed lookup, parse, schema, or repair failure. */ @Serializable data class ToolCall( @@ -162,6 +166,9 @@ data class ToolCall( @SerialName("provider_executed") val providerExecuted: Boolean? = null, @SerialName("dynamic") val dynamic: Boolean? = null, @SerialName("thought_signature") val thoughtSignature: String? = null, + @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val invalid: Boolean? = null, + val error: JsonElement? = null, ) // ───────────────────────────────────────────────────────────────────────────── @@ -1053,6 +1060,10 @@ sealed interface StreamPart { @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, ) : StreamPart + /** + * `invalid` is set by Core when the tool call stays invalid after optional + * repair; `error` is the typed lookup, parse, schema, or repair failure. + */ @Serializable data class ToolCall( @SerialName("tool_call_id") val toolCallId: String = "", @@ -1062,6 +1073,8 @@ sealed interface StreamPart { @SerialName("dynamic") val dynamic: Boolean? = null, @SerialName("thought_signature") val thoughtSignature: String? = null, @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val invalid: Boolean? = null, + val error: JsonElement? = null, ) : StreamPart @Serializable diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt index 04af9b5a..85f25d2b 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt @@ -216,6 +216,7 @@ class ErrorsTest { } /** A code outside 1..14 is a header/library mismatch, not an error type. */ + /** A code outside the enum is a header/library mismatch, not an error type. */ @Test fun `createByCode rejects codes outside the enum`() { assertThatThrownBy { AimuxException.createByCode(999, "?") } diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt index 890dcf27..ed59e794 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt @@ -256,6 +256,25 @@ class TypedModelTest { // ───────────────────────────────────────────────────────────────────────────── class TypedModelRoundTripTest { + @Test + fun `top-level ToolCall provider metadata round-trips`() { + val metadata = JsonObject( + mapOf("google" to JsonObject(mapOf("cache_id" to JsonPrimitive("cache-1")))) + ) + val original = ToolCall( + toolCallId = "call_1", + toolName = "get_weather", + providerMetadata = metadata, + ) + + val json = AimuxJson.encodeToString(ToolCall.serializer(), original) + val decoded = AimuxJson.decodeFromString(ToolCall.serializer(), json) + + assertThat(json).contains("\"provider_metadata\"") + assertThat(decoded.providerMetadata).isEqualTo(metadata) + assertThat(decoded).isEqualTo(original) + } + // ── GenerateContent (externally tagged) ────────────────────────────── @Test diff --git a/bindings/node/__test__/e2e.test.ts b/bindings/node/__test__/e2e.test.ts index 6b2b975c..ad174d0e 100644 --- a/bindings/node/__test__/e2e.test.ts +++ b/bindings/node/__test__/e2e.test.ts @@ -239,7 +239,9 @@ test('e2e: OpenAI generateText parses tool_calls (structured content)', async (t t.truthy(tc, 'raw.content contains a ToolCall variant') t.is(tc.ToolCall.tool_name, 'get_weather') t.is(tc.ToolCall.tool_call_id, 'call_abc') - t.deepEqual(tc.ToolCall.input, { location: 'Tokyo' }) + // raw content keeps the provider's argument text; the parsed object + // lives on the top-level toolCalls. + t.is(tc.ToolCall.input, '{"location":"Tokyo"}') } finally { await closeServer(server) } diff --git a/bindings/node/__test__/wrapper.test.ts b/bindings/node/__test__/wrapper.test.ts index b9d5969d..170ba090 100644 --- a/bindings/node/__test__/wrapper.test.ts +++ b/bindings/node/__test__/wrapper.test.ts @@ -189,7 +189,8 @@ test('wrapper: generateText parses tool_calls + raw.content ToolCall', async (t) if (tc && 'ToolCall' in tc) { t.is(tc.ToolCall.tool_name, 'get_weather') t.is(tc.ToolCall.tool_call_id, 'call_abc') - t.deepEqual(tc.ToolCall.input, { location: 'Tokyo' }) + // raw content keeps the provider's argument text (see e2e.test.ts). + t.is(tc.ToolCall.input, '{"location":"Tokyo"}') } } finally { await closeServer(server) diff --git a/bindings/node/src/error.rs b/bindings/node/src/error.rs index 0b380639..0d6f5a56 100644 --- a/bindings/node/src/error.rs +++ b/bindings/node/src/error.rs @@ -19,7 +19,9 @@ const ERROR_CLASS_NAMES: &[&str] = &[ "RetryError", "JSONParseError", "InvalidResponseDataError", - "ToolError", + "NoSuchToolError", + "InvalidToolInputError", + "ToolCallRepairError", "InvalidArgumentError", "InvalidPromptError", "TokenExpiredError", @@ -176,7 +178,9 @@ fn aimux_error_class_name(error: &AiMuxError) -> &'static str { AiMuxError::Retry(_) => "RetryError", AiMuxError::JsonParse(_) => "JSONParseError", AiMuxError::InvalidResponseData(_) => "InvalidResponseDataError", - AiMuxError::Tool(_) => "ToolError", + AiMuxError::NoSuchTool { .. } => "NoSuchToolError", + AiMuxError::InvalidToolInput { .. } => "InvalidToolInputError", + AiMuxError::ToolCallRepair { .. } => "ToolCallRepairError", AiMuxError::InvalidArgument(_) => "InvalidArgumentError", AiMuxError::InvalidPrompt(_) => "InvalidPromptError", AiMuxError::TokenExpired(_) => "TokenExpiredError", @@ -225,12 +229,12 @@ fn new_registered_error<'env>( /// Build the exact JS subclass registered for this core variant. fn create_aimux_throwable(env: &Env, error: &AiMuxError) -> NapiResult { - Ok(Error::from(create_aimux_error_object(env, error)?.to_unknown())) + Ok(Error::from(aimux_error_object(env, error)?.to_unknown())) } -/// Instantiate the registered subclass and fill its variant-owned fields. -/// Recursive: a `Retry` error carries its attempt history as full instances. -fn create_aimux_error_object<'env>(env: &'env Env, error: &AiMuxError) -> NapiResult> { +/// The error instance as an `Object`, so `ToolCallRepair` can nest its +/// `originalError` as a real registered subclass instance. +fn aimux_error_object<'env>(env: &'env Env, error: &AiMuxError) -> NapiResult> { let message = error.to_string(); let mut obj = new_registered_error(env, aimux_error_class_name(error), &message)?; if let Some(status) = error.status_code() { @@ -272,7 +276,7 @@ fn create_aimux_error_object<'env>(env: &'env Env, error: &AiMuxError) -> NapiRe obj.set("reason", reason)?; let mut attempts = Vec::with_capacity(retry.errors.len()); for attempt in &retry.errors { - attempts.push(create_aimux_error_object(env, attempt)?); + attempts.push(aimux_error_object(env, attempt)?); } obj.set("errors", attempts)?; } @@ -288,6 +292,26 @@ fn create_aimux_error_object<'env>(env: &'env Env, error: &AiMuxError) -> NapiRe AiMuxError::NoSuchProvider { provider_id } => { obj.set("providerId", provider_id.as_str())?; } + AiMuxError::NoSuchTool { + tool_name, + available_tools, + } => { + obj.set("toolName", tool_name.as_str())?; + if let Some(tools) = available_tools { + obj.set("availableTools", tools.clone())?; + } + } + AiMuxError::InvalidToolInput { + tool_name, + tool_input, + .. + } => { + obj.set("toolName", tool_name.as_str())?; + obj.set("toolInput", tool_input.as_str())?; + } + AiMuxError::ToolCallRepair { original_error, .. } => { + obj.set("originalError", &aimux_error_object(env, original_error)?)?; + } _ => {} } Ok(obj) diff --git a/bindings/node/src/error.ts b/bindings/node/src/error.ts index 936452a1..c9f1f8fb 100644 --- a/bindings/node/src/error.ts +++ b/bindings/node/src/error.ts @@ -78,7 +78,25 @@ export class RetryError extends AimuxError { export class JSONParseError extends AimuxError {} export class InvalidResponseDataError extends AimuxError {} -export class ToolError extends AimuxError {} +/** The model called a tool that was not provided (AI SDK `NoSuchToolError`). */ +export class NoSuchToolError extends AimuxError { + /** The tool name the model tried to call. */ + declare readonly toolName: string + /** Tools that were available; absent if none were. */ + declare readonly availableTools?: string[] +} +/** A tool call's input failed to parse or violated the tool's schema (AI SDK `InvalidToolInputError`). */ +export class InvalidToolInputError extends AimuxError { + /** The tool whose input was invalid. */ + declare readonly toolName: string + /** The raw input text the model produced. */ + declare readonly toolInput: string +} +/** The repair callback failed while handling an invalid tool call (AI SDK `ToolCallRepairError`). */ +export class ToolCallRepairError extends AimuxError { + /** The failure that triggered repair (a {@link NoSuchToolError} or {@link InvalidToolInputError}). */ + declare readonly originalError: NoSuchToolError | InvalidToolInputError +} export class InvalidArgumentError extends AimuxError {} export class InvalidPromptError extends AimuxError {} export class TokenExpiredError extends AimuxError { diff --git a/bindings/node/src/index.ts b/bindings/node/src/index.ts index 34f1faf8..8a6cae5a 100644 --- a/bindings/node/src/index.ts +++ b/bindings/node/src/index.ts @@ -62,7 +62,9 @@ export { type RetryErrorReason, JSONParseError, InvalidResponseDataError, - ToolError, + NoSuchToolError, + InvalidToolInputError, + ToolCallRepairError, InvalidArgumentError, InvalidPromptError, TokenExpiredError, @@ -170,7 +172,10 @@ export type RawModel = Model * @param model - A raw model instance from `openai()`, `anthropic()`, etc. * @param prompt - A plain string or an array of typed chat messages. * @param options - Optional typed generation options (tools, tool_choice, - * temperature, response_format, …). + * temperature, response_format, …). The Rust `repair_tool_call` + * callback is core-only (it cannot cross the FFI boundary); + * invalid tool calls arrive with `invalid`/`error` set on the + * tool call. * @param signal - Optional `AbortSignal`; aborting it cancels the call. * * Internally calls the raw diff --git a/bindings/node/src/types/AiMuxError.ts b/bindings/node/src/types/AiMuxError.ts index 8bef0979..2a8eefbf 100644 --- a/bindings/node/src/types/AiMuxError.ts +++ b/bindings/node/src/types/AiMuxError.ts @@ -9,7 +9,7 @@ import type { RetryError } from "./RetryError"; * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. */ -export type AiMuxError = { "ApiCall": ApiCallError } | { "Retry": RetryError } | { "JsonParse": string } | { "InvalidResponseData": string } | { "Tool": string } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, +export type AiMuxError = { "ApiCall": ApiCallError } | { "Retry": RetryError } | { "JsonParse": string } | { "InvalidResponseData": string } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, /** * What kind of model was requested (`"languageModel"`, * `"imageModel"`, …), the AI SDK's `modelType`. diff --git a/bindings/node/src/types/GenerateContent.ts b/bindings/node/src/types/GenerateContent.ts index a64676c5..7de2d7f1 100644 --- a/bindings/node/src/types/GenerateContent.ts +++ b/bindings/node/src/types/GenerateContent.ts @@ -5,7 +5,12 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * A content item in the generation result. */ -export type GenerateContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": { tool_call_id: string, tool_name: string, input: JsonValue, +export type GenerateContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": { tool_call_id: string, tool_name: string, +/** + * Raw provider input. Providers put serialized argument text in a + * `Value::String`; `generate_text` parses and validates it. + */ +input: JsonValue, /** * Whether the tool call will be executed by the provider. * If false/unset, the tool call is executed by the client. diff --git a/bindings/node/src/types/StreamPart.ts b/bindings/node/src/types/StreamPart.ts index 8402cfa6..a04fe838 100644 --- a/bindings/node/src/types/StreamPart.ts +++ b/bindings/node/src/types/StreamPart.ts @@ -21,7 +21,12 @@ dynamic?: boolean | null, /** * Optional title for the tool call. */ -title?: string | null, provider_metadata?: JsonValue | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: JsonValue | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": { tool_call_id: string, tool_name: string, input: JsonValue, +title?: string | null, provider_metadata?: JsonValue | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: JsonValue | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": { tool_call_id: string, tool_name: string, +/** + * Serialized argument text in a `Value::String` from `do_stream`; + * parsed input after `stream_text`. + */ +input: JsonValue, /** * Whether the tool call will be executed by the provider. */ @@ -35,7 +40,15 @@ dynamic?: boolean | null, * `thoughtSignature`). Must be echoed back verbatim on the follow-up * turn when the tool result is sent. */ -thought_signature?: string | null, provider_metadata?: JsonValue | null, } } | { "ToolResult": { tool_call_id: string, tool_name: string, result: JsonValue, +thought_signature?: string | null, +/** + * Set by Core when the call remains invalid after optional repair. + */ +invalid?: boolean | null, +/** + * Typed lookup, parsing, schema, or repair failure. + */ +error?: AiMuxError | null, provider_metadata?: JsonValue | null, } } | { "ToolResult": { tool_call_id: string, tool_name: string, result: JsonValue, /** * Whether the result is an error or error message. */ diff --git a/bindings/node/src/types/ToolCall.ts b/bindings/node/src/types/ToolCall.ts index d16cbbc4..c6b72530 100644 --- a/bindings/node/src/types/ToolCall.ts +++ b/bindings/node/src/types/ToolCall.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AiMuxError } from "./AiMuxError"; import type { JsonValue } from "./serde_json/JsonValue"; /** @@ -14,7 +15,8 @@ tool_call_id: string, */ tool_name: string, /** - * Arguments as a JSON value (usually an object). + * Parsed arguments, or the original string when `invalid` is true and the + * provider input was not valid JSON. */ input: JsonValue, /** @@ -31,4 +33,17 @@ dynamic?: boolean | null, * when the tool result is sent; thinking models reject the request * otherwise. */ -thought_signature?: string | null, }; +thought_signature?: string | null, +/** + * Additional provider-specific metadata associated with this call. + */ +provider_metadata?: JsonValue | null, +/** + * Set when lookup, JSON parsing, or schema validation still failed after + * the optional repair attempt. + */ +invalid?: boolean | null, +/** + * Typed failure associated with an invalid tool call. + */ +error?: AiMuxError | null, }; diff --git a/bindings/python/python/aimux/__init__.py b/bindings/python/python/aimux/__init__.py index a0a676c8..b857d54b 100644 --- a/bindings/python/python/aimux/__init__.py +++ b/bindings/python/python/aimux/__init__.py @@ -13,7 +13,9 @@ RetryError, JSONParseError, InvalidResponseDataError, - ToolError, + NoSuchToolError, + InvalidToolInputError, + ToolCallRepairError, InvalidArgumentError, InvalidPromptError, TokenExpiredError, @@ -84,7 +86,9 @@ "RetryError", "JSONParseError", "InvalidResponseDataError", - "ToolError", + "NoSuchToolError", + "InvalidToolInputError", + "ToolCallRepairError", "InvalidArgumentError", "InvalidPromptError", "TokenExpiredError", diff --git a/bindings/python/python/aimux/wrapper.py b/bindings/python/python/aimux/wrapper.py index 6fbb09a1..6357d8a5 100644 --- a/bindings/python/python/aimux/wrapper.py +++ b/bindings/python/python/aimux/wrapper.py @@ -151,6 +151,9 @@ class ToolCall(BaseModel): provider_executed: Optional[bool] = None dynamic: Optional[bool] = None thought_signature: Optional[str] = None + provider_metadata: Optional[Any] = None + invalid: Optional[bool] = None + error: Optional[AiMuxErrorValue] = None # ───────────────────────────────────────────────────────────────────────────── @@ -489,6 +492,8 @@ class _SPToolCall(BaseModel): dynamic: Optional[bool] = None thought_signature: Optional[str] = None provider_metadata: Optional[Dict[str, Any]] = None + invalid: Optional[bool] = None + error: Optional[AiMuxErrorValue] = None class _SPToolResult(BaseModel): @@ -827,6 +832,10 @@ class GenerateTextOptions(BaseModel): Mirrors Rust ``GenerateTextOptions``. All fields are optional; unset fields default to ``None`` (Rust treats absent / ``null`` as ``None``). + + The Rust ``repair_tool_call`` callback is core-only (it cannot cross the + FFI boundary); invalid tool calls arrive with ``invalid``/``error`` set on + the tool call. """ max_output_tokens: Optional[int] = None diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs index 96768bc5..ea25e8cd 100644 --- a/bindings/python/src/error.rs +++ b/bindings/python/src/error.rs @@ -43,7 +43,24 @@ create_exception!( AimuxError, "Invalid response data" ); -create_exception!(aimux, ToolError, AimuxError, "Tool-related failure"); +create_exception!( + aimux, + NoSuchToolError, + AimuxError, + "Model called a tool that was not provided" +); +create_exception!( + aimux, + InvalidToolInputError, + AimuxError, + "Tool call input failed to parse or violated the tool's schema" +); +create_exception!( + aimux, + ToolCallRepairError, + AimuxError, + "Repair callback failed while handling an invalid tool call" +); create_exception!(aimux, InvalidArgumentError, AimuxError, "Invalid argument"); create_exception!(aimux, InvalidPromptError, AimuxError, "Invalid prompt"); create_exception!(aimux, TokenExpiredError, AimuxError, "Access token expired"); @@ -210,22 +227,23 @@ fn recording_py_err(e: &CoreRecordingError) -> PyErr { } pub(crate) fn to_py_err(e: &AiMuxError) -> PyErr { - Python::with_gil(|py| match exception_instance(py, e) { + Python::with_gil(|py| match variant_instance(py, e) { Ok(instance) => PyErr::from_value_bound(instance), Err(e) => e, }) } -fn exception_instance<'py>( - py: Python<'py>, - e: &AiMuxError, -) -> PyResult> { +/// The exception instance for a variant, so `ToolCallRepair` can nest its +/// `original_error` as a real exception instance. +fn variant_instance<'py>(py: Python<'py>, e: &AiMuxError) -> PyResult> { let typ = match e { AiMuxError::ApiCall(_) => py.get_type_bound::(), AiMuxError::Retry(_) => py.get_type_bound::(), AiMuxError::JsonParse(_) => py.get_type_bound::(), AiMuxError::InvalidResponseData(_) => py.get_type_bound::(), - AiMuxError::Tool(_) => py.get_type_bound::(), + AiMuxError::NoSuchTool { .. } => py.get_type_bound::(), + AiMuxError::InvalidToolInput { .. } => py.get_type_bound::(), + AiMuxError::ToolCallRepair { .. } => py.get_type_bound::(), AiMuxError::InvalidArgument(_) => py.get_type_bound::(), AiMuxError::InvalidPrompt(_) => py.get_type_bound::(), AiMuxError::TokenExpired(_) => py.get_type_bound::(), @@ -276,7 +294,7 @@ fn exception_instance<'py>( // exception instance (recursing through this same projection). let errors = PyList::empty_bound(py); for error in &retry.errors { - errors.append(exception_instance(py, error)?)?; + errors.append(variant_instance(py, error)?)?; } inst.setattr( "reason", @@ -309,6 +327,24 @@ fn exception_instance<'py>( AiMuxError::NoSuchProvider { provider_id } => { inst.setattr("provider_id", provider_id.as_str())?; } + AiMuxError::NoSuchTool { + tool_name, + available_tools, + } => { + inst.setattr("tool_name", tool_name.as_str())?; + inst.setattr("available_tools", available_tools.clone())?; + } + AiMuxError::InvalidToolInput { + tool_name, + tool_input, + .. + } => { + inst.setattr("tool_name", tool_name.as_str())?; + inst.setattr("tool_input", tool_input.as_str())?; + } + AiMuxError::ToolCallRepair { original_error, .. } => { + inst.setattr("original_error", variant_instance(py, original_error)?)?; + } _ => {} } Ok(inst) @@ -335,7 +371,15 @@ pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { "InvalidResponseDataError", py.get_type_bound::(), )?; - m.add("ToolError", py.get_type_bound::())?; + m.add("NoSuchToolError", py.get_type_bound::())?; + m.add( + "InvalidToolInputError", + py.get_type_bound::(), + )?; + m.add( + "ToolCallRepairError", + py.get_type_bound::(), + )?; m.add( "InvalidArgumentError", py.get_type_bound::(), diff --git a/bindings/python/tests/test_e2e.py b/bindings/python/tests/test_e2e.py index 9306a859..6a615786 100644 --- a/bindings/python/tests/test_e2e.py +++ b/bindings/python/tests/test_e2e.py @@ -372,7 +372,9 @@ def test_generate_text_parses_tool_calls(self): assert tc is not None, "raw.content must contain a ToolCall variant" assert tc["ToolCall"]["tool_name"] == "get_weather" assert tc["ToolCall"]["tool_call_id"] == "call_abc" - assert tc["ToolCall"]["input"] == {"location": "Tokyo"} + # raw content keeps the provider's argument text; parsing happens + # at the Core boundary (top-level tool_calls carry the object). + assert tc["ToolCall"]["input"] == '{"location":"Tokyo"}' def test_multi_role_messages_reach_provider(self): with RecordingMockServer(OPENAI_CHAT) as mock: diff --git a/bindings/python/tests/test_wrapper.py b/bindings/python/tests/test_wrapper.py index e3cf7559..19caf561 100644 --- a/bindings/python/tests/test_wrapper.py +++ b/bindings/python/tests/test_wrapper.py @@ -85,7 +85,8 @@ def test_parses_tool_calls(self): assert tool_call_parts, "raw.content must contain a ToolCall variant" assert tool_call_parts[0].root.tool_name == "get_weather" assert tool_call_parts[0].root.tool_call_id == "call_abc" - assert tool_call_parts[0].root.input == {"location": "Tokyo"} + # raw content keeps the provider's argument text (see test_e2e). + assert tool_call_parts[0].root.input == '{"location":"Tokyo"}' def test_raw_content_is_text_variant(self): with MockServer(OPENAI_CHAT) as mock: diff --git a/bindings/swift/Sources/Aimux/Aimux.swift b/bindings/swift/Sources/Aimux/Aimux.swift index 7f8d75e6..c9ae6fdd 100644 --- a/bindings/swift/Sources/Aimux/Aimux.swift +++ b/bindings/swift/Sources/Aimux/Aimux.swift @@ -12,6 +12,7 @@ import Foundation // Every fallible C function returns `aimux_error_t *` (`OpaquePointer?`): // NULL = success (result in the trailing out-param), non-NULL = failure. The // unified code is AiMuxError (1...14), RecordingError (100...105), or a C ABI +// unified code is AiMuxError (1...13, 15...17), RecordingError (100...105), or a C ABI // failure (200...206). The three `expect*` decoders copy the relevant fields, release // it with `aimux_error_free` (exactly once) and return the Swift error // to throw. Errors are not handles: never `aimux_drop_handle` one. @@ -45,6 +46,7 @@ func expectFfiError(_ e: OpaquePointer, context: String) -> any Error { } /// Decode a returned error from an `[AiMuxError]` call: 1...14 become +/// Decode a returned error from an `[AiMuxError]` call: 1...13 / 15...17 becomes /// `AimuxError`; 200...206 is decoded by `expectFfiError`. Frees `e` once. func expectAimuxError(_ e: OpaquePointer, context: String) -> any Error { let code = aimux_error_code(e) @@ -120,6 +122,7 @@ public enum RetryErrorReason: String, Equatable, Sendable { /// Structured aimux failure type (Swift `Error`). /// /// Maps 1:1 from the 14 core `AiMuxError` variants. Every HTTP-shaped failure +/// Maps 1:1 from the 15 core `AiMuxError` variants. Every HTTP-shaped failure /// is `.apiCall` (`AIMUX_E_API_CALL`). Only aimux-core produces these: a /// binding-local failure (raw JSON that does not parse, a typed value that /// fails to encode, library output that fails to decode) surfaces as the @@ -143,7 +146,6 @@ public enum RetryErrorReason: String, Equatable, Sendable { public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatable, Sendable { case jsonParse(message: String, status: Int, retryMs: Int64, retryable: Bool) case invalidResponseData(message: String, status: Int, retryMs: Int64, retryable: Bool) - case tool(message: String, status: Int, retryMs: Int64, retryable: Bool) case invalidArgument(message: String, status: Int, retryMs: Int64, retryable: Bool) case invalidPrompt(message: String, status: Int, retryMs: Int64, retryable: Bool) case tokenExpired(message: String, status: Int, retryMs: Int64, retryable: Bool) @@ -171,6 +173,14 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl /// `message` is the abort payload verbatim ("request aborted" for signal /// aborts). case aborted(message: String, status: Int, retryMs: Int64, retryable: Bool) + /// The model called a tool that is not in the supplied tool set. + case noSuchTool(message: String, status: Int, retryMs: Int64, retryable: Bool, toolName: String, availableTools: [String]?) + /// The model produced tool arguments that fail to parse or validate. + case invalidToolInput(message: String, status: Int, retryMs: Int64, retryable: Bool, toolName: String, toolInput: String) + /// A `repairToolCall` hook itself failed; `originalError` is the error it + /// was repairing, as externally-tagged wire JSON (the same encoding as + /// `ToolCall.error`). + case toolCallRepair(message: String, status: Int, retryMs: Int64, retryable: Bool, originalError: String) case other(message: String, status: Int, retryMs: Int64, retryable: Bool) // MARK: Accessors @@ -180,7 +190,6 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl switch self { case .jsonParse(let m, let s, let r, let t), .invalidResponseData(let m, let s, let r, let t), - .tool(let m, let s, let r, let t), .invalidArgument(let m, let s, let r, let t), .invalidPrompt(let m, let s, let r, let t), .tokenExpired(let m, let s, let r, let t), @@ -190,6 +199,9 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl .apiCall(let m, let s, let r, let t, _, _, _, _, _, _, _), .timeout(let m, let s, let r, let t), .aborted(let m, let s, let r, let t), + .noSuchTool(let m, let s, let r, let t, _, _), + .invalidToolInput(let m, let s, let r, let t, _, _), + .toolCallRepair(let m, let s, let r, let t, _), .other(let m, let s, let r, let t): return (m, s, r, t) case .retry(let m, _, _): @@ -205,7 +217,6 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl switch self { case .jsonParse: c = AIMUX_E_JSON_PARSE case .invalidResponseData: c = AIMUX_E_INVALID_RESPONSE_DATA - case .tool: c = AIMUX_E_TOOL case .invalidArgument: c = AIMUX_E_INVALID_ARGUMENT case .invalidPrompt: c = AIMUX_E_INVALID_PROMPT case .tokenExpired: c = AIMUX_E_TOKEN_EXPIRED @@ -216,6 +227,9 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl case .retry: c = AIMUX_E_RETRY case .timeout: c = AIMUX_E_TIMEOUT case .aborted: c = AIMUX_E_ABORTED + case .noSuchTool: c = AIMUX_E_NO_SUCH_TOOL + case .invalidToolInput: c = AIMUX_E_INVALID_TOOL_INPUT + case .toolCallRepair: c = AIMUX_E_TOOL_CALL_REPAIR case .other: c = AIMUX_E_OTHER } return Int32(bitPattern: c.rawValue) @@ -318,6 +332,36 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl return nil } + /// `.noSuchTool` / `.invalidToolInput` only: the tool name the model called. + public var toolName: String? { + switch self { + case .noSuchTool(_, _, _, _, let v, _), .invalidToolInput(_, _, _, _, let v, _): + return v + default: + return nil + } + } + + /// `.noSuchTool` only: the available tool names, or `nil` when no tool set + /// was supplied. + public var availableTools: [String]? { + if case .noSuchTool(_, _, _, _, _, let v) = self { return v } + return nil + } + + /// `.invalidToolInput` only: the raw argument text the model produced. + public var toolInput: String? { + if case .invalidToolInput(_, _, _, _, _, let v) = self { return v } + return nil + } + + /// `.toolCallRepair` only: the original lookup/parse/validation error as + /// externally-tagged wire JSON (the same encoding as `ToolCall.error`). + public var originalError: String? { + if case .toolCallRepair(_, _, _, _, let v) = self { return v } + return nil + } + public var description: String { message } public var errorDescription: String? { @@ -348,8 +392,6 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl return .jsonParse(message: message, status: status, retryMs: retryMs, retryable: retryable) case AIMUX_E_INVALID_RESPONSE_DATA: return .invalidResponseData(message: message, status: status, retryMs: retryMs, retryable: retryable) - case AIMUX_E_TOOL: - return .tool(message: message, status: status, retryMs: retryMs, retryable: retryable) case AIMUX_E_INVALID_ARGUMENT: return .invalidArgument(message: message, status: status, retryMs: retryMs, retryable: retryable) case AIMUX_E_INVALID_PROMPT: @@ -392,6 +434,21 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl return .timeout(message: message, status: status, retryMs: retryMs, retryable: retryable) case AIMUX_E_ABORTED: return .aborted(message: message, status: status, retryMs: retryMs, retryable: retryable) + case AIMUX_E_NO_SUCH_TOOL: + // The accessor's JSON string array (or NULL) → [String]?; a decode + // failure would be an FFI contract break, treated as "absent". + let tools = takeCString(aimux_error_available_tools(h)) + .flatMap { try? JSONDecoder().decode([String].self, from: Data($0.utf8)) } + return .noSuchTool(message: message, status: status, retryMs: retryMs, retryable: retryable, + toolName: takeCString(aimux_error_tool_name(h)) ?? "", + availableTools: tools) + case AIMUX_E_INVALID_TOOL_INPUT: + return .invalidToolInput(message: message, status: status, retryMs: retryMs, retryable: retryable, + toolName: takeCString(aimux_error_tool_name(h)) ?? "", + toolInput: takeCString(aimux_error_tool_input(h)) ?? "") + case AIMUX_E_TOOL_CALL_REPAIR: + return .toolCallRepair(message: message, status: status, retryMs: retryMs, retryable: retryable, + originalError: takeCString(aimux_error_original_error(h)) ?? "") case AIMUX_E_OTHER: return .other(message: message, status: status, retryMs: retryMs, retryable: retryable) default: diff --git a/bindings/swift/Sources/Aimux/Types.swift b/bindings/swift/Sources/Aimux/Types.swift index 5937c4f5..533c6682 100644 --- a/bindings/swift/Sources/Aimux/Types.swift +++ b/bindings/swift/Sources/Aimux/Types.swift @@ -629,6 +629,12 @@ public struct ToolCall: Codable, Equatable { /// Provider-assigned thought signature (e.g. Google Gemini /// `thoughtSignature`); must be echoed back verbatim on follow-up turns. public var thoughtSignature: String? + /// Additional provider-specific metadata associated with this call. + public var providerMetadata: JSONValue? + /// Set by Core when the tool call stays invalid after optional repair. + public var invalid: Bool? + /// The typed lookup, parse, schema, or repair failure for an invalid call. + public var error: JSONValue? enum CodingKeys: String, CodingKey { case toolCallId = "tool_call_id" @@ -637,14 +643,21 @@ public struct ToolCall: Codable, Equatable { case providerExecuted = "provider_executed" case dynamic case thoughtSignature = "thought_signature" + case providerMetadata = "provider_metadata" + case invalid + case error } public init(toolCallId: String, toolName: String, input: JSONValue, providerExecuted: Bool? = nil, dynamic: Bool? = nil, - thoughtSignature: String? = nil) { + thoughtSignature: String? = nil, providerMetadata: JSONValue? = nil, + invalid: Bool? = nil, + error: JSONValue? = nil) { self.toolCallId = toolCallId; self.toolName = toolName; self.input = input self.providerExecuted = providerExecuted; self.dynamic = dynamic self.thoughtSignature = thoughtSignature + self.providerMetadata = providerMetadata + self.invalid = invalid; self.error = error } } @@ -1187,7 +1200,8 @@ public enum StreamPart: Codable, Equatable { case toolInputDelta(id: String, delta: String, providerMetadata: JSONValue?) case toolInputEnd(id: String, providerMetadata: JSONValue?) case toolCall(toolCallId: String, toolName: String, input: JSONValue, - providerExecuted: Bool?, dynamic: Bool?, providerMetadata: JSONValue?) + providerExecuted: Bool?, dynamic: Bool?, providerMetadata: JSONValue?, + invalid: Bool?, error: JSONValue?) case toolResult(toolCallId: String, toolName: String, result: JSONValue, isError: Bool?, preliminary: Bool?, dynamic: Bool?, providerMetadata: JSONValue?) // P2: file @@ -1207,7 +1221,7 @@ public enum StreamPart: Codable, Equatable { case finishReason = "finish_reason", providerMetadata = "provider_metadata" case error case toolName = "tool_name", toolCallId = "tool_call_id", input, result - case providerExecuted = "provider_executed", dynamic + case providerExecuted = "provider_executed", dynamic, invalid case isError = "is_error", preliminary case timestamp, modelId = "model_id" case sourceType = "source_type", url, title @@ -1262,7 +1276,9 @@ public enum StreamPart: Codable, Equatable { input: try n.decode(JSONValue.self, forKey: .input), providerExecuted: try n.decodeIfPresent(Bool.self, forKey: .providerExecuted), dynamic: try n.decodeIfPresent(Bool.self, forKey: .dynamic), - providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata), + invalid: try n.decodeIfPresent(Bool.self, forKey: .invalid), + error: try n.decodeIfPresent(JSONValue.self, forKey: .error)) case "ToolResult": self = .toolResult(toolCallId: try n.decode(String.self, forKey: .toolCallId), toolName: try n.decode(String.self, forKey: .toolName), @@ -1337,12 +1353,13 @@ public enum StreamPart: Codable, Equatable { case .toolInputEnd(let id, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolInputEnd")) try n.encode(id, forKey: .id); try n.encodeIfPresent(pm, forKey: .providerMetadata) - case .toolCall(let toolCallId, let toolName, let input, let pe, let dyn, let pm): + case .toolCall(let toolCallId, let toolName, let input, let pe, let dyn, let pm, let inv, let err): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolCall")) try n.encode(toolCallId, forKey: .toolCallId); try n.encode(toolName, forKey: .toolName) try n.encode(input, forKey: .input) try n.encodeIfPresent(pe, forKey: .providerExecuted); try n.encodeIfPresent(dyn, forKey: .dynamic) try n.encodeIfPresent(pm, forKey: .providerMetadata) + try n.encodeIfPresent(inv, forKey: .invalid); try n.encodeIfPresent(err, forKey: .error) case .toolResult(let toolCallId, let toolName, let result, let ie, let prel, let dyn, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolResult")) try n.encode(toolCallId, forKey: .toolCallId); try n.encode(toolName, forKey: .toolName) diff --git a/bindings/swift/Tests/AimuxTests/WrapperTests.swift b/bindings/swift/Tests/AimuxTests/WrapperTests.swift index b706a1aa..428579bb 100644 --- a/bindings/swift/Tests/AimuxTests/WrapperTests.swift +++ b/bindings/swift/Tests/AimuxTests/WrapperTests.swift @@ -42,6 +42,23 @@ final class WrapperTests: XCTestCase { // MARK: - generateText: tool calls + func testToolCallProviderMetadataRoundTrip() throws { + let original = ToolCall( + toolCallId: "call_1", + toolName: "get_weather", + input: jv(#"{"location":"Tokyo"}"#), + providerMetadata: jv(#"{"openai":{"item_id":"item_1"}}"#) + ) + + let encoded = try JSONEncoder().encode(original) + let wire = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + let metadata = wire?["provider_metadata"] as? [String: Any] + XCTAssertNotNil(metadata?["openai"]) + + let decoded = try JSONDecoder().decode(ToolCall.self, from: encoded) + XCTAssertEqual(decoded.providerMetadata, original.providerMetadata) + } + /// Passing a typed `tools` option yields a typed `ToolCall` in the result: /// `.toolCalls[0].toolName`, `.toolCallId`, and the structured /// `.raw.content` `.toolCall` variant. @@ -80,7 +97,9 @@ final class WrapperTests: XCTestCase { XCTAssertEqual(toolContents.count, 1) XCTAssertEqual(toolContents[0].toolName, "get_weather") XCTAssertEqual(toolContents[0].toolCallId, "call_abc") - XCTAssertEqual(toolContents[0].input["location"]?.stringValue, "Tokyo") + // Raw content keeps the provider's argument text; the parsed object + // lives on the top-level toolCalls (asserted above). + XCTAssertEqual(toolContents[0].input.stringValue, "{\"location\":\"Tokyo\"}") } // MARK: - generateText: multi-role messages @@ -235,7 +254,7 @@ final class WrapperTests: XCTestCase { // The complete ToolCall part carries the tool name and structured input. let toolCall = parts.compactMap { part -> (String, JSONValue)? in - if case .toolCall(_, let name, let input, _, _, _) = part { return (name, input) } + if case .toolCall(_, let name, let input, _, _, _, _, _) = part { return (name, input) } return nil }.first XCTAssertEqual(toolCall?.0, "get_weather") diff --git a/docs/API.md b/docs/API.md index 2d100395..b60eea45 100644 --- a/docs/API.md +++ b/docs/API.md @@ -134,6 +134,11 @@ for its own declaration): | `raw` | Raw provider result (includes full content) | > **Note**: `text` and `tool_calls` are convenience fields extracted from `raw.content`. +> Extraction parses and validates each tool call per the AI SDK +> `parseToolCall` / `repairToolCall` contract: a `tool_calls` entry carries the +> parsed `input` plus `provider_metadata?`, `invalid?` (`true` when tool lookup, +> input parse, or schema validation failed) and `error?` (the serialized +> `AiMuxError` — `NoSuchTool` / `InvalidToolInput` / `ToolCallRepair`). > The `Source`, `Reasoning`, and `ToolResult` variants do not appear in the convenience fields — access them via `raw.content`. > **Type declarations are per-language.** Every binding declares these types in @@ -153,7 +158,7 @@ for its own declaration): | Variant | Fields | Description | |------|------|------| | `Text` | `text` | Generated text | -| `ToolCall` | `tool_call_id`, `tool_name`, `input`, `provider_executed?`, `dynamic?`, `provider_metadata?` | Tool call requested by the model | +| `ToolCall` | `tool_call_id`, `tool_name`, `input`, `provider_executed?`, `dynamic?`, `provider_metadata?` | Tool call requested by the model; here `input` is the provider's raw serialized argument text (a JSON string value) — Core parses it once for the `tool_calls` convenience field | | `Source` | `id`, `source_type`, `url?`, `title?` | Reference/source | | `Reasoning` | `text`, `provider_metadata?` | Reasoning/thinking segment | | `File` | `data: FileData`, `media_type`, `filename?`, `provider_metadata?` | File generated by the model | @@ -172,7 +177,7 @@ Examples: [Node.js](api/node.md#streaming-generation) · [Python](api/python.md# | `StreamStart` | Stream start (carries warnings) | | `TextStart` / `TextDelta` / `TextEnd` | Text segment lifecycle | | `ToolInputStart` / `ToolInputDelta` / `ToolInputEnd` | Tool calling input stream | -| `ToolCall` | Complete tool call | +| `ToolCall` | Complete tool call (same shape as a `tool_calls` entry, incl. `invalid?` / `error?`) | | `ToolResult` | Tool result executed by the provider | | `ReasoningStart` / `ReasoningDelta` / `ReasoningEnd` | Reasoning segment lifecycle | | `ResponseMetadata` | Response metadata (id, timestamp, model_id) | diff --git a/docs/api/c.md b/docs/api/c.md index d240ca42..d8ec3572 100644 --- a/docs/api/c.md +++ b/docs/api/c.md @@ -148,6 +148,20 @@ failures 200–206: `REENTRANT_CALL`, `RESULT_SERIALIZATION`, and `CALLBACK_FAILURE`. Values are never renumbered or reused. A code outside the enum is a header/library mismatch. +The unified `aimux_error_code_t` maps AiMuxError variants to values 1–13 and +15–17 (`AIMUX_E_NO_SUCH_TOOL` 15, `AIMUX_E_INVALID_TOOL_INPUT` 16, +`AIMUX_E_TOOL_CALL_REPAIR` 17; 4 is retired — the legacy catch-all `Tool` +variant, never produced — and 14 is reserved), adds RecordingError values +100–105, and assigns C ABI failures 200–206: `NULL_POINTER`, `INVALID_UTF8`, +`INVALID_WIRE_JSON`, `INVALID_HANDLE`, `REENTRANT_CALL`, +`RESULT_SERIALIZATION`, and `CALLBACK_FAILURE`. Values are never renumbered +or reused. A code outside the enum is a header/library mismatch. +Tool-contract payloads have their own getters, mirroring the `NoSuchModel` +ones: `aimux_error_tool_name` (codes 15/16), `aimux_error_available_tools` +(15; a JSON string array, NULL when no tool set was supplied), +`aimux_error_tool_input` (16), and `aimux_error_original_error` (17; the +original failure as the same externally-tagged JSON used by +`ToolCall.error`). All returned strings are caller-owned. Internal panics abort the process in this workspace's **release** profile (`panic = "abort"`); in a `panic=unwind` build a Rust callback's panic is diff --git a/docs/api/flutter.md b/docs/api/flutter.md index b6c49c28..d46ccaf4 100644 --- a/docs/api/flutter.md +++ b/docs/api/flutter.md @@ -77,6 +77,7 @@ shares a base with the other (both just `implements Exception`): | Source | Rust | Dart | C code | |---|---|---|---| | AiMux | `AiMuxError` | `AimuxException` hierarchy | 1..14 | +| AiMux | `AiMuxError` | `AimuxException` hierarchy | 1..13, 15..17 (4 retired, 14 reserved) | | recorder | `RecordingError` | `RecordingException` | 100..105 | Every fallible C call returns an opaque `aimux_error_t *` (`NULL` = @@ -85,6 +86,7 @@ success, result in the trailing out-parameter). The binding has one decoder `expectRecordingError(e, context)` for `initRecording` / `recordingTryFlush`, `expectFfiError(e, context)` for utilities that can only fail in the C ABI. One unified code selects 1..14, 100..105, or 200..206; each decoder copies the +One unified code selects 1..13 / 15..17, 100..105, or 200..206; each decoder copies the relevant fields, releases the error with `aimux_error_free` exactly once, and throws the matching `AimuxException` subclass / `RecordingException`. Codes 200..206 throw the native @@ -97,7 +99,6 @@ throws the matching `AimuxException` subclass / `RecordingException`. Codes Exception (implements) └── AimuxException ├── JSONParseError / InvalidResponseDataError - ├── ToolError ├── InvalidArgumentError / InvalidPromptError ├── TokenExpiredError // status 401 ├── UnsupportedFunctionalityError @@ -106,6 +107,9 @@ Exception (implements) ├── RetryError // reason, errors, lastError ├── AimuxTimeoutError ├── RequestAbortedError + ├── NoSuchToolError // code 15; tool not in the supplied tool set + ├── InvalidToolInputError // code 16; tool arguments failed to parse/validate + ├── ToolCallRepairError // code 17; a repairToolCall hook itself failed └── OtherError ``` @@ -116,6 +120,14 @@ subclass only: `APICallError.providerCode` / `.providerMessage` / `.responseBody (`String?`), `NoSuchModelError.modelId` / `.modelType`, and `NoSuchProviderError.providerId`. A code outside the enum is a header/library mismatch and fails with `StateError`, not an error type. +subclass only: `APICallError.providerCode` / `.providerMessage` / `.requestId` / `.responseBody` +(`String?`), `NoSuchModelError.modelId` / `.modelType`, +`NoSuchProviderError.providerId`, `NoSuchToolError.toolName` / +`.availableTools` (`List?`, null when no tool set was supplied), +`InvalidToolInputError.toolName` / `.toolInput` (the raw argument text), and +`ToolCallRepairError.originalError` (the repaired-over error decoded from its +wire JSON — the same shape as `ToolCall.error`). A code outside the enum is a +header/library mismatch and fails with `StateError`, not an error type. `APICallError` additionally exposes the sanitized URL/request values, response headers/body, parsed provider data/code, and retryability. @@ -259,6 +271,10 @@ model.close(); (sealed), `GenerateResult`, `GenerateTextResult`, `GenerateTextOptions`, `ModelMessage`, `StreamPart` (sealed), `FileBytes`, `FileData`, `ContentPart`. +`ToolCall` carries `providerMetadata` plus `invalid` (set by Core when tool +lookup, input parse, or schema validation fails, even after repair) and `error` +(the serialized `AiMuxError` for that failure). + ## Coverage Text generation and streaming are supported. Multimodal features (embedding, diff --git a/docs/api/gaps.md b/docs/api/gaps.md index 041a9935..1417b0a3 100644 --- a/docs/api/gaps.md +++ b/docs/api/gaps.md @@ -20,6 +20,7 @@ | **Python** | ~~Search 无工厂~~ | ✅ 已完成 | | **C ABI** | ~~`cohere_embedding` / `google_embedding` / `google_image` 无 `_with_base`~~ | ✅ 已完成 | | **Java** | 无(RFC-0013 新绑定,自始实现完整多模态面) | ✅ 已完成 | +| **全部绑定** | `repair_tool_call` 回调仅 Rust core 可用(见 [§8](#8-设计内差异repair_tool_callgeneratetextoptions)) | 设计内差异 | > **所有差距已修复。** Feature Coverage 矩阵全绿(见 > [API.md](../API.md#feature-coverage))。 @@ -152,6 +153,30 @@ FFI 调用仅 8 个符号。[Types.swift](../../bindings/swift/Sources/Aimux/Typ `aimux_openai_embedding_new_with_base` 的模式),并在 [c.md](c.md#function-list) 补行。 +## 8. 设计内差异:`repair_tool_call`(`GenerateTextOptions`) + +`GenerateTextOptions.repair_tool_call` 是 Rust core 独有的回调字段 +(`#[serde(skip)]`),无法跨 JSON/FFI 边界,因此**不会**出现在任何语言绑定里。 +这不是缺口:各绑定统一通过 tool call 上的 `invalid: true` + 类型化 `error` +字段(lookup / parse / schema / repair 失败)获知修复失败的调用。 + +同一契约下的其它设计内差异(均为有意为之,非缺口): + +- **Tool execution is not ported** (no executor / Agent loop), so the AI SDK's + synthetic tool-error stream chunk after an invalid call — and the tool-output + machinery it feeds — has no aimux counterpart. Invalid calls surface only as + the `invalid` / `error` fields on the `ToolCall` part. +- **Provider-defined tools carry no input schema**: aimux's `Tool::Provider` + sits at the AI SDK's V4-level tool set, so its calls are JSON-parsed but not + schema-validated. Upstream provider-defined tools carry validating schemas at + the core ToolSet level. +- **Function tools are always schema-validated**: aimux's `FunctionTool` + corresponds to the AI SDK's validator-carrying user tools (the zod path). The + upstream case where a bare jsonSchema/MCP tool goes unvalidated does not + arise, because MCP is not ported. +- **`experimental_refineToolInput`** (upstream's experimental input-refinement + hook) is not ported. + --- ## 建议实施顺序 diff --git a/docs/api/go.md b/docs/api/go.md index 4e500ff0..4360d68b 100644 --- a/docs/api/go.md +++ b/docs/api/go.md @@ -59,10 +59,19 @@ if err != nil { | `ProviderCode`, `ProviderMessage`, `ResponseBody`, `URL`, `RequestBodyValues`, `ResponseHeaders`, `Data` | `CodeAPICall` payload; empty under any other code (request-id evidence rides in `ResponseHeaders`) | | `ModelID`, `ModelType` | `CodeNoSuchModel` payload; empty under any other code | | `ProviderID` | `CodeNoSuchProvider` payload; empty under any other code | +| `ToolName` | `CodeNoSuchTool` / `CodeInvalidToolInput` payload: the tool name called | +| `AvailableTools` | `CodeNoSuchTool` payload: `[]string` of the tools the call offered; `nil` when not reported | +| `ToolInput` | `CodeInvalidToolInput` payload: the raw argument text the model produced | +| `OriginalError` | `CodeToolCallRepair` payload: the pre-repair failure as `json.RawMessage` wire JSON (same encoding as `ToolCall.Error`) | `Code` values 1..14 mirror aimux-core's `AiMuxError` variants. A code outside the enum is a header/library mismatch and fails with a `panic`, not an error type. +`Code` values 1..13 and 15..17 mirror aimux-core's `AiMuxError` variants; 4 is +retired (the legacy `Tool` variant) and 14 is reserved. The tool-call variants +arrive as `CodeNoSuchTool` (15), `CodeInvalidToolInput` (16), and +`CodeToolCallRepair` (17). A code outside the enum is a header/library +mismatch and fails with a `panic`, not an error type. Recording failures are a separate type, as in Rust (`recording::RecordingError` is unrelated to `AiMuxError`): `RecordingTryFlush() error` returns @@ -95,6 +104,8 @@ no Go type of their own; the binding maps them to native Go errors: Decoder: every fallible C call returns an opaque `aimux_error_t *` (NULL = success, result in the out-parameter). One `aimux_error_code()` distinguishes `AiMuxError` (1–14), `RecordingError` (100–105), and C ABI failures (200–206). +`AiMuxError` (1–13, 15–17), `RecordingError` (100–105), and C ABI failures +(200–206). `expectAimuxError`, `expectRecordingError`, and `expectFfiError` enforce the range expected by each call; the first two restore `*Error` and `*RecordingError`, while 200–206 becomes a plain `error`. Every path frees the @@ -112,6 +123,7 @@ design** — that one is opt-in, and every one of its five entry points has a | `aimux.go` `mustNew` — behind `OpenAI` / `OpenAIWithBase` / `Anthropic` / `AnthropicWithBase` / `DeepSeek` | **Yes, by design.** `regexp.MustCompile` convention: an `apiKey` / `modelID` / `baseURL` that is not valid UTF-8 or contains a NUL panics, as does any AiMuxError failure. Use `NewOpenAI` / `NewOpenAIWithBase` / `NewAnthropic` / `NewAnthropicWithBase` / `NewDeepSeek` for anything caller-supplied | | `aimux.go` `InitLogging` — `expectFfiError` returned an error | No. `level` is coerced first: empty, non-UTF-8, or NUL-bearing falls back to `"warn"`, which is what aimux-core does with an unparseable level anyway (`AIMUX_LOG` / `AIMUX_LOG_LEVEL` outrank it regardless). That leaves no documented failure for `aimux_init_logging`, so a non-nil error here is a header/library mismatch | | `aimux.go` `expectAimuxError` — `aimux_error_code_t` outside 1..14 | No. Header/library version mismatch | +| `aimux.go` `expectAimuxError` — `aimux_error_code_t` outside 1..13, 15..17 | No. Header/library version mismatch | | `aimux.go` `expectRecordingError` — `aimux_error_code_t` outside the enum | No. Header/library version mismatch | | `multimodal.go` `TranscriptionSession.NextPart` — unknown `aimux_transcription_next_part` state | No. Header/library version mismatch | @@ -514,3 +526,7 @@ Typed structs live in `bindings/go/types.go` (text) and The multimodal methods return JSON strings through the C ABI; the `ParseXxxResult` functions decode them into the typed structs. All call-option pointer fields (`*string`, `*bool`, `*int`) are optional — pass `nil` to omit. + +`ToolCall` carries `ProviderMetadata` plus `Invalid` (set by Core when tool +lookup, input parse, or schema validation fails, even after repair) and `Error` +(the serialized `AiMuxError` for that failure). diff --git a/docs/api/java.md b/docs/api/java.md index c3e16b5f..3cf6250d 100644 --- a/docs/api/java.md +++ b/docs/api/java.md @@ -69,7 +69,6 @@ Unknown names throw `NoSuchProviderError` naming the requested provider RuntimeException └── AimuxException ├── JSONParseError / InvalidResponseDataError - ├── ToolError ├── InvalidArgumentError / InvalidPromptError ├── TokenExpiredError // 401, refresh and retry ├── UnsupportedFunctionalityError @@ -77,6 +76,9 @@ RuntimeException ├── APICallError // every HTTP-shaped failure; classify on getStatusCode() ├── RetryError // reason, errors, lastError ├── TimeoutError / RequestAbortedError + ├── NoSuchToolError // code 15: the tool the model called is not in the tool set + ├── InvalidToolInputError // code 16: the tool arguments failed to parse or validate + ├── ToolCallRepairError // code 17: tool-call repair itself failed └── OtherError ``` @@ -86,6 +88,7 @@ Every instance has: |-------|---------| | `getMessage()` | human-readable text from C | | `getCode()` | `aimux_error_code_t` value 1–14, where 14 = `Retry` (matches `aimux-error.h`) | +| `getCode()` | `aimux_error_code_t` value 1–13 or 15–17 (4 retired, 14 reserved; matches `aimux-error.h`) | | `getStatusCode()` | HTTP status, or `-1` | | `getRetryMs()` | rate-limit hint, or `-1` (`0` = retry now) | | `isRetryable()` | the `AiMuxError` retry verdict (not derivable from status) | @@ -103,6 +106,17 @@ non-retryably), `getErrors()` (the per-attempt history, oldest first, each itself an `AimuxException` — typically `APICallError` with its full detail), `getLastError()`; `NoSuchModelError` — `getModelId()`, `getModelType()`; `NoSuchProviderError` — `getProviderId()`. +Six subclasses carry the C payload of their variant (`null` when unavailable): +`APICallError` — +`getProviderCode()`, `getProviderMessage()`, `getRequestId()`, `getResponseBody()`; +`NoSuchModelError` — `getModelId()`, `getModelType()`; +`NoSuchProviderError` — `getProviderId()`; +`NoSuchToolError` — `getToolName()`, `getAvailableTools()` (a `List`, +`null` when no tool set was supplied); +`InvalidToolInputError` — `getToolName()`, `getToolInput()` (the raw argument +text the model produced); +`ToolCallRepairError` — `getOriginalError()` (the original lookup/parse/validation +error as a Jackson `JsonNode`, the same wire encoding as `ToolCall.error`). Recording failures are a **separate type**, mirroring the two unrelated Rust error types: `Aimux.initRecording(dir)` and `Aimux.recordingTryFlush()` @@ -141,6 +155,7 @@ Transport: every fallible C call returns an opaque `aimux_error_t *` (JNA `Pointer`) — `null` on success with the result in a trailing out-parameter (`LongByReference` handle / `PointerByReference` JSON), non-null on failure. `AimuxResult` reads one unified code: 1–14 restores the matching +`AimuxResult` reads one unified code: 1–13 / 15–17 restores the matching `AimuxException` subclass, 100–105 restores `RecordingException`, and 200–206 becomes `IllegalStateException("aimux ffi: …")`. Payload getters are read only under their owning AiMuxError code; a `RetryError`'s attempt errors are new @@ -363,6 +378,11 @@ custom serializer for the scalar-or-object wire form), `ContentPart` (sealed), `GenerateTextResult`, `StreamPart` (sealed). All sealed hierarchies serialize in the wrapper-object wire form (e.g. `{"TextDelta":{...}}`). +`ToolCall` (top-level and `StreamPart.ToolCall`) carries `getProviderMetadata()` +plus `getInvalid()` (set by Core when tool lookup, input parse, or schema +validation fails, even after repair) and `getError()` (the serialized +`AiMuxError` for that failure). + `MultimodalTypes.java` declares the typed multimodal surface: `EmbeddingCallOptions` / `EmbeddingResult`, `SpeechCallOptions` / `SpeechResult`, `ImageCallOptions` / `ImageResult`, `TranscriptionCallOptions` / diff --git a/docs/api/kotlin.md b/docs/api/kotlin.md index f744f259..07fc7066 100644 --- a/docs/api/kotlin.md +++ b/docs/api/kotlin.md @@ -57,6 +57,7 @@ Two aimux exception types, each mirroring its own Rust type — **AiMux** beyond `RuntimeException`; catch each on its own. Every fallible C call returns an `aimux_error_t *` (null = success, result in the out-parameter). The binding reads one unified code: 1..14 restores an `AimuxException` subclass, +binding reads one unified code: 1..13 / 15..17 restores an `AimuxException` subclass, 100..105 restores `RecordingException`, and 200..206 becomes `IllegalStateException("aimux ffi: …")`. Payload getters are read only under their owning AiMuxError code. @@ -74,7 +75,6 @@ their owning code. RuntimeException └── AimuxException // code, status, retryMs, retryable ├── JSONParseError / InvalidResponseDataError - ├── ToolError ├── InvalidArgumentError / InvalidPromptError ├── TokenExpiredError // 401, refresh and retry ├── UnsupportedFunctionalityError @@ -83,6 +83,9 @@ RuntimeException │ // + providerCode, providerMessage, responseBody, url, requestBodyValues, responseHeaders, data (null when absent) ├── RetryError // the retry loop gave up; reason, errors (oldest first), lastError ├── TimeoutError / RequestAbortedError + ├── NoSuchToolError // toolName + availableTools (null = no tool set supplied) + ├── InvalidToolInputError // toolName + toolInput (the raw argument text) + ├── ToolCallRepairError // originalError (wire JSON, same encoding as ToolCall.error) └── OtherError ``` @@ -106,6 +109,7 @@ try { | Field | Meaning | |-------|---------| | `code` | `AIMUX_E_*` matching C `aimux_error_code_t` (1..14, where 14 = `Retry`; 1 is the catch-all `Other`) | +| `code` | `AIMUX_E_*` matching C `aimux_error_code_t` (1..13 and 15..17; 1 is the catch-all `Other`; 4 is retired — the legacy `Tool` variant — and 14 is reserved) | | `status` | HTTP status when known; otherwise `-1` | | `retryMs` | Rate-limit hint in ms; `-1` if none; `0` = retry immediately | @@ -247,6 +251,10 @@ typed model surface: `Role`, `FinishReasonUnified`, `ReasoningEffort`, `MultimodalTypes.kt` includes `VideoCallOptions.poll: VideoPollOptions?`; `intervalMs` and `timeoutMs` serialize as `interval_ms` / `timeout_ms` for the Core-owned video status loop. +`ToolCall` (top-level and `StreamPart.ToolCall`) carries `providerMetadata` +plus `invalid` (set by Core when tool lookup, input parse, or schema validation +fails, even after repair) and `error` (the serialized `AiMuxError` for that +failure). ## Coverage diff --git a/docs/api/node.md b/docs/api/node.md index 949a9ef7..2bdee08c 100644 --- a/docs/api/node.md +++ b/docs/api/node.md @@ -91,6 +91,8 @@ Error ├── APICallError // provider call/transport failure; status when observed ├── RetryError // the retry loop gave up; reason, errors (oldest first), lastError ├── JSONParseError / InvalidResponseDataError / ToolError + ├── JSONParseError / InvalidResponseDataError + ├── NoSuchToolError / InvalidToolInputError / ToolCallRepairError // tool-contract errors ├── InvalidArgumentError / InvalidPromptError ├── TokenExpiredError ├── UnsupportedFunctionalityError @@ -258,6 +260,10 @@ if (result.tool_calls.length > 0) { } ``` +> The `repair_tool_call` callback is Rust-core-only (it cannot cross the FFI +> boundary); tool calls that stay invalid arrive with `invalid: true` and a +> typed `error` on the tool call. + ### Tool Selection Strategy ```typescript diff --git a/docs/api/python.md b/docs/api/python.md index b354c4dd..f32b8790 100644 --- a/docs/api/python.md +++ b/docs/api/python.md @@ -110,6 +110,10 @@ if len(result["tool_calls"]) > 0: print(call["input"]) # {"location": "Tokyo"} ``` +> The `repair_tool_call` callback is Rust-core-only (it cannot cross the FFI +> boundary); tool calls that stay invalid arrive with `invalid: true` and a +> typed `error` on the tool call. + ### Tool Selection Strategy Pass `tool_choice` through the options dict: @@ -338,6 +342,8 @@ Exception ├── APICallError # provider call/transport failure; status when observed ├── RetryError # the retry loop gave up; reason, errors (oldest first), last_error ├── JSONParseError / InvalidResponseDataError / ToolError + ├── JSONParseError / InvalidResponseDataError + ├── NoSuchToolError / InvalidToolInputError / ToolCallRepairError # tool-contract errors ├── InvalidArgumentError / InvalidPromptError ├── TokenExpiredError ├── UnsupportedFunctionalityError diff --git a/docs/api/swift.md b/docs/api/swift.md index 2ef760fa..e455adea 100644 --- a/docs/api/swift.md +++ b/docs/api/swift.md @@ -108,6 +108,8 @@ Every fallible C function returns an opaque `aimux_error_t *` (`OpaquePointer?`): `NULL` = success (the result is in the trailing out-parameter), non-`NULL` = failure. One unified code selects `AimuxError` (1...14), `RecordingError` (100...105), or a C ABI failure (200...206). +(1...13 and 15...17; 4 retired, 14 reserved), `RecordingError` (100...105), +or a C ABI failure (200...206). The three decoders enforce the range expected by each call and restore the Swift error type; 200...206 collapses to `DecodingError.dataCorrupted`. Every path copies its strings (freed with @@ -123,7 +125,6 @@ and yields the invariant `DecodingError.dataCorrupted("aimux ffi: : |------|--------|--------| | `.jsonParse` | `AIMUX_E_JSON_PARSE` (2) | JSON parse/serialize | | `.invalidResponseData` | `AIMUX_E_INVALID_RESPONSE_DATA` (3) | Malformed response / stream data | -| `.tool` | `AIMUX_E_TOOL` (4) | Tool-related failure | | `.invalidArgument` | `AIMUX_E_INVALID_ARGUMENT` (5) | Bad argument | | `.invalidPrompt` | `AIMUX_E_INVALID_PROMPT` (6) | Bad prompt JSON | | `.tokenExpired` | `AIMUX_E_TOKEN_EXPIRED` (7) | Expired token; `status` 401 | @@ -134,6 +135,9 @@ and yields the invariant `DecodingError.dataCorrupted("aimux ffi: : | `.retry` | `AIMUX_E_RETRY` (14) | Complete attempt history and stop reason | | `.timeout` | `AIMUX_E_TIMEOUT` (12) | Request timed out | | `.aborted` | `AIMUX_E_ABORTED` (13) | Request aborted | +| `.noSuchTool` | `AIMUX_E_NO_SUCH_TOOL` (15) | The model called a tool outside the supplied tool set; carries `toolName` and `availableTools` (`[String]?`, `nil` when no tool set was supplied) | +| `.invalidToolInput` | `AIMUX_E_INVALID_TOOL_INPUT` (16) | Tool arguments failed to parse/validate; carries `toolName` and `toolInput` (the raw argument text) | +| `.toolCallRepair` | `AIMUX_E_TOOL_CALL_REPAIR` (17) | A `repairToolCall` hook itself failed; carries `originalError` (the repaired-over error as wire JSON, the `ToolCall.error` encoding) | | `.other` | `AIMUX_E_OTHER` (1) | Unclassified core error | There are no binding-local cases: only aimux-core produces an `AimuxError`. @@ -150,6 +154,14 @@ typed payload as extra associated values: `.apiCall(providerCode:providerMessage (all optional), `.noSuchModel(modelId:modelType:)` and `.noSuchProvider(providerId:)`; the same-named computed properties return `nil` on every other case. `e.code` returns the mapped `aimux_error_code_t` +`retryable`. Six cases carry a +typed payload as extra associated values: `.apiCall(providerCode:providerMessage:requestId:responseBody:)` +(all optional), `.noSuchModel(modelId:modelType:)`, +`.noSuchProvider(providerId:)`, `.noSuchTool(toolName:availableTools:)`, +`.invalidToolInput(toolName:toolInput:)` and +`.toolCallRepair(originalError:)`; the same-named computed properties return +`nil` on every other case (`toolName` answers for both `.noSuchTool` and +`.invalidToolInput`). `e.code` returns the mapped `aimux_error_code_t` constant as `Int32`. **Recording errors are a separate type.** `Model.initRecording(dir:)` and @@ -213,6 +225,11 @@ mirroring the shared JSON shape — usable with the JSON-string APIs: `GenerateContent`, `GenerateResult`, `GenerateTextResult`, `GenerateTextOptions`, `StreamPart` (all `Codable, Equatable`). +`ToolCall` (top-level and `StreamPart.toolCall`) carries `providerMetadata` +plus `invalid` (set by Core when tool lookup, input parse, or schema validation +fails, even after repair) and `error` (the serialized `AiMuxError` for that +failure). + Example: ```swift diff --git a/tools/aimux-web/web/src/types/AiMuxError.ts b/tools/aimux-web/web/src/types/AiMuxError.ts index bd81f0a3..a5377515 100644 --- a/tools/aimux-web/web/src/types/AiMuxError.ts +++ b/tools/aimux-web/web/src/types/AiMuxError.ts @@ -14,7 +14,7 @@ import type { ApiCallError } from "./ApiCallError"; * (`ApiCallError { status_code: .., ..Default::default() }`), the same * shape as the AI SDK's named-options constructor. */ -export type AiMuxError = { "ApiCall": ApiCallError } | { "JsonParse": string } | { "InvalidResponseData": string } | { "Tool": string } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, +export type AiMuxError = { "ApiCall": ApiCallError } | { "JsonParse": string } | { "InvalidResponseData": string } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, /** * What kind of model was requested (`"languageModel"`, * `"imageModel"`, …), the AI SDK's `modelType`. From c3d98fbf2e7d755588b1cca00600261328d367f7 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Mon, 24 Aug 2026 09:37:22 +0800 Subject: [PATCH 02/19] test(core): pin the fifteen-variant error contract The golden variant-set test only asserted the wire tags of the variants it already knew, so an added variant sailed past a test named "exactly thirteen". Extend the list to the three tool-contract variants (the dead legacy catch-all Tool is gone), snapshot their exact JSON payloads, and add a compile-time exhaustive match, so any future addition fails the build of this test instead of relying on the runtime assertion. --- aimux-core/tests/error_value_golden_test.rs | 92 +++++++++++++++++++-- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/aimux-core/tests/error_value_golden_test.rs b/aimux-core/tests/error_value_golden_test.rs index b3161f78..6d8f9164 100644 --- a/aimux-core/tests/error_value_golden_test.rs +++ b/aimux-core/tests/error_value_golden_test.rs @@ -105,9 +105,39 @@ fn error_value_snapshots_plain_variants() { AiMuxError::InvalidResponseData("eof".into()), r#"{"InvalidResponseData":"eof"}"#, ), + // NoSuchTool is pinned in both shapes: `skip_serializing_if` makes + // the wire payload vary with `available_tools`. ( - AiMuxError::Tool("tool blew up".into()), - r#"{"Tool":"tool blew up"}"#, + AiMuxError::NoSuchTool { + tool_name: "weathr".into(), + available_tools: Some(vec!["weather".into(), "search".into()]), + }, + r#"{"NoSuchTool":{"tool_name":"weathr","available_tools":["weather","search"]}}"#, + ), + ( + AiMuxError::NoSuchTool { + tool_name: "weathr".into(), + available_tools: None, + }, + r#"{"NoSuchTool":{"tool_name":"weathr"}}"#, + ), + ( + AiMuxError::InvalidToolInput { + tool_name: "weather".into(), + tool_input: "{".into(), + cause: "JSON parsing failed".into(), + }, + r#"{"InvalidToolInput":{"tool_name":"weather","tool_input":"{","cause":"JSON parsing failed"}}"#, + ), + ( + AiMuxError::ToolCallRepair { + original_error: Box::new(AiMuxError::NoSuchTool { + tool_name: "weathr".into(), + available_tools: None, + }), + cause: Box::new(AiMuxError::Other("repair model failed".into())), + }, + r#"{"ToolCallRepair":{"original_error":{"NoSuchTool":{"tool_name":"weathr"}},"cause":{"Other":"repair model failed"}}}"#, ), ( AiMuxError::InvalidArgument("bad arg".into()), @@ -153,12 +183,39 @@ fn error_value_snapshots_plain_variants() { } /// The variant set is a cross-language contract of its own: bindings switch on -/// the wire tag. Adding or removing one is a breaking change (14 variants — -/// the per-status avatars `Auth`/`ModelNotFound`/`RateLimited` are gone, and -/// `Http`/`Provider` folded into `ApiCall`: a failed exchange is an `ApiCall` -/// error classified by `status_code`, transport failures included). +/// the wire tag. Adding or removing one is a breaking change (16 variants — +/// the per-status avatars `Auth`/`ModelNotFound`/`RateLimited` are gone, +/// `Http`/`Provider` folded into `ApiCall` (a failed exchange is an `ApiCall` +/// error classified by `status_code`, transport failures included), and the +/// legacy catch-all `Tool` — never constructed anywhere — is replaced by the +/// typed `NoSuchTool`/`InvalidToolInput`/`ToolCallRepair`). +/// Compile-time pin: adding an `AiMuxError` variant breaks this match, so +/// the wire-tag list below and every binding's switch must be updated in the +/// same change (the runtime assertion alone cannot see additions). +#[allow(dead_code)] +fn variant_addition_breaks_this_match(error: &AiMuxError) { + match error { + AiMuxError::ApiCall(_) + | AiMuxError::Retry(_) + | AiMuxError::JsonParse(_) + | AiMuxError::InvalidResponseData(_) + | AiMuxError::NoSuchTool { .. } + | AiMuxError::InvalidToolInput { .. } + | AiMuxError::ToolCallRepair { .. } + | AiMuxError::InvalidArgument(_) + | AiMuxError::InvalidPrompt(_) + | AiMuxError::TokenExpired(_) + | AiMuxError::UnsupportedFunctionality(_) + | AiMuxError::NoSuchModel { .. } + | AiMuxError::NoSuchProvider { .. } + | AiMuxError::Timeout(_) + | AiMuxError::Aborted(_) + | AiMuxError::Other(_) => {} + } +} + #[test] -fn variant_set_is_exactly_fourteen() { +fn variant_set_is_exactly_sixteen() { let all = [ AiMuxError::ApiCall(Box::new(api_error("x"))), AiMuxError::Retry(RetryError { @@ -167,7 +224,22 @@ fn variant_set_is_exactly_fourteen() { }), AiMuxError::JsonParse("x".into()), AiMuxError::InvalidResponseData("x".into()), - AiMuxError::Tool("x".into()), + AiMuxError::NoSuchTool { + tool_name: "x".into(), + available_tools: None, + }, + AiMuxError::InvalidToolInput { + tool_name: "x".into(), + tool_input: "{}".into(), + cause: "x".into(), + }, + AiMuxError::ToolCallRepair { + original_error: Box::new(AiMuxError::NoSuchTool { + tool_name: "x".into(), + available_tools: None, + }), + cause: Box::new(AiMuxError::Other("x".into())), + }, AiMuxError::InvalidArgument("x".into()), AiMuxError::InvalidPrompt("x".into()), AiMuxError::TokenExpired("x".into()), @@ -202,14 +274,16 @@ fn variant_set_is_exactly_fourteen() { "InvalidArgument", "InvalidPrompt", "InvalidResponseData", + "InvalidToolInput", "JsonParse", "NoSuchModel", "NoSuchProvider", + "NoSuchTool", "Other", "Retry", "Timeout", "TokenExpired", - "Tool", + "ToolCallRepair", "UnsupportedFunctionality", ], "variant set changed" From 7ed1dd8131f6d6a784eb5a324b9ff0a7b5659824 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Tue, 25 Aug 2026 14:43:30 +0800 Subject: [PATCH 03/19] feat: replay server-tool calls and repair streamed tool inputs end to end Port of the follow-up round built in the stacked worktree, rebased onto the master-based branch: - generate: assemble response messages in stream order (text/reasoning segment flushing, provider_executed on replayed tool calls), matching toResponseMessages; response_tool_call_input replaces the narrower response_message_input (JS 'typeof x === "object"' also admits arrays). - openai outputs: buffer raw tool arguments across repair so invalid streams replay verbatim. - anthropic (direct + vertex): collapse bash/text-editor code-execution variants into the caller's single code_execution tool; the wire name survives in the normalized input 'type'. Google/xAI/HuggingFace conversions aligned the same way. - bindings: thought_signature + provider_executed + arbitrary-JSON provider_metadata on ToolCall across Go/Java/Kotlin/Swift/Flutter/ Node/Python; wire-format fixtures extended. Deliberately not ported (coupled to the retry PR's machinery, restored when that branch rebases onto this one): timeout/abort racing of the repair callback and its six tests, recoverable stream-frame handling, anthropic_stream_error. --- aimux-core/src/content.rs | 6 + aimux-core/src/generate.rs | 401 +++++++++++--- aimux-core/src/openai_output.rs | 299 +++++++++-- aimux-core/src/tool.rs | 14 - aimux-core/tests/contract_test.rs | 33 +- aimux-core/tests/error_value_golden_test.rs | 29 + .../tests/response_messages_tool_test.rs | 500 ++++++++++++++++++ aimux-core/tests/tool_input_test.rs | 266 +++++++++- aimux-providers/src/anthropic/convert.rs | 120 ++++- aimux-providers/src/anthropic/stream.rs | 298 ++++++++--- .../src/anthropic/tool_name_mapping.rs | 49 +- aimux-providers/src/anthropic/types.rs | 14 + aimux-providers/src/google/convert.rs | 312 +++++++++-- aimux-providers/src/google/model.rs | 73 ++- aimux-providers/src/huggingface/responses.rs | 8 +- aimux-providers/src/vertex/anthropic_model.rs | 148 ++++-- aimux-providers/src/vertex/model.rs | 268 ++++++++-- aimux-providers/src/xai/responses/convert.rs | 29 +- aimux-providers/src/xai/responses/mod.rs | 4 +- aimux-providers/tests/alibaba_test.rs | 1 + .../anthropic_assistant_tool_result_test.rs | 260 ++++++++- .../tests/anthropic_cache_control_test.rs | 1 + aimux-providers/tests/anthropic_model_test.rs | 413 ++++++++++++++- .../tests/anthropic_provider_tools_test.rs | 223 +++++++- .../tests/cohere_remaining_test.rs | 1 + .../tests/data_loss_regression_test.rs | 5 +- aimux-providers/tests/google_model_test.rs | 70 +++ .../tests/google_provider_tools_test.rs | 206 +++++++- .../tests/huggingface_responses_test.rs | 11 + .../tests/mistral_remaining_test.rs | 1 + .../tests/vertex_anthropic_test.rs | 208 +++++++- aimux-providers/tests/vertex_model_test.rs | 357 ++++++++++++- aimux-providers/tests/xai_responses_test.rs | 161 +++++- bindings/flutter/lib/types.dart | 10 +- bindings/flutter/lib/types.g.dart | 6 +- bindings/flutter/test/content_part_test.dart | 3 + bindings/flutter/test/contract_test.dart | 18 + .../flutter/test/typed_round_trip_test.dart | 48 +- bindings/go/roundtrip_test.go | 2 +- bindings/go/types.go | 1 + bindings/go/wire_format_test.go | 21 +- .../main/java/ai/arcships/aimux/Types.java | 70 ++- .../java/ai/arcships/aimux/ContractTest.java | 59 ++- .../main/kotlin/ai/arcships/aimux/Types.kt | 2 + .../kotlin/ai/arcships/aimux/ContractTest.kt | 25 +- .../ai/arcships/aimux/TypedModelTest.kt | 15 +- bindings/node/src/types/ContentPart.ts | 6 + bindings/python/python/aimux/wrapper.py | 1 + bindings/python/tests/test_contract.py | 21 +- bindings/swift/Sources/Aimux/Types.swift | 30 +- .../Tests/AimuxTests/ContractTests.swift | 70 +++ .../swift/Tests/AimuxTests/WrapperTests.swift | 4 +- contract-tests/fixtures/wire-format.json | 12 +- contract-tests/run-node.ts | 18 + tools/aimux-web/src/wire.rs | 5 + tools/aimux-web/web/src/types/ContentPart.ts | 6 + 56 files changed, 4716 insertions(+), 526 deletions(-) create mode 100644 aimux-core/tests/response_messages_tool_test.rs diff --git a/aimux-core/src/content.rs b/aimux-core/src/content.rs index 72f039ad..e6dfe464 100644 --- a/aimux-core/src/content.rs +++ b/aimux-core/src/content.rs @@ -99,6 +99,11 @@ pub enum ContentPart { tool_name: String, /// Arguments as a JSON value (usually an object). input: Value, + /// Whether the tool call is executed by the provider rather than by + /// the client. This is part of the prompt contract because providers + /// need it to replay server tool calls on a later turn. + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_executed: Option, /// Provider-assigned thought signature (e.g. Google Gemini /// `thoughtSignature`). Must be echoed back verbatim on the follow-up /// turn when the tool result is sent. @@ -161,6 +166,7 @@ impl ContentPart { tool_call_id: tool_call_id.into(), tool_name: tool_name.into(), input, + provider_executed: None, thought_signature: None, provider_options: None, } diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 2b292445..c6d2bc97 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -43,6 +43,56 @@ fn is_output_chunk(part: &StreamPart) -> bool { } } +/// Match the AI SDK's response-message safety rule for invalid tool calls: +/// malformed primitive input must not be replayed as a prompt tool-call input. +/// JavaScript's `typeof value === "object"` includes arrays and null, so those +/// values are intentionally retained here as well. +fn response_tool_call_input(input: &Value, invalid: Option) -> Value { + if invalid == Some(true) && !matches!(input, Value::Object(_) | Value::Array(_) | Value::Null) { + Value::Object(serde_json::Map::new()) + } else { + input.clone() + } +} + +fn flush_response_text( + parts: &mut Vec, + text: &mut String, + provider_options: &mut Option, +) { + if !text.is_empty() { + parts.push(ContentPart::Text { + text: std::mem::take(text), + provider_options: provider_options.take(), + }); + } else { + *provider_options = None; + } +} + +fn flush_response_reasoning( + parts: &mut Vec, + reasoning: &mut Vec, + text: &mut String, + provider_options: &mut Option, +) { + if text.is_empty() && provider_options.is_none() { + return; + } + + let text = std::mem::take(text); + if !text.is_empty() { + reasoning.push(ReasoningPart { text: text.clone() }); + } + let provider_options = provider_options.take(); + let signature = extract_reasoning_signature(provider_options.as_ref()); + parts.push(ContentPart::Reasoning { + text, + signature, + provider_options, + }); +} + // ───────────────────────────────────────────────────────────────────────────── // User-facing options // ───────────────────────────────────────────────────────────────────────────── @@ -326,6 +376,9 @@ impl StreamTextResult { let mut finish_provider_metadata: Option = None; let mut response: Option = None; let mut response_content_parts: Vec = Vec::new(); + let mut response_text_buf = String::new(); + let mut response_text_provider_options: Option = None; + let mut response_reasoning_provider_options: Option = None; let mut saw_output = false; let mut saw_finish = false; @@ -350,40 +403,101 @@ impl StreamTextResult { saw_output = true; } match part { - StreamPart::TextDelta { delta, .. } => { + StreamPart::TextStart { + provider_metadata, .. + } => { + flush_response_reasoning( + &mut response_content_parts, + &mut reasoning, + &mut reasoning_text_buf, + &mut response_reasoning_provider_options, + ); + // A new text segment establishes its position immediately; + // flush a preceding implicit segment before starting it. + flush_response_text( + &mut response_content_parts, + &mut response_text_buf, + &mut response_text_provider_options, + ); + response_text_provider_options = provider_metadata; + } + StreamPart::TextDelta { + delta, + provider_metadata, + .. + } => { + flush_response_reasoning( + &mut response_content_parts, + &mut reasoning, + &mut reasoning_text_buf, + &mut response_reasoning_provider_options, + ); text.push_str(&delta); - // Accumulate for response_messages lazily (see Finish below). + response_text_buf.push_str(&delta); + if provider_metadata.is_some() { + response_text_provider_options = provider_metadata; + } + } + StreamPart::TextEnd { + provider_metadata, .. + } => { + if provider_metadata.is_some() { + response_text_provider_options = provider_metadata; + } + flush_response_text( + &mut response_content_parts, + &mut response_text_buf, + &mut response_text_provider_options, + ); } - StreamPart::ReasoningDelta { delta, .. } => { + StreamPart::ReasoningStart { + provider_metadata, .. + } => { + flush_response_text( + &mut response_content_parts, + &mut response_text_buf, + &mut response_text_provider_options, + ); + flush_response_reasoning( + &mut response_content_parts, + &mut reasoning, + &mut reasoning_text_buf, + &mut response_reasoning_provider_options, + ); + response_reasoning_provider_options = provider_metadata; + } + StreamPart::ReasoningDelta { + delta, + provider_metadata, + .. + } => { + flush_response_text( + &mut response_content_parts, + &mut response_text_buf, + &mut response_text_provider_options, + ); reasoning_text_buf.push_str(&delta); + if provider_metadata.is_some() { + response_reasoning_provider_options = provider_metadata; + } } StreamPart::ReasoningEnd { provider_metadata, .. } => { - if !reasoning_text_buf.is_empty() { - reasoning.push(ReasoningPart { - text: reasoning_text_buf.clone(), - }); - // Push reasoning into response_messages too — it carries - // the thinking-block signature (provider_metadata) which - // must be echoed back for extended-thinking multi-turn. - let signature = extract_reasoning_signature(provider_metadata.as_ref()); - response_content_parts.push(ContentPart::Reasoning { - text: reasoning_text_buf.clone(), - signature, - provider_options: provider_metadata, - }); - reasoning_text_buf.clear(); - } else if provider_metadata.is_some() { - // No visible summary text, but the part carries - // provider data (e.g. OpenAI encrypted reasoning with - // store=false) that must round-trip on the next turn. - response_content_parts.push(ContentPart::Reasoning { - text: String::new(), - signature: None, - provider_options: provider_metadata, - }); + flush_response_text( + &mut response_content_parts, + &mut response_text_buf, + &mut response_text_provider_options, + ); + if provider_metadata.is_some() { + response_reasoning_provider_options = provider_metadata; } + flush_response_reasoning( + &mut response_content_parts, + &mut reasoning, + &mut reasoning_text_buf, + &mut response_reasoning_provider_options, + ); } StreamPart::ToolCall { tool_call_id, @@ -397,6 +511,19 @@ impl StreamTextResult { error, .. } => { + flush_response_text( + &mut response_content_parts, + &mut response_text_buf, + &mut response_text_provider_options, + ); + flush_response_reasoning( + &mut response_content_parts, + &mut reasoning, + &mut reasoning_text_buf, + &mut response_reasoning_provider_options, + ); + let response_input = response_tool_call_input(&input, invalid); + let response_provider_options = provider_metadata.clone(); tool_calls.push(crate::tool::ToolCall { tool_call_id: tool_call_id.clone(), tool_name: tool_name.clone(), @@ -408,8 +535,50 @@ impl StreamTextResult { invalid, error, }); - // Defer adding to response_content_parts — order is rebuilt - // after the loop (reasoning → text → tool_calls). + response_content_parts.push(ContentPart::ToolCall { + tool_call_id, + tool_name, + input: response_input, + provider_executed, + thought_signature, + provider_options: response_provider_options, + }); + } + StreamPart::ToolResult { + tool_call_id, + tool_name, + result, + is_error, + preliminary, + dynamic, + provider_metadata, + } => { + // Preliminary server-tool results are transient stream + // updates. The provider contract requires a later final + // result, and only that final value belongs in the replay + // transcript for the next model turn. + if preliminary != Some(true) { + flush_response_text( + &mut response_content_parts, + &mut response_text_buf, + &mut response_text_provider_options, + ); + flush_response_reasoning( + &mut response_content_parts, + &mut reasoning, + &mut reasoning_text_buf, + &mut response_reasoning_provider_options, + ); + response_content_parts.push(ContentPart::ToolResult { + tool_call_id, + tool_name: Some(tool_name), + result, + is_error, + preliminary, + dynamic, + provider_options: provider_metadata, + }); + } } StreamPart::Source { id, @@ -438,18 +607,17 @@ impl StreamTextResult { usage: u, provider_metadata: pm, } => { - // Flush any pending reasoning delta before finishing. - if !reasoning_text_buf.is_empty() { - reasoning.push(ReasoningPart { - text: reasoning_text_buf.clone(), - }); - response_content_parts.push(ContentPart::Reasoning { - text: reasoning_text_buf.clone(), - signature: None, - provider_options: None, - }); - reasoning_text_buf.clear(); - } + flush_response_text( + &mut response_content_parts, + &mut response_text_buf, + &mut response_text_provider_options, + ); + flush_response_reasoning( + &mut response_content_parts, + &mut reasoning, + &mut reasoning_text_buf, + &mut response_reasoning_provider_options, + ); raw_finish_reason = fr.raw.clone(); finish_reason = fr; usage = u.clone(); @@ -496,23 +664,17 @@ impl StreamTextResult { }; } - // Build response_content_parts in provider order: - // reasoning (added during loop) → text → tool_calls. - if !text.is_empty() { - response_content_parts.push(ContentPart::Text { - text: text.clone(), - provider_options: None, - }); - } - for tc in &tool_calls { - response_content_parts.push(ContentPart::ToolCall { - tool_call_id: tc.tool_call_id.clone(), - tool_name: tc.tool_name.clone(), - input: crate::tool::response_message_input(tc), - thought_signature: tc.thought_signature.clone(), - provider_options: tc.provider_metadata.clone(), - }); - } + flush_response_text( + &mut response_content_parts, + &mut response_text_buf, + &mut response_text_provider_options, + ); + flush_response_reasoning( + &mut response_content_parts, + &mut reasoning, + &mut reasoning_text_buf, + &mut response_reasoning_provider_options, + ); let response_messages = if response_content_parts.is_empty() { Vec::new() } else { @@ -668,13 +830,6 @@ pub async fn generate_text( return Err(e); } }; - if let (Some(rec), Some(call_id)) = (&recorder, &call_id) { - rec.record_outcome( - call_id, - &crate::recording::OutcomeRecord::from_generate_result(&result), - ); - } - // 4. Extract text, tool calls, reasoning, sources, files from content. let mut text = String::new(); let mut tool_calls = Vec::new(); @@ -685,12 +840,17 @@ pub async fn generate_text( let mut response_content_parts: Vec = Vec::new(); for content in &result.content { match content { - GenerateContent::Text { text: t, .. } => { + GenerateContent::Text { + text: t, + provider_metadata, + } => { text.push_str(t); - response_content_parts.push(ContentPart::Text { - text: t.clone(), - provider_options: None, - }); + if !t.is_empty() { + response_content_parts.push(ContentPart::Text { + text: t.clone(), + provider_options: provider_metadata.clone(), + }); + } } GenerateContent::ToolCall { tool_call_id, @@ -721,7 +881,8 @@ pub async fn generate_text( response_content_parts.push(ContentPart::ToolCall { tool_call_id: parsed.tool_call_id.clone(), tool_name: parsed.tool_name.clone(), - input: crate::tool::response_message_input(&parsed), + input: response_tool_call_input(&parsed.input, parsed.invalid), + provider_executed: parsed.provider_executed, thought_signature: parsed.thought_signature.clone(), provider_options: parsed.provider_metadata.clone(), }); @@ -769,8 +930,29 @@ pub async fn generate_text( media_type: media_type.clone(), }); } - // Provider-executed tool results; not extracted to the top level. - GenerateContent::ToolResult { .. } => {} + // Provider-executed results stay in the assistant message so the + // provider can replay its own server-tool transcript next turn. + GenerateContent::ToolResult { + tool_call_id, + tool_name, + result, + is_error, + preliminary, + dynamic, + provider_metadata, + } => { + if *preliminary != Some(true) { + response_content_parts.push(ContentPart::ToolResult { + tool_call_id: tool_call_id.clone(), + tool_name: Some(tool_name.clone()), + result: result.clone(), + is_error: *is_error, + preliminary: *preliminary, + dynamic: *dynamic, + provider_options: provider_metadata.clone(), + }); + } + } } } @@ -780,10 +962,24 @@ pub async fn generate_text( .collect::>() .join(""); - let response_messages = vec![ModelMessage { - role: Role::Assistant, - content: MessageContent::Parts(response_content_parts), - }]; + // Parsing and optional repair are part of the user operation. Record a + // successful outcome only after that phase has completed within the same + // deadline/cancellation scope as the provider call. + if let (Some(rec), Some(call_id)) = (&recorder, &call_id) { + rec.record_outcome( + call_id, + &crate::recording::OutcomeRecord::from_generate_result(&result), + ); + } + + let response_messages = if response_content_parts.is_empty() { + Vec::new() + } else { + vec![ModelMessage { + role: Role::Assistant, + content: MessageContent::Parts(response_content_parts), + }] + }; // Extract fields before moving `result` into `raw`. let raw_finish_reason = result.finish_reason.raw.clone(); @@ -1268,8 +1464,9 @@ fn split_prompt( // ───────────────────────────────────────────────────────────────────────────── use crate::openai_output::{ - ChatCompletion, ChatCompletionStream, OpenAiStreamOptions, to_chat_completion, - to_chat_completion_stream, + ChatCompletion, ChatCompletionFunction, ChatCompletionStream, ChatCompletionToolCall, + OpenAiStreamOptions, parsed_tool_call_arguments, to_chat_completion, to_chat_completion_stream, + to_chat_completion_stream_with_deferred_tool_calls, }; /// Generate text and return the result as an OpenAI Chat Completion. @@ -1277,6 +1474,8 @@ use crate::openai_output::{ /// This is the OpenAI-compatible equivalent of [`generate_text`]. Internally /// it calls `generate_text`, then converts the [`GenerateResult`] into a /// [`ChatCompletion`] via [`to_chat_completion`]. +/// Tool calls reflect Core parsing and any configured repair; provider +/// response metadata, usage, and non-tool content are preserved from `raw`. /// /// Works with **any** provider (OpenAI, Anthropic, Google, …) — the output is /// always standard OpenAI Chat Completions JSON. @@ -1308,7 +1507,31 @@ pub async fn generate_text_as_openai( options: GenerateTextOptions, ) -> Result { let result = generate_text(model, prompt, options).await?; - Ok(to_chat_completion(&result.raw, model.model_id())) + let mut completion = to_chat_completion(&result.raw, model.model_id()); + if let Some(choice) = completion.choices.first_mut() { + choice.message.tool_calls = if result.tool_calls.is_empty() { + None + } else { + Some( + result + .tool_calls + .iter() + .map(|tool_call| ChatCompletionToolCall { + id: tool_call.tool_call_id.clone(), + tool_type: "function".to_string(), + function: ChatCompletionFunction { + name: tool_call.tool_name.clone(), + arguments: parsed_tool_call_arguments( + &tool_call.input, + tool_call.invalid, + ), + }, + }) + .collect(), + ) + }; + } + Ok(completion) } /// Stream text and return the result as a stream of OpenAI Chat Completion chunks. @@ -1355,12 +1578,22 @@ pub async fn stream_text_as_openai( options: GenerateTextOptions, stream_options: OpenAiStreamOptions, ) -> Result { + // `parse_tool_call` only invokes repair when a tool set was supplied. + let defer_tool_calls = options.repair_tool_call.is_some() && options.tools.is_some(); let result = stream_text(model, prompt, options).await?; - Ok(to_chat_completion_stream( - result.stream, - model.model_id(), - stream_options, - )) + if defer_tool_calls { + Ok(to_chat_completion_stream_with_deferred_tool_calls( + result.stream, + model.model_id(), + stream_options, + )) + } else { + Ok(to_chat_completion_stream( + result.stream, + model.model_id(), + stream_options, + )) + } } #[cfg(test)] diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index a0ac31cc..b10ff719 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -407,13 +407,36 @@ pub fn to_chat_completion_stream( stream: Pin> + Send>>, model: &str, options: OpenAiStreamOptions, +) -> ChatCompletionStream { + to_chat_completion_stream_impl(stream, model, options, false) +} + +/// Convert a Core stream after tool-call repair has been enabled. +/// +/// A repair callback may replace both the tool name and its full input after +/// the provider's input deltas have arrived. Holding those deltas until the +/// parsed `ToolCall` prevents the OpenAI stream from exposing values that +/// cannot be corrected by a later delta. +pub(crate) fn to_chat_completion_stream_with_deferred_tool_calls( + stream: Pin> + Send>>, + model: &str, + options: OpenAiStreamOptions, +) -> ChatCompletionStream { + to_chat_completion_stream_impl(stream, model, options, true) +} + +fn to_chat_completion_stream_impl( + stream: Pin> + Send>>, + model: &str, + options: OpenAiStreamOptions, + defer_tool_calls: bool, ) -> ChatCompletionStream { let model = model.to_string(); let include_usage = options.include_usage; let include_reasoning = options.include_reasoning; let chunk_stream = async_stream::stream! { - let mut state = StreamState::new(model.clone()); + let mut state = StreamState::new(model.clone(), defer_tool_calls); use futures::StreamExt; let mut stream = stream; @@ -471,6 +494,8 @@ struct StreamState { next_tool_index: u32, /// Whether each tool_call_id has had its opening chunk emitted. tool_call_opened: std::collections::HashSet, + /// Hold provider input frames until Core emits the parsed/repaired call. + defer_tool_calls: bool, final_usage: Option, final_finish_reason: Option, finish_emitted: bool, @@ -481,10 +506,12 @@ struct ToolCallAccum { index: u32, id: String, name: String, + /// Argument bytes already emitted through OpenAI delta chunks. + arguments: String, } impl StreamState { - fn new(model: String) -> Self { + fn new(model: String, defer_tool_calls: bool) -> Self { Self { id: format!("chatcmpl-{}", random_id()), model, @@ -494,6 +521,7 @@ impl StreamState { tool_call_order: Vec::new(), next_tool_index: 0, tool_call_opened: std::collections::HashSet::new(), + defer_tool_calls, final_usage: None, final_finish_reason: None, finish_emitted: false, @@ -617,40 +645,46 @@ impl StreamState { index: idx, id: id.clone(), name: tool_name.clone(), + arguments: String::new(), }, ); self.tool_call_order.push(id.clone()); idx }; - self.tool_call_opened.insert(id.clone()); + if !self.defer_tool_calls { + self.tool_call_opened.insert(id.clone()); - let mut chunk = self.base_chunk(); - chunk.choices = vec![ChatCompletionChunkChoice { - index: 0, - delta: ChatCompletionDelta { - tool_calls: Some(vec![ChatCompletionChunkToolCall { - index, - id: Some(id.clone()), - tool_type: Some("function".to_string()), - function: ChatCompletionChunkFunction { - name: Some(tool_name.clone()), - arguments: Some(String::new()), - }, - }]), - ..Default::default() - }, - finish_reason: None, - logprobs: None, - }]; - chunks.push(chunk); + let mut chunk = self.base_chunk(); + chunk.choices = vec![ChatCompletionChunkChoice { + index: 0, + delta: ChatCompletionDelta { + tool_calls: Some(vec![ChatCompletionChunkToolCall { + index, + id: Some(id.clone()), + tool_type: Some("function".to_string()), + function: ChatCompletionChunkFunction { + name: Some(tool_name.clone()), + arguments: Some(String::new()), + }, + }]), + ..Default::default() + }, + finish_reason: None, + logprobs: None, + }]; + chunks.push(chunk); + } } StreamPart::ToolInputDelta { id, delta, .. } => { // Ensure started (shouldn't happen without Start, but be safe). if let Some(c) = self.ensure_started() { chunks.push(c); } - let index = match self.tool_calls.get(id) { - Some(acc) => acc.index, + let index = match self.tool_calls.get_mut(id) { + Some(acc) => { + acc.arguments.push_str(delta); + acc.index + } None => { // Delta without Start — allocate a new index. let idx = self.next_tool_index; @@ -661,6 +695,7 @@ impl StreamState { index: idx, id: id.clone(), name: String::new(), + arguments: delta.clone(), }, ); self.tool_call_order.push(id.clone()); @@ -668,25 +703,27 @@ impl StreamState { } }; - let mut chunk = self.base_chunk(); - chunk.choices = vec![ChatCompletionChunkChoice { - index: 0, - delta: ChatCompletionDelta { - tool_calls: Some(vec![ChatCompletionChunkToolCall { - index, - id: None, - tool_type: None, - function: ChatCompletionChunkFunction { - name: None, - arguments: Some(delta.clone()), - }, - }]), - ..Default::default() - }, - finish_reason: None, - logprobs: None, - }]; - chunks.push(chunk); + if !self.defer_tool_calls { + let mut chunk = self.base_chunk(); + chunk.choices = vec![ChatCompletionChunkChoice { + index: 0, + delta: ChatCompletionDelta { + tool_calls: Some(vec![ChatCompletionChunkToolCall { + index, + id: None, + tool_type: None, + function: ChatCompletionChunkFunction { + name: None, + arguments: Some(delta.clone()), + }, + }]), + ..Default::default() + }, + finish_reason: None, + logprobs: None, + }]; + chunks.push(chunk); + } } StreamPart::ToolInputEnd { .. } => {} @@ -694,13 +731,92 @@ impl StreamState { tool_call_id, tool_name, input, + invalid, .. } => { // Complete tool call (e.g. from non-streaming-style providers). - // If already opened via ToolInputStart, skip; otherwise emit - // the full call in one chunk. - if self.tool_call_opened.contains(tool_call_id) { - // Already streamed — the arguments were sent via deltas. + // A provider may carry all input on its start frame and emit no + // deltas. In that case the final call is the first point where + // the OpenAI adapter can forward those arguments. + if self.defer_tool_calls { + if let Some(c) = self.ensure_started() { + chunks.push(c); + } + let index = if let Some(acc) = self.tool_calls.get_mut(tool_call_id) { + acc.name.clone_from(tool_name); + acc.arguments = parsed_tool_call_arguments(input, *invalid); + acc.index + } else { + let index = self.next_tool_index; + self.next_tool_index += 1; + self.tool_calls.insert( + tool_call_id.clone(), + ToolCallAccum { + index, + id: tool_call_id.clone(), + name: tool_name.clone(), + arguments: parsed_tool_call_arguments(input, *invalid), + }, + ); + self.tool_call_order.push(tool_call_id.clone()); + index + }; + self.tool_call_opened.insert(tool_call_id.clone()); + let arguments = self.tool_calls[tool_call_id].arguments.clone(); + + let mut chunk = self.base_chunk(); + chunk.choices = vec![ChatCompletionChunkChoice { + index: 0, + delta: ChatCompletionDelta { + tool_calls: Some(vec![ChatCompletionChunkToolCall { + index, + id: Some(tool_call_id.clone()), + tool_type: Some("function".to_string()), + function: ChatCompletionChunkFunction { + name: Some(tool_name.clone()), + arguments: Some(arguments), + }, + }]), + ..Default::default() + }, + finish_reason: None, + logprobs: None, + }]; + chunks.push(chunk); + } else if self.tool_call_opened.contains(tool_call_id) { + let full_arguments = parsed_tool_call_arguments(input, *invalid); + let missing_arguments = self + .tool_calls + .get(tool_call_id) + .and_then(|acc| full_arguments.strip_prefix(&acc.arguments)) + .unwrap_or_default() + .to_string(); + + if !missing_arguments.is_empty() { + let index = self.tool_calls[tool_call_id].index; + if let Some(acc) = self.tool_calls.get_mut(tool_call_id) { + acc.arguments.push_str(&missing_arguments); + } + let mut chunk = self.base_chunk(); + chunk.choices = vec![ChatCompletionChunkChoice { + index: 0, + delta: ChatCompletionDelta { + tool_calls: Some(vec![ChatCompletionChunkToolCall { + index, + id: None, + tool_type: None, + function: ChatCompletionChunkFunction { + name: None, + arguments: Some(missing_arguments), + }, + }]), + ..Default::default() + }, + finish_reason: None, + logprobs: None, + }]; + chunks.push(chunk); + } } else { if let Some(c) = self.ensure_started() { chunks.push(c); @@ -713,16 +829,13 @@ impl StreamState { index, id: tool_call_id.clone(), name: tool_name.clone(), + arguments: parsed_tool_call_arguments(input, *invalid), }, ); self.tool_call_order.push(tool_call_id.clone()); self.tool_call_opened.insert(tool_call_id.clone()); - let arguments = if input.is_null() { - "{}".to_string() - } else { - input.to_string() - }; + let arguments = self.tool_calls[tool_call_id].arguments.clone(); let mut chunk = self.base_chunk(); chunk.choices = vec![ChatCompletionChunkChoice { @@ -973,6 +1086,16 @@ fn now_unix() -> u64 { .unwrap_or(0) } +pub(crate) fn parsed_tool_call_arguments(input: &Value, invalid: Option) -> String { + match input { + // Core uses a string carrier to retain malformed input on invalid + // calls; emitting it verbatim avoids adding a second JSON layer. + Value::String(raw) if invalid == Some(true) => raw.clone(), + Value::Null => "{}".to_string(), + input => input.to_string(), + } +} + /// Generate a short random ID (24 hex chars, similar to OpenAI's chatcmpl IDs). fn random_id() -> String { // Use a simple counter + timestamp for deterministic-enough uniqueness. @@ -1292,6 +1415,17 @@ mod tests { id: "call_1".to_string(), provider_metadata: None, }), + Ok(StreamPart::ToolCall { + tool_call_id: "call_1".to_string(), + tool_name: "get_weather".to_string(), + input: json!({ "city": "Tokyo" }), + provider_executed: None, + dynamic: None, + thought_signature: None, + invalid: None, + error: None, + provider_metadata: None, + }), Ok(StreamPart::Finish { finish_reason: FinishReason { unified: FinishReasonUnified::ToolCalls, @@ -1357,6 +1491,67 @@ mod tests { assert_eq!(last.choices[0].finish_reason.as_deref(), Some("tool_calls")); } + #[tokio::test] + async fn test_stream_complete_call_backfills_arguments_absent_from_deltas() { + let parts: Vec> = vec![ + Ok(StreamPart::ToolInputStart { + id: "call_1".to_string(), + tool_name: "fetch".to_string(), + provider_executed: Some(true), + dynamic: None, + title: None, + provider_metadata: None, + }), + Ok(StreamPart::ToolInputEnd { + id: "call_1".to_string(), + provider_metadata: None, + }), + Ok(StreamPart::ToolCall { + tool_call_id: "call_1".to_string(), + tool_name: "fetch".to_string(), + input: json!({ "url": "https://example.com" }), + provider_executed: Some(true), + dynamic: None, + thought_signature: None, + invalid: None, + error: None, + provider_metadata: None, + }), + Ok(StreamPart::Finish { + finish_reason: FinishReason { + unified: FinishReasonUnified::Stop, + raw: None, + }, + usage: Usage::default(), + provider_metadata: None, + }), + ]; + + let result = to_chat_completion_stream( + Box::pin(futures::stream::iter(parts)), + "claude", + OpenAiStreamOptions::default(), + ); + let chunks = collect_stream(result).await; + let arguments = chunks + .iter() + .filter_map(|chunk| { + chunk + .choices + .first()? + .delta + .tool_calls + .as_ref()? + .first()? + .function + .arguments + .as_deref() + }) + .collect::(); + + assert_eq!(arguments, r#"{"url":"https://example.com"}"#); + } + #[tokio::test] async fn test_stream_multiple_tool_calls_index() { let parts: Vec> = vec![ diff --git a/aimux-core/src/tool.rs b/aimux-core/src/tool.rs index c7bded36..9117880a 100644 --- a/aimux-core/src/tool.rs +++ b/aimux-core/src/tool.rs @@ -354,20 +354,6 @@ fn contains_forbidden_prototype(value: &Value) -> bool { } } -/// The input echoed back in the assistant response message. Mirrors the AI -/// SDK's `toResponseMessages`: an invalid call whose retained input is not an -/// object becomes `{}` so the follow-up turn stays provider-acceptable. JSON -/// null survives, as upstream's `typeof part.input !== 'object'` keeps it. -pub(crate) fn response_message_input(tool_call: &ToolCall) -> Value { - if tool_call.invalid == Some(true) - && !(tool_call.input.is_object() || tool_call.input.is_null()) - { - Value::Object(serde_json::Map::new()) - } else { - tool_call.input.clone() - } -} - fn valid_tool_call(tool_call: RawToolCall, input: Value, dynamic: Option) -> ToolCall { ToolCall { tool_call_id: tool_call.tool_call_id, diff --git a/aimux-core/tests/contract_test.rs b/aimux-core/tests/contract_test.rs index e5dd94d7..d02d6451 100644 --- a/aimux-core/tests/contract_test.rs +++ b/aimux-core/tests/contract_test.rs @@ -4,8 +4,9 @@ //! matches the expected wire format. The same fixtures are used by Node/Python //! tests to ensure cross-language consistency. +use aimux_core::content::ContentPart; use aimux_core::generate::GenerateTextOptions; -use aimux_core::message::{ModelMessage, Role}; +use aimux_core::message::{MessageContent, ModelMessage, Role}; use aimux_core::options::{TimeoutConfiguration, ToolChoice}; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; @@ -100,6 +101,36 @@ fn role_wire_format() { assert_serialize(&Role::System, "\"system\"", "role_system"); } +#[test] +fn provider_executed_tool_transcript_message_wire_format() { + let expected = fixture_json("model_message_provider_executed_tool_transcript"); + let message: ModelMessage = serde_json::from_str(&expected).unwrap(); + let MessageContent::Parts(parts) = &message.content else { + panic!("expected multipart assistant message"); + }; + assert!(matches!( + &parts[0], + ContentPart::ToolCall { + provider_executed: Some(true), + .. + } + )); + assert!(matches!( + &parts[1], + ContentPart::ToolResult { + is_error: Some(false), + preliminary: Some(true), + dynamic: Some(true), + .. + } + )); + assert_serialize( + &message, + &expected, + "model_message_provider_executed_tool_transcript", + ); +} + #[test] fn finish_reason_unified_wire_format() { assert_serialize( diff --git a/aimux-core/tests/error_value_golden_test.rs b/aimux-core/tests/error_value_golden_test.rs index 6d8f9164..89dfe41f 100644 --- a/aimux-core/tests/error_value_golden_test.rs +++ b/aimux-core/tests/error_value_golden_test.rs @@ -139,6 +139,35 @@ fn error_value_snapshots_plain_variants() { }, r#"{"ToolCallRepair":{"original_error":{"NoSuchTool":{"tool_name":"weathr"}},"cause":{"Other":"repair model failed"}}}"#, ), + ( + AiMuxError::NoSuchTool { + tool_name: "forecast".into(), + available_tools: Some(vec!["weather".into(), "search".into()]), + }, + r#"{"NoSuchTool":{"tool_name":"forecast","available_tools":["weather","search"]}}"#, + ), + ( + AiMuxError::InvalidToolInput { + tool_name: "weather".into(), + tool_input: r#"{"city":7}"#.into(), + cause: "input does not match the schema".into(), + }, + r#"{"InvalidToolInput":{"tool_name":"weather","tool_input":"{\"city\":7}","cause":"input does not match the schema"}}"#, + ), + ( + AiMuxError::ToolCallRepair { + original_error: Box::new(AiMuxError::NoSuchTool { + tool_name: "forecast".into(), + available_tools: None, + }), + cause: Box::new(AiMuxError::InvalidToolInput { + tool_name: "weather".into(), + tool_input: "{".into(), + cause: "input is not valid JSON".into(), + }), + }, + r#"{"ToolCallRepair":{"original_error":{"NoSuchTool":{"tool_name":"forecast"}},"cause":{"InvalidToolInput":{"tool_name":"weather","tool_input":"{","cause":"input is not valid JSON"}}}}"#, + ), ( AiMuxError::InvalidArgument("bad arg".into()), r#"{"InvalidArgument":"bad arg"}"#, diff --git a/aimux-core/tests/response_messages_tool_test.rs b/aimux-core/tests/response_messages_tool_test.rs new file mode 100644 index 00000000..a2dba10f --- /dev/null +++ b/aimux-core/tests/response_messages_tool_test.rs @@ -0,0 +1,500 @@ +use aimux_core::error::AiMuxError; +use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; +use aimux_core::language_model::LanguageModel; +use aimux_core::message::{MessageContent, Role}; +use aimux_core::options::CallOptions; +use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::stream_part::StreamPart; +use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; +use async_trait::async_trait; +use serde_json::json; + +struct ProviderTranscriptModel; + +fn finish_reason() -> FinishReason { + FinishReason { + unified: FinishReasonUnified::Stop, + raw: Some("end_turn".to_string()), + } +} + +#[async_trait] +impl LanguageModel for ProviderTranscriptModel { + fn provider(&self) -> &str { + "mock" + } + + fn model_id(&self) -> &str { + "provider-transcript" + } + + async fn do_generate(&self, _options: &CallOptions) -> Result { + Ok(GenerateResult { + content: vec![ + GenerateContent::Text { + text: "before".to_string(), + provider_metadata: None, + }, + GenerateContent::ToolCall { + tool_call_id: "srv-1".to_string(), + tool_name: "server_search".to_string(), + input: json!(r#"{"query":"Rust"}"#), + provider_executed: Some(true), + dynamic: Some(true), + thought_signature: None, + provider_metadata: Some(json!({ + "mock": { "serverCallId": "wire-1" } + })), + }, + GenerateContent::ToolResult { + tool_call_id: "srv-1".to_string(), + tool_name: "server_search".to_string(), + result: json!({ "answer": "still running" }), + is_error: Some(false), + preliminary: Some(true), + dynamic: Some(true), + provider_metadata: Some(json!({ + "mock": { "serverResultId": "wire-preliminary-1" } + })), + }, + GenerateContent::ToolResult { + tool_call_id: "srv-1".to_string(), + tool_name: "server_search".to_string(), + result: json!({ "answer": 42 }), + is_error: Some(false), + preliminary: Some(false), + dynamic: Some(true), + provider_metadata: Some(json!({ + "mock": { "serverResultId": "wire-result-1" } + })), + }, + GenerateContent::Text { + text: "after".to_string(), + provider_metadata: None, + }, + ], + finish_reason: finish_reason(), + usage: Usage::default(), + warnings: vec![], + provider_metadata: None, + response: Default::default(), + request_body: None, + response_headers: None, + }) + } + + async fn do_stream(&self, _options: &CallOptions) -> Result { + let parts = vec![ + Ok(StreamPart::StreamStart { warnings: vec![] }), + Ok(StreamPart::TextDelta { + id: "text-1".to_string(), + delta: "before".to_string(), + provider_metadata: None, + }), + Ok(StreamPart::ToolCall { + tool_call_id: "srv-1".to_string(), + tool_name: "server_search".to_string(), + input: json!(r#"{"query":"Rust"}"#), + provider_executed: Some(true), + dynamic: Some(true), + thought_signature: None, + invalid: None, + error: None, + provider_metadata: Some(json!({ + "mock": { "serverCallId": "wire-1" } + })), + }), + Ok(StreamPart::ToolResult { + tool_call_id: "srv-1".to_string(), + tool_name: "server_search".to_string(), + result: json!({ "answer": "still running" }), + is_error: Some(false), + preliminary: Some(true), + dynamic: Some(true), + provider_metadata: Some(json!({ + "mock": { "serverResultId": "wire-preliminary-1" } + })), + }), + Ok(StreamPart::ToolResult { + tool_call_id: "srv-1".to_string(), + tool_name: "server_search".to_string(), + result: json!({ "answer": 42 }), + is_error: Some(false), + preliminary: Some(false), + dynamic: Some(true), + provider_metadata: Some(json!({ + "mock": { "serverResultId": "wire-result-1" } + })), + }), + Ok(StreamPart::TextDelta { + id: "text-2".to_string(), + delta: "after".to_string(), + provider_metadata: None, + }), + Ok(StreamPart::Finish { + finish_reason: finish_reason(), + usage: Usage::default(), + provider_metadata: None, + }), + ]; + Ok(StreamResult { + stream: Box::pin(futures::stream::iter(parts)), + request_body: None, + response_headers: None, + }) + } +} + +fn assert_provider_transcript(messages: &[aimux_core::message::ModelMessage]) { + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, Role::Assistant); + let MessageContent::Parts(parts) = &messages[0].content else { + panic!("expected assistant parts"); + }; + assert_eq!( + parts.len(), + 4, + "provider order must be preserved: {parts:?}" + ); + assert!(matches!( + &parts[0], + aimux_core::content::ContentPart::Text { text, .. } if text == "before" + )); + assert!(matches!( + &parts[1], + aimux_core::content::ContentPart::ToolCall { + tool_call_id, + tool_name, + input, + provider_executed: Some(true), + provider_options: Some(options), + .. + } if tool_call_id == "srv-1" + && tool_name == "server_search" + && input == &json!({ "query": "Rust" }) + && options["mock"]["serverCallId"] == "wire-1" + )); + assert!(matches!( + &parts[2], + aimux_core::content::ContentPart::ToolResult { + tool_call_id, + tool_name: Some(tool_name), + result, + is_error: Some(false), + preliminary: Some(false), + dynamic: Some(true), + provider_options: Some(options), + } if tool_call_id == "srv-1" + && tool_name == "server_search" + && result == &json!({ "answer": 42 }) + && options["mock"]["serverResultId"] == "wire-result-1" + )); + assert!(matches!( + &parts[3], + aimux_core::content::ContentPart::Text { text, .. } if text == "after" + )); +} + +#[tokio::test] +async fn generate_response_messages_keep_provider_tool_transcript() { + let result = generate_text( + &ProviderTranscriptModel, + "search", + GenerateTextOptions::default(), + ) + .await + .unwrap(); + + assert_provider_transcript(&result.response_messages); +} + +#[tokio::test] +async fn stream_response_messages_keep_provider_tool_transcript() { + let result = stream_text( + &ProviderTranscriptModel, + "search", + GenerateTextOptions::default(), + ) + .await + .unwrap() + .consume() + .await + .unwrap(); + + assert_provider_transcript(&result.response_messages); +} + +struct InvalidToolInputModel; + +#[async_trait] +impl LanguageModel for InvalidToolInputModel { + fn provider(&self) -> &str { + "mock" + } + + fn model_id(&self) -> &str { + "invalid-tool-input" + } + + async fn do_generate(&self, _options: &CallOptions) -> Result { + Ok(GenerateResult { + content: vec![GenerateContent::ToolCall { + tool_call_id: "bad-1".to_string(), + tool_name: "server_search".to_string(), + input: json!("{"), + provider_executed: Some(true), + dynamic: Some(true), + thought_signature: None, + provider_metadata: None, + }], + finish_reason: finish_reason(), + usage: Usage::default(), + warnings: vec![], + provider_metadata: None, + response: Default::default(), + request_body: None, + response_headers: None, + }) + } + + async fn do_stream(&self, _options: &CallOptions) -> Result { + unimplemented!() + } +} + +#[tokio::test] +async fn invalid_primitive_tool_input_is_not_replayed() { + let result = generate_text( + &InvalidToolInputModel, + "search", + GenerateTextOptions::default(), + ) + .await + .unwrap(); + assert_eq!(result.tool_calls[0].input, json!("{")); + assert_eq!(result.tool_calls[0].invalid, Some(true)); + + let MessageContent::Parts(parts) = &result.response_messages[0].content else { + panic!("expected assistant parts"); + }; + assert!(matches!( + &parts[0], + aimux_core::content::ContentPart::ToolCall { input, .. } if input == &json!({}) + )); +} + +struct EmptyModel; + +#[async_trait] +impl LanguageModel for EmptyModel { + fn provider(&self) -> &str { + "mock" + } + + fn model_id(&self) -> &str { + "empty" + } + + async fn do_generate(&self, _options: &CallOptions) -> Result { + Ok(GenerateResult { + content: vec![GenerateContent::Text { + text: String::new(), + provider_metadata: None, + }], + finish_reason: finish_reason(), + usage: Usage::default(), + warnings: vec![], + provider_metadata: None, + response: Default::default(), + request_body: None, + response_headers: None, + }) + } + + async fn do_stream(&self, _options: &CallOptions) -> Result { + unimplemented!() + } +} + +#[tokio::test] +async fn empty_generation_has_no_empty_assistant_response_message() { + let result = generate_text(&EmptyModel, "", GenerateTextOptions::default()) + .await + .unwrap(); + assert!(result.response_messages.is_empty()); +} + +struct ContentMetadataModel; + +#[async_trait] +impl LanguageModel for ContentMetadataModel { + fn provider(&self) -> &str { + "mock" + } + + fn model_id(&self) -> &str { + "content-metadata" + } + + async fn do_generate(&self, _options: &CallOptions) -> Result { + Ok(GenerateResult { + content: vec![ + GenerateContent::Text { + text: "answer".to_string(), + provider_metadata: Some(json!({ + "google": { "thoughtSignature": "text-generate" } + })), + }, + GenerateContent::Reasoning { + text: "think".to_string(), + provider_metadata: Some(json!({ + "anthropic": { "signature": "reason-generate" } + })), + }, + ], + finish_reason: finish_reason(), + usage: Usage::default(), + warnings: vec![], + provider_metadata: None, + response: Default::default(), + request_body: None, + response_headers: None, + }) + } + + async fn do_stream(&self, _options: &CallOptions) -> Result { + let parts = vec![ + Ok(StreamPart::TextStart { + id: "text-1".to_string(), + provider_metadata: Some(json!({ "mock": { "phase": "start" } })), + }), + Ok(StreamPart::TextDelta { + id: "text-1".to_string(), + delta: "answer".to_string(), + provider_metadata: Some(json!({ + "google": { "thoughtSignature": "text-delta" } + })), + }), + Ok(StreamPart::TextEnd { + id: "text-1".to_string(), + provider_metadata: None, + }), + Ok(StreamPart::ReasoningStart { + id: "reason-1".to_string(), + provider_metadata: Some(json!({ + "anthropic": { "signature": "reason-start" } + })), + }), + Ok(StreamPart::ReasoningDelta { + id: "reason-1".to_string(), + delta: "think".to_string(), + provider_metadata: None, + }), + Ok(StreamPart::ReasoningEnd { + id: "reason-1".to_string(), + provider_metadata: None, + }), + Ok(StreamPart::ReasoningStart { + id: "reason-2".to_string(), + provider_metadata: None, + }), + Ok(StreamPart::ReasoningDelta { + id: "reason-2".to_string(), + delta: "again".to_string(), + provider_metadata: Some(json!({ + "anthropic": { "signature": "reason-delta" } + })), + }), + Ok(StreamPart::ReasoningEnd { + id: "reason-2".to_string(), + provider_metadata: None, + }), + Ok(StreamPart::Finish { + finish_reason: finish_reason(), + usage: Usage::default(), + provider_metadata: None, + }), + ]; + Ok(StreamResult { + stream: Box::pin(futures::stream::iter(parts)), + request_body: None, + response_headers: None, + }) + } +} + +#[tokio::test] +async fn generate_response_messages_keep_text_and_reasoning_metadata() { + let result = generate_text( + &ContentMetadataModel, + "metadata", + GenerateTextOptions::default(), + ) + .await + .unwrap(); + let MessageContent::Parts(parts) = &result.response_messages[0].content else { + panic!("expected assistant parts"); + }; + assert!(matches!( + &parts[0], + aimux_core::content::ContentPart::Text { + text, + provider_options: Some(options), + } if text == "answer" && options["google"]["thoughtSignature"] == "text-generate" + )); + assert!(matches!( + &parts[1], + aimux_core::content::ContentPart::Reasoning { + text, + signature: Some(signature), + provider_options: Some(options), + } if text == "think" + && signature == "reason-generate" + && options["anthropic"]["signature"] == "reason-generate" + )); +} + +#[tokio::test] +async fn stream_response_messages_keep_latest_segment_metadata() { + let result = stream_text( + &ContentMetadataModel, + "metadata", + GenerateTextOptions::default(), + ) + .await + .unwrap() + .consume() + .await + .unwrap(); + let MessageContent::Parts(parts) = &result.response_messages[0].content else { + panic!("expected assistant parts"); + }; + assert_eq!(parts.len(), 3); + assert!(matches!( + &parts[0], + aimux_core::content::ContentPart::Text { + text, + provider_options: Some(options), + } if text == "answer" && options["google"]["thoughtSignature"] == "text-delta" + )); + assert!(matches!( + &parts[1], + aimux_core::content::ContentPart::Reasoning { + text, + signature: Some(signature), + provider_options: Some(options), + } if text == "think" + && signature == "reason-start" + && options["anthropic"]["signature"] == "reason-start" + )); + assert!(matches!( + &parts[2], + aimux_core::content::ContentPart::Reasoning { + text, + signature: Some(signature), + provider_options: Some(options), + } if text == "again" + && signature == "reason-delta" + && options["anthropic"]["signature"] == "reason-delta" + )); +} diff --git a/aimux-core/tests/tool_input_test.rs b/aimux-core/tests/tool_input_test.rs index e9a1cef8..83e884b9 100644 --- a/aimux-core/tests/tool_input_test.rs +++ b/aimux-core/tests/tool_input_test.rs @@ -2,8 +2,11 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; +use aimux_core::generate::{ + GenerateTextOptions, generate_text, generate_text_as_openai, stream_text, stream_text_as_openai, +}; use aimux_core::language_model::LanguageModel; +use aimux_core::openai_output::OpenAiStreamOptions; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; use aimux_core::stream_part::StreamPart; @@ -296,7 +299,33 @@ fn weather_tool_schema() -> serde_json::Value { }) } -struct RawToolModel; +struct RawToolModel { + tool_name: &'static str, + input: &'static str, + leading_text: bool, + stream_input: bool, +} + +impl RawToolModel { + fn new(input: &'static str) -> Self { + Self { + tool_name: "weather", + input, + leading_text: false, + stream_input: false, + } + } + + fn named(mut self, tool_name: &'static str) -> Self { + self.tool_name = tool_name; + self + } + + fn with_streamed_input(mut self) -> Self { + self.stream_input = true; + self + } +} #[async_trait] impl LanguageModel for RawToolModel { @@ -312,8 +341,8 @@ impl LanguageModel for RawToolModel { Ok(GenerateResult { content: vec![GenerateContent::ToolCall { tool_call_id: "call-1".into(), - tool_name: "weather".into(), - input: json!(r#"{"city":"Singapore"}"#), + tool_name: self.tool_name.into(), + input: json!(self.input), provider_executed: None, dynamic: None, thought_signature: None, @@ -333,29 +362,58 @@ impl LanguageModel for RawToolModel { } async fn do_stream(&self, _options: &CallOptions) -> Result { - Ok(StreamResult { - stream: Box::pin(futures::stream::iter([ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::ToolCall { - tool_call_id: "call-1".into(), - tool_name: "weather".into(), - input: json!(r#"{"city":"Singapore"}"#), + let mut parts = vec![Ok(StreamPart::StreamStart { warnings: vec![] })]; + if self.leading_text { + parts.push(Ok(StreamPart::TextDelta { + id: "text-1".into(), + delta: "ready".into(), + provider_metadata: None, + })); + } + if self.stream_input { + parts.extend([ + Ok(StreamPart::ToolInputStart { + id: "call-1".into(), + tool_name: self.tool_name.into(), provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, + title: None, + provider_metadata: None, + }), + Ok(StreamPart::ToolInputDelta { + id: "call-1".into(), + delta: self.input.into(), provider_metadata: None, }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: Some("tool_calls".into()), - }, - usage: Usage::default(), + Ok(StreamPart::ToolInputEnd { + id: "call-1".into(), provider_metadata: None, }), - ])), + ]); + } + parts.extend([ + Ok(StreamPart::ToolCall { + tool_call_id: "call-1".into(), + tool_name: self.tool_name.into(), + input: json!(self.input), + provider_executed: None, + dynamic: None, + thought_signature: None, + invalid: None, + error: None, + provider_metadata: None, + }), + Ok(StreamPart::Finish { + finish_reason: FinishReason { + unified: FinishReasonUnified::ToolCalls, + raw: Some("tool_calls".into()), + }, + usage: Usage::default(), + provider_metadata: None, + }), + ]); + Ok(StreamResult { + stream: Box::pin(futures::stream::iter(parts)), request_body: None, response_headers: None, }) @@ -364,8 +422,9 @@ impl LanguageModel for RawToolModel { #[tokio::test] async fn generate_text_parses_provider_raw_input_at_the_core_boundary() { + let model = RawToolModel::new(r#"{"city":"Singapore"}"#); let result = generate_text( - &RawToolModel, + &model, "weather", GenerateTextOptions { tools: Some(vec![weather_tool()]), @@ -386,8 +445,9 @@ async fn generate_text_parses_provider_raw_input_at_the_core_boundary() { #[tokio::test] async fn stream_text_parses_provider_raw_input_at_the_core_boundary() { + let model = RawToolModel::new(r#"{"city":"Singapore"}"#); let mut result = stream_text( - &RawToolModel, + &model, "weather", GenerateTextOptions { tools: Some(vec![weather_tool()]), @@ -406,3 +466,163 @@ async fn stream_text_parses_provider_raw_input_at_the_core_boundary() { } panic!("expected a parsed tool call"); } + +#[tokio::test] +async fn openai_outputs_preserve_invalid_raw_tool_arguments() { + let raw_input = r#"{"city":"Singapore"#; + let model = RawToolModel::new(raw_input); + let completion = generate_text_as_openai( + &model, + "weather", + GenerateTextOptions { + tools: Some(vec![weather_tool()]), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!( + completion.choices[0].message.tool_calls.as_ref().unwrap()[0] + .function + .arguments, + raw_input + ); + + let result = stream_text_as_openai( + &model, + "weather", + GenerateTextOptions { + tools: Some(vec![weather_tool()]), + ..Default::default() + }, + OpenAiStreamOptions::default(), + ) + .await + .unwrap(); + let chunks = result + .stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .unwrap(); + + let arguments = chunks + .iter() + .flat_map(|chunk| &chunk.choices) + .filter_map(|choice| choice.delta.tool_calls.as_ref()) + .flatten() + .find_map(|tool_call| tool_call.function.arguments.as_deref()) + .expect("expected complete tool call arguments"); + assert_eq!(arguments, raw_input); +} + +#[tokio::test] +async fn openai_outputs_use_repaired_tool_name_and_input_for_complete_calls() { + let repair = ToolCallRepair::new(|context| async move { + Ok(Some(RawToolCall { + tool_name: "weather".into(), + input: r#"{"city":"Singapore","days":3}"#.into(), + ..context.tool_call + })) + }); + let model = RawToolModel::new(r#"{"place":"Singapore"}"#) + .named("forecast") + .with_streamed_input(); + let options = GenerateTextOptions { + tools: Some(vec![weather_tool()]), + repair_tool_call: Some(repair.clone()), + ..Default::default() + }; + + let completion = generate_text_as_openai(&model, "weather", options) + .await + .unwrap(); + let non_stream_call = &completion.choices[0].message.tool_calls.as_ref().unwrap()[0]; + + let stream = stream_text_as_openai( + &model, + "weather", + GenerateTextOptions { + tools: Some(vec![weather_tool()]), + repair_tool_call: Some(repair), + ..Default::default() + }, + OpenAiStreamOptions::default(), + ) + .await + .unwrap(); + let chunks = stream + .stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .unwrap(); + let stream_calls = chunks + .iter() + .flat_map(|chunk| &chunk.choices) + .filter_map(|choice| choice.delta.tool_calls.as_ref()) + .flatten() + .collect::>(); + assert_eq!( + stream_calls.len(), + 1, + "provider input frames must stay buffered when repair can replace them" + ); + let stream_call = stream_calls[0]; + + assert_eq!(non_stream_call.function.name, "weather"); + assert_eq!( + non_stream_call.function.arguments, + r#"{"city":"Singapore","days":3}"# + ); + assert_eq!( + stream_call.function.name.as_deref(), + Some(non_stream_call.function.name.as_str()) + ); + assert_eq!( + stream_call.function.arguments.as_deref(), + Some(non_stream_call.function.arguments.as_str()) + ); +} + +#[tokio::test] +async fn openai_stream_without_repair_preserves_provider_tool_input_deltas() { + let model = RawToolModel::new(r#"{"city":"Singapore"}"#).with_streamed_input(); + let result = stream_text_as_openai( + &model, + "weather", + GenerateTextOptions { + tools: Some(vec![weather_tool()]), + ..Default::default() + }, + OpenAiStreamOptions::default(), + ) + .await + .unwrap(); + let chunks = result + .stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .unwrap(); + let tool_calls = chunks + .iter() + .flat_map(|chunk| &chunk.choices) + .filter_map(|choice| choice.delta.tool_calls.as_ref()) + .flatten() + .collect::>(); + + assert_eq!(tool_calls.len(), 2); + assert_eq!(tool_calls[0].id.as_deref(), Some("call-1")); + assert_eq!(tool_calls[0].function.name.as_deref(), Some("weather")); + assert_eq!(tool_calls[0].function.arguments.as_deref(), Some("")); + assert_eq!(tool_calls[1].id, None); + assert_eq!(tool_calls[1].function.name, None); + assert_eq!( + tool_calls[1].function.arguments.as_deref(), + Some(r#"{"city":"Singapore"}"#) + ); +} diff --git a/aimux-providers/src/anthropic/convert.rs b/aimux-providers/src/anthropic/convert.rs index 961d3cf5..c6e98ca5 100644 --- a/aimux-providers/src/anthropic/convert.rs +++ b/aimux-providers/src/anthropic/convert.rs @@ -199,6 +199,7 @@ pub fn convert_prompt_to_anthropic_full_with_tools( if let Some(block) = convert_part_to_anthropic( p, send_reasoning, + tool_names, &mut betas, &mut warnings, &mut validator, @@ -237,6 +238,7 @@ pub fn convert_prompt_to_anthropic_full_with_tools( convert_part_to_anthropic( p, send_reasoning, + tool_names, &mut betas, &mut warnings, &mut validator, @@ -350,6 +352,7 @@ fn top_level_media_type(media_type: &str) -> &str { fn convert_part_to_anthropic( part: &ContentPart, send_reasoning: bool, + tool_names: &ToolNameMapping, betas: &mut BTreeSet, warnings: &mut Vec, validator: &mut CacheControlValidator, @@ -505,9 +508,91 @@ fn convert_part_to_anthropic( tool_call_id, tool_name, input, + provider_executed, provider_options, .. } => { + let cc = resolve_cc(validator, provider_options.as_ref()); + + if *provider_executed == Some(true) { + let provider_name = tool_names.to_provider_tool_name(tool_name); + let anthropic_options = provider_options + .as_ref() + .and_then(|options| options.get("anthropic")); + + if anthropic_options + .and_then(|options| options.get("type")) + .and_then(Value::as_str) + == Some("mcp-tool-use") + { + let Some(server_name) = anthropic_options + .and_then(|options| options.get("serverName")) + .and_then(Value::as_str) + else { + warnings.push(Warning::Other { + message: "mcp tool use server name is required and must be a string" + .to_string(), + }); + return Ok(None); + }; + return Ok(Some(apply_cc( + json!({ + "type": "mcp_tool_use", + "id": tool_call_id, + "name": tool_name, + "input": input, + "server_name": server_name, + }), + cc, + ))); + } + + let (server_name, server_input) = if provider_name == "code_execution" { + let input_type = input.get("type").and_then(Value::as_str); + match input_type { + Some("bash_code_execution" | "text_editor_code_execution") => { + let mut value = input.clone(); + if let Some(object) = value.as_object_mut() { + object.remove("type"); + } + (input_type.unwrap().to_string(), value) + } + Some("programmatic-tool-call") => { + let mut value = input.clone(); + if let Some(object) = value.as_object_mut() { + object.remove("type"); + } + ("code_execution".to_string(), value) + } + _ => ("code_execution".to_string(), input.clone()), + } + } else if matches!( + provider_name, + "web_fetch" | "web_search" | "tool_search_tool_regex" | "tool_search_tool_bm25" + ) { + (provider_name.to_string(), input.clone()) + } else if provider_name == "advisor" { + ("advisor".to_string(), json!({})) + } else { + warnings.push(Warning::Other { + message: format!( + "provider executed tool call for tool {tool_name} is not supported" + ), + }); + return Ok(None); + }; + + return Ok(Some(apply_cc( + json!({ + "type": "server_tool_use", + "id": tool_call_id, + "name": server_name, + "input": server_input, + }), + cc, + ))); + } + // Anthropic requires `input` to be a JSON object. The SDK wraps any // non-object (e.g. malformed JSON the model produced) in // `{ "rawInvalidInput": }`. @@ -516,16 +601,31 @@ fn convert_part_to_anthropic( } else { json!({ "rawInvalidInput": input }) }; - let cc = resolve_cc(validator, provider_options.as_ref()); - apply_cc( - json!({ - "type": "tool_use", - "id": tool_call_id, - "name": tool_name, - "input": input_val, - }), - cc, - ) + let caller = provider_options + .as_ref() + .and_then(|options| options.get("anthropic")) + .and_then(|anthropic| anthropic.get("caller")) + .and_then(|caller| { + let caller_type = caller.get("type")?.as_str()?; + match caller_type { + "code_execution_20250825" | "code_execution_20260120" => { + let tool_id = caller.get("toolId")?.as_str()?; + Some(json!({ "type": caller_type, "tool_id": tool_id })) + } + "direct" => Some(json!({ "type": "direct" })), + _ => None, + } + }); + let mut block = json!({ + "type": "tool_use", + "id": tool_call_id, + "name": tool_names.to_provider_tool_name(tool_name), + "input": input_val, + }); + if let Some(caller) = caller { + block["caller"] = caller; + } + apply_cc(block, cc) } ContentPart::ToolResult { diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index 8a7569bb..4f79f3b9 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -31,7 +31,7 @@ use serde_json::{Value, json}; use super::convert::parse_stop_reason; use super::tool_name_mapping::ToolNameMapping; -use super::types::{AnthropicResponse, ContentBlock, StreamErrorData, StreamEvent}; +use super::types::{AnthropicResponse, ContentBlock, StreamErrorData, StreamEvent, ToolCallCaller}; pub(crate) fn anthropic_stream_error( error: &StreamErrorData, @@ -284,12 +284,76 @@ fn map_advisor_result(payload: &Value) -> (Value, Option) { } } -/// Which `tool_search_*` provider name this call registered. +/// Anthropic's 2025 code-execution tool exposes its bash and text-editor +/// operations as distinct wire names, but both belong to the caller's single +/// `code_execution` provider tool. +pub(crate) fn server_tool_provider_name(name: &str) -> &str { + match name { + "text_editor_code_execution" | "bash_code_execution" => "code_execution", + _ => name, + } +} + +pub(crate) fn normalized_server_tool_input(name: &str, input: &Value) -> Value { + let input_type = if matches!(name, "text_editor_code_execution" | "bash_code_execution") { + Some(name) + } else if name == "code_execution" && input.get("code").is_some() && input.get("type").is_none() + { + Some("programmatic-tool-call") + } else { + None + }; + let (Some(input_type), Value::Object(input)) = (input_type, input) else { + return input.clone(); + }; + + let mut normalized = serde_json::Map::new(); + normalized.insert("type".to_string(), Value::String(input_type.to_string())); + normalized.extend(input.clone()); + Value::Object(normalized) +} + +pub(crate) fn initial_tool_input(input: &Value) -> String { + match input { + Value::Object(object) if object.is_empty() => String::new(), + _ => input.to_string(), + } +} + +pub(crate) fn tool_call_caller_metadata(caller: Option<&ToolCallCaller>) -> Option { + let caller = match caller? { + ToolCallCaller::CodeExecution20250825 { tool_id } => json!({ + "type": "code_execution_20250825", + "toolId": tool_id, + }), + ToolCallCaller::CodeExecution20260120 { tool_id } => json!({ + "type": "code_execution_20260120", + "toolId": tool_id, + }), + ToolCallCaller::Direct => json!({ "type": "direct" }), + }; + Some(json!({ "anthropic": { "caller": caller } })) +} + +fn is_tool_search_provider_name(name: &str) -> bool { + matches!(name, "tool_search_tool_regex" | "tool_search_tool_bm25") +} + +/// Resolve the provider tool behind a shared `tool_search_tool_result` block. /// -/// Anthropic reports both regex and bm25 searches through one result block, so -/// upstream probes which variant the caller renamed and defaults to regex -/// (:1337-1352). -fn tool_search_provider_name(names: &ToolNameMapping) -> &'static str { +/// When the matching call is present, its id is authoritative. The name-based +/// fallback preserves Anthropic's deferred-result behavior, where the call may +/// have appeared in an earlier response. +fn tool_search_provider_name<'a>( + names: &ToolNameMapping, + provider_name_for_call: Option<&'a str>, +) -> &'a str { + if let Some(provider_name) = provider_name_for_call + && is_tool_search_provider_name(provider_name) + { + return provider_name; + } + if names.to_custom_tool_name("tool_search_tool_bm25") != "tool_search_tool_bm25" { "tool_search_tool_bm25" } else { @@ -306,6 +370,7 @@ pub(crate) fn stream_parts_for_result_block( block: &ContentBlock, names: &ToolNameMapping, mcp_tool_calls: &HashMap, + server_tool_calls: &HashMap, ) -> Vec { let tool_result = |tool_name: String, (result, is_error): (Value, Option), tool_use_id: &str| { @@ -396,7 +461,10 @@ pub(crate) fn stream_parts_for_result_block( content, } => vec![tool_result( names - .to_custom_tool_name(tool_search_provider_name(names)) + .to_custom_tool_name(tool_search_provider_name( + names, + server_tool_calls.get(tool_use_id).map(String::as_str), + )) .to_string(), map_tool_search_result(content), tool_use_id, @@ -447,18 +515,24 @@ pub(crate) fn parse_anthropic_content( names: &ToolNameMapping, ) -> Vec { let mut content = Vec::new(); - // `mcp_tool_result` inherits the name and server of the `mcp_tool_use` it - // answers, so index those first (upstream keeps the same lookup table). + // Result blocks inherit information from their matching calls. Index the + // complete response first so ordering does not affect non-stream parsing. let mut mcp_tool_calls: HashMap<&str, (&str, &str)> = HashMap::new(); + let mut server_tool_calls: HashMap<&str, &str> = HashMap::new(); for block in blocks { - if let ContentBlock::McpToolUse { - id, - name, - server_name, - .. - } = block - { - mcp_tool_calls.insert(id.as_str(), (name.as_str(), server_name.as_str())); + match block { + ContentBlock::McpToolUse { + id, + name, + server_name, + .. + } => { + mcp_tool_calls.insert(id.as_str(), (name.as_str(), server_name.as_str())); + } + ContentBlock::ServerToolUse { id, name, .. } if is_tool_search_provider_name(name) => { + server_tool_calls.insert(id.as_str(), name.as_str()); + } + _ => {} } } @@ -470,15 +544,20 @@ pub(crate) fn parse_anthropic_content( provider_metadata: None, }); } - ContentBlock::ToolUse { id, name, input } => { + ContentBlock::ToolUse { + id, + name, + input, + caller, + } => { content.push(GenerateContent::ToolCall { tool_call_id: id.clone(), - tool_name: name.clone(), + tool_name: names.to_custom_tool_name(name).to_string(), input: Value::String(input.to_string()), provider_executed: None, dynamic: None, thought_signature: None, - provider_metadata: None, + provider_metadata: tool_call_caller_metadata(caller.as_ref()), }); } ContentBlock::Thinking { @@ -495,12 +574,15 @@ pub(crate) fn parse_anthropic_content( // Provider-executed (server-side) tool calls are surfaced as tool // calls so they round-trip on follow-up turns. ContentBlock::ServerToolUse { id, name, input } => { + let provider_name = server_tool_provider_name(name); content.push(GenerateContent::ToolCall { tool_call_id: id.clone(), - tool_name: name.clone(), - input: Value::String(input.to_string()), + tool_name: names.to_custom_tool_name(provider_name).to_string(), + input: Value::String(normalized_server_tool_input(name, input).to_string()), provider_executed: Some(true), - dynamic: None, + dynamic: (provider_name == "code_execution" + && names.mark_code_execution_dynamic()) + .then_some(true), thought_signature: None, provider_metadata: None, }); @@ -644,11 +726,13 @@ pub(crate) fn parse_anthropic_content( content: payload, } => { let (result, is_error) = map_tool_search_result(payload); + let provider_name = tool_search_provider_name( + names, + server_tool_calls.get(tool_use_id.as_str()).copied(), + ); content.push(GenerateContent::ToolResult { tool_call_id: tool_use_id.clone(), - tool_name: names - .to_custom_tool_name(tool_search_provider_name(names)) - .to_string(), + tool_name: names.to_custom_tool_name(provider_name).to_string(), result, is_error, preliminary: None, @@ -783,6 +867,12 @@ enum BlockState { id: String, name: String, accumulated_json: String, + provider_executed: Option, + dynamic: Option, + provider_tool_name: Option, + provider_tool_input_type: Option, + provider_metadata: Option, + first_delta: bool, }, Thinking { started: bool, @@ -858,6 +948,9 @@ pub(crate) async fn anthropic_stream_core( // id → (tool name, server name), so `mcp_tool_result` can inherit them // from the `mcp_tool_use` it answers. let mut mcp_tool_calls: HashMap = HashMap::new(); + // tool_use_id → provider tool name. Both tool-search variants share + // one result block type, so the id is required to disambiguate aliases. + let mut server_tool_calls: HashMap = HashMap::new(); while let Some(event) = sse.next().await { match event { @@ -893,10 +986,19 @@ pub(crate) async fn anthropic_stream_core( }, ); } - ContentBlock::ToolUse { id, name, .. } => { + ContentBlock::ToolUse { + id, + name, + input, + caller, + } => { + let custom_name = tool_names + .to_custom_tool_name(&name) + .to_string(); + let initial_input = initial_tool_input(&input); yield Ok(StreamPart::ToolInputStart { id: id.clone(), - tool_name: name.clone(), + tool_name: custom_name.clone(), provider_executed: None, dynamic: None, title: None, @@ -904,23 +1006,56 @@ pub(crate) async fn anthropic_stream_core( }); blocks.insert(index, BlockState::ToolUse { id, - name, - accumulated_json: String::new(), + name: custom_name, + first_delta: initial_input.is_empty(), + accumulated_json: initial_input, + provider_executed: None, + dynamic: None, + provider_tool_name: None, + provider_tool_input_type: None, + provider_metadata: tool_call_caller_metadata(caller.as_ref()), }); } - // Server-side tool use — emit as ToolCall. + // Server-side tool use follows the same input + // lifecycle as client tools because some code + // execution inputs arrive entirely via deltas. ContentBlock::ServerToolUse { id, name, input } => { - yield Ok(StreamPart::ToolCall { - tool_call_id: id.clone(), - tool_name: tool_names - .to_custom_tool_name(&name) - .to_string(), - input: Value::String(input.to_string()), + if is_tool_search_provider_name(&name) { + server_tool_calls.insert(id.clone(), name.clone()); + } + let provider_name = server_tool_provider_name(&name); + let custom_name = tool_names + .to_custom_tool_name(provider_name) + .to_string(); + let dynamic = (provider_name == "code_execution" + && tool_names.mark_code_execution_dynamic()) + .then_some(true); + let initial_input = initial_tool_input(&input); + yield Ok(StreamPart::ToolInputStart { + id: id.clone(), + tool_name: custom_name.clone(), provider_executed: Some(true), - dynamic: None, - thought_signature: None, - invalid: None, - error: None, + dynamic, + title: None, + provider_metadata: None, + }); + blocks.insert(index, BlockState::ToolUse { + id, + name: custom_name, + first_delta: initial_input.is_empty(), + accumulated_json: initial_input, + provider_executed: Some(true), + dynamic, + provider_tool_name: Some(provider_name.to_string()), + provider_tool_input_type: match name.as_str() { + "text_editor_code_execution" | "bash_code_execution" => { + Some(name) + } + "code_execution" => { + Some("programmatic-tool-call".to_string()) + } + _ => None, + }, provider_metadata: None, }); } @@ -972,6 +1107,7 @@ pub(crate) async fn anthropic_stream_core( &other, &tool_names, &mcp_tool_calls, + &server_tool_calls, ) { yield Ok(part); } @@ -1012,21 +1148,37 @@ pub(crate) async fn anthropic_stream_core( // leading `input_json_delta` with // `partial_json: ""`) are skipped, matching the // TS SDK. - let delta_id: Option = match blocks.get_mut(&index) { - Some(BlockState::ToolUse { - id, - accumulated_json, - .. - }) if !partial.is_empty() => { - accumulated_json.push_str(&partial); - Some(id.clone()) - } - _ => None, - }; - if let Some(id) = delta_id { + let delta_event: Option<(String, String)> = + match blocks.get_mut(&index) { + Some(BlockState::ToolUse { + id, + accumulated_json, + provider_tool_input_type, + first_delta, + .. + }) if !partial.is_empty() => { + let emitted_delta = if *first_delta { + if let Some(input_type) = provider_tool_input_type { + format!( + "{{\"type\": \"{input_type}\",{}", + partial.strip_prefix('{').unwrap_or(&partial) + ) + } else { + partial + } + } else { + partial + }; + accumulated_json.push_str(&emitted_delta); + *first_delta = false; + Some((id.clone(), emitted_delta)) + } + _ => None, + }; + if let Some((id, delta)) = delta_event { yield Ok(StreamPart::ToolInputDelta { id, - delta: partial, + delta, provider_metadata: None, }); } @@ -1110,29 +1262,45 @@ pub(crate) async fn anthropic_stream_core( BlockState::ToolUse { id, name, - accumulated_json, + mut accumulated_json, + provider_executed, + dynamic, + provider_tool_name, + provider_tool_input_type, + provider_metadata, + .. } => { yield Ok(StreamPart::ToolInputEnd { id: id.clone(), provider_metadata: None, }); - // Empty input normalizes to "{}" per - // the upstream provider. - let input = Value::String(if accumulated_json.is_empty() { - "{}".to_string() - } else { - accumulated_json - }); + if accumulated_json.is_empty() { + accumulated_json = "{}".to_string(); + } + if provider_tool_name.as_deref() == Some("code_execution") + && let Ok(parsed) = serde_json::from_str::(&accumulated_json) + { + let wire_name = match provider_tool_input_type.as_deref() { + Some(name @ ("text_editor_code_execution" | "bash_code_execution")) => name, + _ => "code_execution", + }; + accumulated_json = normalized_server_tool_input( + wire_name, + &parsed, + ) + .to_string(); + } + let input = Value::String(accumulated_json); yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: name, input, - provider_executed: None, - dynamic: None, + provider_executed, + dynamic, thought_signature: None, invalid: None, error: None, - provider_metadata: None, + provider_metadata, }); } } diff --git a/aimux-providers/src/anthropic/tool_name_mapping.rs b/aimux-providers/src/anthropic/tool_name_mapping.rs index d398abb0..adb61152 100644 --- a/aimux-providers/src/anthropic/tool_name_mapping.rs +++ b/aimux-providers/src/anthropic/tool_name_mapping.rs @@ -57,6 +57,7 @@ fn provider_tool_name(id: &str) -> Option<&'static str> { pub struct ToolNameMapping { custom_to_provider: HashMap, provider_to_custom: HashMap, + mark_code_execution_dynamic: bool, } impl ToolNameMapping { @@ -64,18 +65,27 @@ impl ToolNameMapping { #[must_use] pub fn new(tools: Option<&[Tool]>) -> Self { let mut mapping = ToolNameMapping::default(); + let mut has_web_tool_20260209 = false; + let mut has_code_execution = false; for tool in tools.unwrap_or(&[]) { - if let Tool::Provider(pt) = tool - && let Some(provider_name) = provider_tool_name(&pt.id) - { - mapping - .custom_to_provider - .insert(pt.name.clone(), provider_name.to_string()); - mapping - .provider_to_custom - .insert(provider_name.to_string(), pt.name.clone()); + if let Tool::Provider(pt) = tool { + has_web_tool_20260209 |= matches!( + pt.id.as_str(), + "anthropic.web_search_20260209" | "anthropic.web_fetch_20260209" + ); + has_code_execution |= pt.id.starts_with("anthropic.code_execution_"); + + if let Some(provider_name) = provider_tool_name(&pt.id) { + mapping + .custom_to_provider + .insert(pt.name.clone(), provider_name.to_string()); + mapping + .provider_to_custom + .insert(provider_name.to_string(), pt.name.clone()); + } } } + mapping.mark_code_execution_dynamic = has_web_tool_20260209 && !has_code_execution; mapping } @@ -94,6 +104,12 @@ impl ToolNameMapping { .map(String::as_str) .unwrap_or(provider_name) } + + /// Whether code execution may be invoked implicitly by a 2026 web tool. + #[must_use] + pub fn mark_code_execution_dynamic(&self) -> bool { + self.mark_code_execution_dynamic + } } #[cfg(test)] @@ -151,6 +167,21 @@ mod tests { } } + #[test] + fn marks_implicit_code_execution_dynamic_only_for_2026_web_tools() { + let web_only = vec![provider_tool("anthropic.web_search_20260209", "search")]; + assert!(ToolNameMapping::new(Some(&web_only)).mark_code_execution_dynamic()); + + let web_and_code = vec![ + provider_tool("anthropic.web_search_20260209", "search"), + provider_tool("anthropic.code_execution_20260120", "runCode"), + ]; + assert!(!ToolNameMapping::new(Some(&web_and_code)).mark_code_execution_dynamic()); + + let old_web = vec![provider_tool("anthropic.web_search_20250305", "search")]; + assert!(!ToolNameMapping::new(Some(&old_web)).mark_code_execution_dynamic()); + } + /// Every provider tool id this crate can build a request for must also be /// mappable, and to the same name the request body uses. The two tables are /// written out separately, so without this they drift silently: a renamed diff --git a/aimux-providers/src/anthropic/types.rs b/aimux-providers/src/anthropic/types.rs index b7143d9c..50b018ea 100644 --- a/aimux-providers/src/anthropic/types.rs +++ b/aimux-providers/src/anthropic/types.rs @@ -22,6 +22,18 @@ pub struct AnthropicResponse { pub context_management: Option, } +/// Origin of an Anthropic programmatic tool call. +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum ToolCallCaller { + #[serde(rename = "code_execution_20250825")] + CodeExecution20250825 { tool_id: String }, + #[serde(rename = "code_execution_20260120")] + CodeExecution20260120 { tool_id: String }, + #[serde(rename = "direct")] + Direct, +} + #[derive(Debug, Deserialize)] #[serde(tag = "type")] pub enum ContentBlock { @@ -32,6 +44,8 @@ pub enum ContentBlock { id: String, name: String, input: Value, + #[serde(default)] + caller: Option, }, /// Anthropic extended-thinking block. Carries the reasoning text and an /// opaque `signature` (required to send the thinking block back in a diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index 7d90f782..12856558 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -9,12 +9,14 @@ //! - Assistant messages become `role: "model"`. //! - Tool results become `functionResponse` parts inside a `role: "user"` //! message (Gemini has no `tool` role). -//! - Tool calls in assistant messages become `functionCall` parts. +//! - Tool calls in assistant messages become `functionCall` parts, except +//! provider-executed server transcripts which retain their native wire +//! representation. //! //! We model the variable-shape content parts as `serde_json::Value` to keep //! the surface area small — the TS SDK uses a tagged union, but the only -//! fields we actually read back are `text`, `functionCall`, and -//! `functionResponse`, all of which we already produce ourselves. +//! fields we actually read back are `text`, function tool parts, and native +//! provider-executed tool parts. use aimux_core::content::ContentPart; use aimux_core::language_model_message::LanguageModelPrompt; @@ -26,6 +28,22 @@ use aimux_core::types::{FinishReason, FinishReasonUnified, Warning}; use base64::Engine; use serde_json::{Map, Value, json}; +/// Resolve the caller-facing name for Google's code execution provider tool. +/// Gemini always uses `code_execution` on the response wire, while callers +/// may rename the provider tool for a particular request. +pub(crate) fn code_execution_tool_name(tools: Option<&[Tool]>) -> String { + tools + .unwrap_or_default() + .iter() + .find_map(|tool| match tool { + Tool::Provider(provider) if provider.id == "google.code_execution" => { + Some(provider.name.clone()) + } + _ => None, + }) + .unwrap_or_else(|| "code_execution".to_string()) +} + // ── Public conversion result ───────────────────────────────────────────────── /// The result of converting a `LanguageModelPrompt` into Google's @@ -40,6 +58,33 @@ pub struct GooglePrompt { pub contents: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProviderMetadataNamespace { + Google, + Vertex, +} + +/// Resolve per-part provider options in the same precedence order as the AI +/// SDK. Vertex prefers `googleVertex`, then legacy `vertex`; `google` is only a +/// cross-provider fallback. The public Google provider uses the inverse +/// fallback so transcripts survive gateway/provider failover. +fn read_provider_options( + provider_options: Option<&Value>, + namespace: ProviderMetadataNamespace, +) -> Option<&Value> { + let provider_options = provider_options?; + match namespace { + ProviderMetadataNamespace::Google => provider_options + .get("google") + .or_else(|| provider_options.get("googleVertex")) + .or_else(|| provider_options.get("vertex")), + ProviderMetadataNamespace::Vertex => provider_options + .get("googleVertex") + .or_else(|| provider_options.get("vertex")) + .or_else(|| provider_options.get("google")), + } +} + // ── convertToGoogleMessages ────────────────────────────────────────────────── /// Convert a provider-facing prompt into Google's `{ systemInstruction, contents }`. @@ -48,9 +93,9 @@ pub struct GooglePrompt { /// appropriate to the Rust data model: /// - No Gemma special-casing (we don't know the model id here; callers that /// need it can post-process). -/// - No server-tool-call handling — provider-executed `toolCall` parts have no -/// dedicated `ContentPart` variant; callers must reconstruct them from raw -/// results if they need to replay them. +/// - Provider-executed calls and results use their provider metadata to replay +/// native `toolCall` / `toolResponse` or `executableCode` / +/// `codeExecutionResult` parts. /// - Tool-result `output` is serialized into `functionResponse.response.content` /// as a string (JSON-stringified for non-string outputs, matching the TS /// `output.type === 'json'` path). @@ -60,6 +105,13 @@ pub struct GooglePrompt { /// by Gemini thinking models on follow-up turns). #[must_use] pub fn convert_to_google_messages(prompt: &LanguageModelPrompt) -> GooglePrompt { + convert_to_google_messages_for_namespace(prompt, ProviderMetadataNamespace::Google) +} + +fn convert_to_google_messages_for_namespace( + prompt: &LanguageModelPrompt, + namespace: ProviderMetadataNamespace, +) -> GooglePrompt { let mut system_parts: Vec = Vec::new(); let mut contents: Vec = Vec::new(); let mut system_messages_allowed = true; @@ -91,7 +143,7 @@ pub fn convert_to_google_messages(prompt: &LanguageModelPrompt) -> GooglePrompt } Role::Assistant => { system_messages_allowed = false; - let parts = convert_assistant_parts(&msg.content); + let parts = convert_assistant_parts(&msg.content, namespace); if !parts.is_empty() { contents.push(json!({ "role": "model", "parts": parts })); } @@ -161,7 +213,10 @@ fn convert_user_parts(content: &[ContentPart]) -> Vec { /// /// - `Text` → `{ text }` (skipped when empty, matching the TS SDK). /// - `ToolCall` → `{ functionCall: { id?, name, args } }`. -fn convert_assistant_parts(content: &[ContentPart]) -> Vec { +fn convert_assistant_parts( + content: &[ContentPart], + namespace: ProviderMetadataNamespace, +) -> Vec { let mut parts = Vec::new(); for part in content { match part { @@ -173,9 +228,7 @@ fn convert_assistant_parts(content: &[ContentPart]) -> Vec { let mut p = json!({ "text": text }); // Echo thoughtSignature from provider_options if present // (upstream convert-to-google-messages.ts:355-377). - if let Some(sig) = provider_options - .as_ref() - .and_then(|o| o.get("google")) + if let Some(sig) = read_provider_options(provider_options.as_ref(), namespace) .and_then(|g| g.get("thoughtSignature")) .and_then(|v| v.as_str()) { @@ -194,11 +247,10 @@ fn convert_assistant_parts(content: &[ContentPart]) -> Vec { // Prefer explicit signature field, then fall back to provider_options. if let Some(sig) = signature.as_ref() { p["thoughtSignature"] = json!(sig); - } else if let Some(sig) = provider_options - .as_ref() - .and_then(|o| o.get("google")) - .and_then(|g| g.get("thoughtSignature")) - .and_then(|v| v.as_str()) + } else if let Some(sig) = + read_provider_options(provider_options.as_ref(), namespace) + .and_then(|g| g.get("thoughtSignature")) + .and_then(|v| v.as_str()) { p["thoughtSignature"] = json!(sig); } @@ -210,22 +262,53 @@ fn convert_assistant_parts(content: &[ContentPart]) -> Vec { tool_name, input, thought_signature, + provider_options, .. } => { - let mut function_call = Map::new(); - if !tool_call_id.is_empty() { - function_call.insert("id".to_string(), json!(tool_call_id)); - } - function_call.insert("name".to_string(), json!(tool_name)); - function_call.insert("args".to_string(), input.clone()); - let mut part_value = json!({ "functionCall": function_call }); - // Thinking models (e.g. gemini-2.5-pro) attach a - // `thoughtSignature` to the part; it must be echoed back - // verbatim on the follow-up turn or the API rejects the - // request with HTTP 400. Emit it as a sibling of - // `functionCall` (not inside it), matching the response shape. - if let Some(sig) = thought_signature { - part_value["thoughtSignature"] = json!(sig); + let google_options = read_provider_options(provider_options.as_ref(), namespace); + let server_tool_call_id = google_options + .and_then(|options| options.get("serverToolCallId")) + .and_then(|value| value.as_str()); + let server_tool_type = google_options + .and_then(|options| options.get("serverToolType")) + .and_then(|value| value.as_str()); + let signature = thought_signature.as_deref().or_else(|| { + google_options + .and_then(|options| options.get("thoughtSignature")) + .and_then(|value| value.as_str()) + }); + + let mut part_value = if let (Some(server_id), Some(server_type)) = + (server_tool_call_id, server_tool_type) + { + let args = match input { + Value::String(raw) => { + serde_json::from_str(raw).unwrap_or_else(|_| input.clone()) + } + _ => input.clone(), + }; + if server_type == "code_execution" { + json!({ "executableCode": args }) + } else { + json!({ + "toolCall": { + "toolType": server_type, + "args": args, + "id": server_id, + } + }) + } + } else { + let mut function_call = Map::new(); + if !tool_call_id.is_empty() { + function_call.insert("id".to_string(), json!(tool_call_id)); + } + function_call.insert("name".to_string(), json!(tool_name)); + function_call.insert("args".to_string(), input.clone()); + json!({ "functionCall": function_call }) + }; + if let Some(signature) = signature { + part_value["thoughtSignature"] = json!(signature); } parts.push(part_value); } @@ -242,17 +325,28 @@ fn convert_assistant_parts(content: &[ContentPart]) -> Vec { // upstream convert-to-google-messages.ts:518-540. // If it carries serverToolCallId + serverToolType, emit as // a toolResponse; otherwise skip (upstream returns undefined). - if let Some(opts) = provider_options.as_ref().and_then(|o| o.get("google")) { + if let Some(opts) = read_provider_options(provider_options.as_ref(), namespace) { let server_id = opts.get("serverToolCallId").and_then(|v| v.as_str()); let server_type = opts.get("serverToolType").and_then(|v| v.as_str()); if let (Some(sid), Some(st)) = (server_id, server_type) { - parts.push(json!({ - "toolResponse": { - "toolType": st, - "response": result, - "id": sid, - } - })); + let mut part_value = if st == "code_execution" { + json!({ "codeExecutionResult": result }) + } else { + json!({ + "toolResponse": { + "toolType": st, + "response": result, + "id": sid, + } + }) + }; + if let Some(signature) = opts + .get("thoughtSignature") + .and_then(|value| value.as_str()) + { + part_value["thoughtSignature"] = json!(signature); + } + parts.push(part_value); } } } @@ -915,6 +1009,18 @@ pub fn build_request_body(model_id: &str, options: &CallOptions) -> Value { build_request_body_with_warnings(model_id, options).0 } +/// Build a Vertex Gemini request body using Vertex's provider-metadata +/// namespaces when replaying response parts. +#[must_use] +pub(crate) fn build_vertex_request_body(model_id: &str, options: &CallOptions) -> Value { + build_request_body_with_warnings_for_namespace( + model_id, + options, + ProviderMetadataNamespace::Vertex, + ) + .0 +} + /// Build the Gemini `generateContent` request body **and** collect the tool /// warnings (e.g. unsupported provider-defined tools, mixed function+provider /// tools on pre-Gemini-3 models). @@ -922,11 +1028,23 @@ pub fn build_request_body(model_id: &str, options: &CallOptions) -> Value { pub fn build_request_body_with_warnings( model_id: &str, options: &CallOptions, +) -> (Value, Vec) { + build_request_body_with_warnings_for_namespace( + model_id, + options, + ProviderMetadataNamespace::Google, + ) +} + +fn build_request_body_with_warnings_for_namespace( + model_id: &str, + options: &CallOptions, + namespace: ProviderMetadataNamespace, ) -> (Value, Vec) { let GooglePrompt { system_instruction, contents, - } = convert_to_google_messages(&options.prompt); + } = convert_to_google_messages_for_namespace(&options.prompt, namespace); let mut generation_config = Map::new(); @@ -1172,3 +1290,119 @@ pub fn extract_sources( sources } + +#[cfg(test)] +mod tests { + use super::*; + use aimux_core::language_model_message::LanguageModelPromptMessage; + + fn assistant_prompt(content: Vec) -> LanguageModelPrompt { + vec![LanguageModelPromptMessage { + role: Role::Assistant, + content, + provider_options: None, + }] + } + + fn code_metadata(namespace: &str, id: &str) -> Value { + json!({ + (namespace): { + "serverToolCallId": id, + "serverToolType": "code_execution", + } + }) + } + + #[test] + fn vertex_replays_native_code_execution_with_multiple_results() { + let call_id = "code-1"; + let prompt = assistant_prompt(vec![ + ContentPart::ToolCall { + tool_call_id: call_id.to_string(), + tool_name: "runCode".to_string(), + input: json!({ "language": "PYTHON", "code": "print(2)" }), + provider_executed: Some(true), + thought_signature: None, + provider_options: Some(code_metadata("googleVertex", call_id)), + }, + ContentPart::ToolResult { + tool_call_id: call_id.to_string(), + tool_name: Some("runCode".to_string()), + result: json!({ "outcome": "OUTCOME_OK", "output": "2" }), + is_error: None, + preliminary: None, + dynamic: None, + provider_options: Some(code_metadata("googleVertex", call_id)), + }, + ContentPart::ToolResult { + tool_call_id: call_id.to_string(), + tool_name: Some("runCode".to_string()), + result: json!({ "outcome": "OUTCOME_OK", "output": "still 2" }), + is_error: None, + preliminary: None, + dynamic: None, + provider_options: Some(code_metadata("googleVertex", call_id)), + }, + ]); + + let converted = + convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Vertex); + assert_eq!( + converted.contents[0]["parts"], + json!([ + { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } }, + ]) + ); + } + + #[test] + fn provider_metadata_namespace_precedence_matches_ai_sdk() { + let content = ContentPart::ToolCall { + tool_call_id: "call-1".to_string(), + tool_name: "weather".to_string(), + input: json!({}), + provider_executed: None, + thought_signature: None, + provider_options: Some(json!({ + "googleVertex": { "thoughtSignature": "google-vertex" }, + "vertex": { "thoughtSignature": "vertex" }, + "google": { "thoughtSignature": "google" }, + })), + }; + let prompt = assistant_prompt(vec![content]); + + let vertex = + convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Vertex); + assert_eq!( + vertex.contents[0]["parts"][0]["thoughtSignature"], + "google-vertex" + ); + + let google = + convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Google); + assert_eq!(google.contents[0]["parts"][0]["thoughtSignature"], "google"); + } + + #[test] + fn vertex_reads_google_as_cross_namespace_fallback() { + let prompt = assistant_prompt(vec![ContentPart::ToolCall { + tool_call_id: "call-1".to_string(), + tool_name: "weather".to_string(), + input: json!({}), + provider_executed: None, + thought_signature: None, + provider_options: Some(json!({ + "google": { "thoughtSignature": "gateway-signature" }, + })), + }]); + + let converted = + convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Vertex); + assert_eq!( + converted.contents[0]["parts"][0]["thoughtSignature"], + "gateway-signature" + ); + } +} diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index 0545a7c1..bddb800b 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -20,7 +20,8 @@ use aimux_provider_utils::HttpRequest; use super::GoogleConfig; use super::convert::{ - build_request_body_with_warnings, convert_usage, extract_sources, parse_finish_reason, + build_request_body_with_warnings, code_execution_tool_name, convert_usage, extract_sources, + parse_finish_reason, }; use super::types::{Candidate, GenerateContentResponse, GoogleStreamEvent}; @@ -118,6 +119,7 @@ impl LanguageModel for GoogleModel { } async fn do_generate(&self, options: &CallOptions) -> Result { + let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); let (body, tool_warnings) = build_request_body_with_warnings(&self.model_id, options); let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( @@ -140,7 +142,8 @@ impl LanguageModel for GoogleModel { AiMuxError::InvalidResponseData("no candidates in response".to_string()) })?; - let (content, has_tool_calls) = extract_content_from_candidate(&candidate); + let (content, has_tool_calls) = + extract_content_from_candidate(&candidate, &code_execution_tool_name); let finish_reason = candidate .finish_reason @@ -187,6 +190,7 @@ impl LanguageModel for GoogleModel { } async fn do_stream(&self, options: &CallOptions) -> Result { + let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); let (body, tool_warnings) = build_request_body_with_warnings(&self.model_id, options); let headers = self.build_headers(options.headers.as_ref()); let endpoint = self.stream_endpoint(); @@ -452,23 +456,29 @@ impl LanguageModel for GoogleModel { block_counter += 1; last_code_execution_tool_call_id = Some(id.clone()); yield Ok(StreamPart::ToolCall { - tool_call_id: id, - tool_name: "code_execution".to_string(), + tool_call_id: id.clone(), + tool_name: code_execution_tool_name.clone(), input: Value::String(ec.to_string()), provider_executed: Some(true), dynamic: None, thought_signature: None, invalid: None, error: None, - provider_metadata: None, + provider_metadata: Some(server_tool_metadata( + &id, + "code_execution", + None, + )), }); // provider-executed → does NOT set has_tool_calls } } else if let Some(cer) = part.get("codeExecutionResult") { // Result corresponds to the most recent - // executableCode part. + // executableCode part. Gemini may emit + // several results for that one call, so + // retain the association until a new call. if let Some(call_id) = - last_code_execution_tool_call_id.take() + last_code_execution_tool_call_id.as_ref() { let outcome = cer.get("outcome").cloned().unwrap_or(json!(null)); @@ -478,13 +488,17 @@ impl LanguageModel for GoogleModel { .map(std::string::ToString::to_string) .unwrap_or_default(); yield Ok(StreamPart::ToolResult { - tool_call_id: call_id, - tool_name: "code_execution".to_string(), + tool_call_id: call_id.clone(), + tool_name: code_execution_tool_name.clone(), result: json!({ "outcome": outcome, "output": output }), is_error: None, preliminary: None, dynamic: None, - provider_metadata: None, + provider_metadata: Some(server_tool_metadata( + call_id, + "code_execution", + None, + )), }); } } else if let Some(tc) = part.get("toolCall") { @@ -661,6 +675,21 @@ impl LanguageModel for GoogleModel { // ── Helpers ────────────────────────────────────────────────────────────────── +fn server_tool_metadata( + tool_call_id: &str, + server_tool_type: &str, + thought_signature: Option<&str>, +) -> Value { + let mut payload = json!({ + "serverToolCallId": tool_call_id, + "serverToolType": server_tool_type, + }); + if let Some(signature) = thought_signature { + payload["thoughtSignature"] = json!(signature); + } + json!({ "google": payload }) +} + /// Extract `GenerateContent` items from a non-streaming candidate. /// /// Returns `(content, has_tool_calls)` so the caller can disambiguate the @@ -671,7 +700,10 @@ impl LanguageModel for GoogleModel { /// /// Sources extracted from `groundingMetadata.groundingChunks` are appended /// after the parts, matching the TS `extractSources` + `content.push(source)`. -fn extract_content_from_candidate(candidate: &Candidate) -> (Vec, bool) { +fn extract_content_from_candidate( + candidate: &Candidate, + code_execution_tool_name: &str, +) -> (Vec, bool) { let mut content = Vec::new(); let mut has_tool_calls = false; let mut source_id = 0usize; @@ -704,17 +736,18 @@ fn extract_content_from_candidate(candidate: &Candidate) -> (Vec (Vec Result, AiMu tool_name: name.to_string(), input, provider_executed: Some(true), - dynamic: None, + dynamic: Some(true), thought_signature: None, provider_metadata: None, }); @@ -1176,7 +1176,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu result: output.clone(), is_error: None, preliminary: None, - dynamic: None, + dynamic: Some(true), provider_metadata: None, }); } @@ -1190,7 +1190,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu tool_name: "list_tools".to_string(), input: Value::String(json!({ "server_label": server_label }).to_string()), provider_executed: Some(true), - dynamic: None, + dynamic: Some(true), thought_signature: None, provider_metadata: None, }); @@ -1202,7 +1202,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu result: json!({ "tools": tools }), is_error: None, preliminary: None, - dynamic: None, + dynamic: Some(true), provider_metadata: None, }); } diff --git a/aimux-providers/src/vertex/anthropic_model.rs b/aimux-providers/src/vertex/anthropic_model.rs index 470bc5a8..87a70e45 100644 --- a/aimux-providers/src/vertex/anthropic_model.rs +++ b/aimux-providers/src/vertex/anthropic_model.rs @@ -30,7 +30,10 @@ use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usa use aimux_provider_utils::{HttpRequest, RetryConfig}; use crate::anthropic::convert::{build_request_body_with_warnings, parse_stop_reason}; -use crate::anthropic::stream::stream_parts_for_result_block; +use crate::anthropic::stream::{ + initial_tool_input, normalized_server_tool_input, server_tool_provider_name, + stream_parts_for_result_block, tool_call_caller_metadata, +}; use crate::anthropic::tool_name_mapping::ToolNameMapping; use crate::anthropic::types::{AnthropicResponse, ContentBlock, StreamEvent}; @@ -277,6 +280,7 @@ impl LanguageModel for VertexAnthropicModel { let mut final_finish_reason: Option = None; let mut response_meta_emitted = false; let mut mcp_tool_calls: HashMap = HashMap::new(); + let mut server_tool_calls: HashMap = HashMap::new(); while let Some(event) = sse.next().await { match event { @@ -306,10 +310,19 @@ impl LanguageModel for VertexAnthropicModel { ContentBlock::Thinking { .. } => { blocks.insert(index, BlockState::Thinking { started: false }); } - ContentBlock::ToolUse { id, name, .. } => { + ContentBlock::ToolUse { + id, + name, + input, + caller, + } => { + let custom_name = tool_names + .to_custom_tool_name(&name) + .to_string(); + let initial_input = initial_tool_input(&input); yield Ok(StreamPart::ToolInputStart { id: id.clone(), - tool_name: name.clone(), + tool_name: custom_name.clone(), provider_executed: None, dynamic: None, title: None, @@ -317,22 +330,56 @@ impl LanguageModel for VertexAnthropicModel { }); blocks.insert(index, BlockState::ToolUse { id, - name, - accumulated_json: String::new(), + name: custom_name, + first_delta: initial_input.is_empty(), + accumulated_json: initial_input, + provider_executed: None, + dynamic: None, + provider_tool_name: None, + provider_tool_input_type: None, + provider_metadata: tool_call_caller_metadata(caller.as_ref()), }); } ContentBlock::ServerToolUse { id, name, input } => { - yield Ok(StreamPart::ToolCall { - tool_call_id: id.clone(), - tool_name: tool_names - .to_custom_tool_name(&name) - .to_string(), - input: Value::String(input.to_string()), + if matches!( + name.as_str(), + "tool_search_tool_regex" | "tool_search_tool_bm25" + ) { + server_tool_calls.insert(id.clone(), name.clone()); + } + let provider_name = server_tool_provider_name(&name); + let custom_name = tool_names + .to_custom_tool_name(provider_name) + .to_string(); + let dynamic = (provider_name == "code_execution" + && tool_names.mark_code_execution_dynamic()) + .then_some(true); + let initial_input = initial_tool_input(&input); + yield Ok(StreamPart::ToolInputStart { + id: id.clone(), + tool_name: custom_name.clone(), provider_executed: Some(true), - dynamic: None, - thought_signature: None, - invalid: None, - error: None, + dynamic, + title: None, + provider_metadata: None, + }); + blocks.insert(index, BlockState::ToolUse { + id, + name: custom_name, + first_delta: initial_input.is_empty(), + accumulated_json: initial_input, + provider_executed: Some(true), + dynamic, + provider_tool_name: Some(provider_name.to_string()), + provider_tool_input_type: match name.as_str() { + "text_editor_code_execution" | "bash_code_execution" => { + Some(name) + } + "code_execution" => { + Some("programmatic-tool-call".to_string()) + } + _ => None, + }, provider_metadata: None, }); } @@ -370,6 +417,7 @@ impl LanguageModel for VertexAnthropicModel { &other, &tool_names, &mcp_tool_calls, + &server_tool_calls, ) { yield Ok(part); } @@ -399,21 +447,36 @@ impl LanguageModel for VertexAnthropicModel { }); } if let Some(partial) = delta.partial_json { - let delta_id: Option = match blocks.get_mut(&index) { + let delta_event: Option<(String, String)> = match blocks.get_mut(&index) { Some(BlockState::ToolUse { id, accumulated_json, + provider_tool_input_type, + first_delta, .. }) if !partial.is_empty() => { - accumulated_json.push_str(&partial); - Some(id.clone()) + let emitted_delta = if *first_delta { + if let Some(input_type) = provider_tool_input_type { + format!( + "{{\"type\": \"{input_type}\",{}", + partial.strip_prefix('{').unwrap_or(&partial) + ) + } else { + partial + } + } else { + partial + }; + accumulated_json.push_str(&emitted_delta); + *first_delta = false; + Some((id.clone(), emitted_delta)) } _ => None, }; - if let Some(id) = delta_id { + if let Some((id, delta)) = delta_event { yield Ok(StreamPart::ToolInputDelta { id, - delta: partial, + delta, provider_metadata: None, }); } @@ -463,27 +526,42 @@ impl LanguageModel for VertexAnthropicModel { BlockState::ToolUse { id, name, - accumulated_json, + mut accumulated_json, + provider_executed, + dynamic, + provider_tool_name, + provider_tool_input_type, + provider_metadata, + .. } => { yield Ok(StreamPart::ToolInputEnd { id: id.clone(), provider_metadata: None}); - // Empty input normalizes to "{}" - // per the upstream provider. - let input = - Value::String(if accumulated_json.is_empty() { - "{}".to_string() - } else { - accumulated_json - }); + if accumulated_json.is_empty() { + accumulated_json = "{}".to_string(); + } + if provider_tool_name.as_deref() == Some("code_execution") + && let Ok(parsed) = serde_json::from_str::(&accumulated_json) + { + let wire_name = match provider_tool_input_type.as_deref() { + Some(name @ ("text_editor_code_execution" | "bash_code_execution")) => name, + _ => "code_execution", + }; + accumulated_json = normalized_server_tool_input( + wire_name, + &parsed, + ) + .to_string(); + } + let input = Value::String(accumulated_json); yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: name, input, - provider_executed: None, - dynamic: None, + provider_executed, + dynamic, thought_signature: None, invalid: None, error: None, - provider_metadata: None, + provider_metadata, }); } } @@ -558,6 +636,12 @@ enum BlockState { id: String, name: String, accumulated_json: String, + provider_executed: Option, + dynamic: Option, + provider_tool_name: Option, + provider_tool_input_type: Option, + provider_metadata: Option, + first_delta: bool, }, Thinking { started: bool, diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index 86e6feb0..ddec5545 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -20,7 +20,8 @@ use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usa use aimux_provider_utils::{HttpRequest, RetryConfig}; use crate::google::convert::{ - build_request_body, convert_usage, extract_sources, parse_finish_reason, + build_vertex_request_body, code_execution_tool_name, convert_usage, extract_sources, + parse_finish_reason, }; use crate::google::types::{Candidate, GenerateContentResponse, GoogleStreamEvent}; @@ -152,7 +153,8 @@ impl LanguageModel for VertexModel { } async fn do_generate(&self, options: &CallOptions) -> Result { - let body = build_request_body(&self.model_id, options); + let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); + let body = build_vertex_request_body(&self.model_id, options); let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( HttpRequest { @@ -178,7 +180,8 @@ impl LanguageModel for VertexModel { AiMuxError::InvalidResponseData("no candidates in response".to_string()) })?; - let (content, has_tool_calls) = extract_content_from_candidate(&candidate); + let (content, has_tool_calls) = + extract_content_from_candidate(&candidate, &code_execution_tool_name); let finish_reason = candidate .finish_reason @@ -195,16 +198,14 @@ impl LanguageModel for VertexModel { .map(convert_usage) .unwrap_or_default(); - let provider_metadata = Some(serde_json::json!({ - "googleVertex": { - "promptFeedback": data.prompt_feedback, - "groundingMetadata": candidate.grounding_metadata, - "urlContextMetadata": candidate.url_context_metadata, - "safetyRatings": candidate.safety_ratings, - "usageMetadata": data.usage_metadata, - "finishMessage": candidate.finish_message, - } - })); + let provider_metadata = Some(vertex_provider_metadata(json!({ + "promptFeedback": data.prompt_feedback, + "groundingMetadata": candidate.grounding_metadata, + "urlContextMetadata": candidate.url_context_metadata, + "safetyRatings": candidate.safety_ratings, + "usageMetadata": data.usage_metadata, + "finishMessage": candidate.finish_message, + }))); Ok(GenerateResult { content, @@ -225,7 +226,8 @@ impl LanguageModel for VertexModel { } async fn do_stream(&self, options: &CallOptions) -> Result { - let body = build_request_body(&self.model_id, options); + let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); + let body = build_vertex_request_body(&self.model_id, options); let headers = self.build_headers(options.headers.as_ref()); let endpoint = self.stream_endpoint(); let resp = aimux_provider_utils::post_json_to_api( @@ -366,17 +368,28 @@ impl LanguageModel for VertexModel { for part in parts { if let Some(text) = part.get("text").and_then(|v| v.as_str()) { if !text.is_empty() { + let text_metadata = part + .get("thoughtSignature") + .and_then(|v| v.as_str()) + .map(|signature| { + vertex_provider_metadata(json!({ + "thoughtSignature": signature, + })) + }); if text_id.is_none() { let id = format!("{block_counter}"); block_counter += 1; text_id = Some(id.clone()); - yield Ok(StreamPart::TextStart { id, provider_metadata: None}); + yield Ok(StreamPart::TextStart { + id, + provider_metadata: text_metadata.clone(), + }); } if let Some(id) = &text_id { yield Ok(StreamPart::TextDelta { id: id.clone(), delta: text.to_string(), - provider_metadata: None, + provider_metadata: text_metadata, }); } } @@ -396,6 +409,11 @@ impl LanguageModel for VertexModel { .get("thoughtSignature") .and_then(|v| v.as_str()) .map(std::string::ToString::to_string); + let tool_metadata = thought_signature.as_deref().map(|signature| { + vertex_provider_metadata(json!({ + "thoughtSignature": signature, + })) + }); yield Ok(StreamPart::ToolInputStart { id: id.clone(), @@ -403,15 +421,18 @@ impl LanguageModel for VertexModel { provider_executed: None, dynamic: None, title: None, - provider_metadata: None, + provider_metadata: tool_metadata.clone(), }); let args_str = args.to_string(); yield Ok(StreamPart::ToolInputDelta { id: id.clone(), delta: args_str, - provider_metadata: None, + provider_metadata: tool_metadata.clone(), + }); + yield Ok(StreamPart::ToolInputEnd { + id: id.clone(), + provider_metadata: tool_metadata.clone(), }); - yield Ok(StreamPart::ToolInputEnd { id: id.clone(), provider_metadata: None}); yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: name.to_string(), @@ -421,7 +442,7 @@ impl LanguageModel for VertexModel { thought_signature, invalid: None, error: None, - provider_metadata: None, + provider_metadata: tool_metadata, }); has_tool_calls = true; } else if let Some(ec) = part.get("executableCode") { @@ -436,23 +457,29 @@ impl LanguageModel for VertexModel { block_counter += 1; last_code_execution_tool_call_id = Some(id.clone()); yield Ok(StreamPart::ToolCall { - tool_call_id: id, - tool_name: "code_execution".to_string(), + tool_call_id: id.clone(), + tool_name: code_execution_tool_name.clone(), input: Value::String(ec.to_string()), provider_executed: Some(true), dynamic: None, thought_signature: None, invalid: None, error: None, - provider_metadata: None, + provider_metadata: Some(vertex_server_tool_metadata( + &id, + "code_execution", + None, + )), }); // provider-executed → does NOT set has_tool_calls } } else if let Some(cer) = part.get("codeExecutionResult") { // Result corresponds to the most recent - // executableCode part. + // executableCode part. Gemini may emit + // several results for that one call, so + // retain the association until a new call. if let Some(call_id) = - last_code_execution_tool_call_id.take() + last_code_execution_tool_call_id.as_ref() { let outcome = cer.get("outcome").cloned().unwrap_or(json!(null)); @@ -462,13 +489,17 @@ impl LanguageModel for VertexModel { .map(std::string::ToString::to_string) .unwrap_or_default(); yield Ok(StreamPart::ToolResult { - tool_call_id: call_id, - tool_name: String::new(), + tool_call_id: call_id.clone(), + tool_name: code_execution_tool_name.clone(), result: json!({ "outcome": outcome, "output": output }), is_error: None, preliminary: None, dynamic: None, - provider_metadata: None, + provider_metadata: Some(vertex_server_tool_metadata( + call_id, + "code_execution", + None, + )), }); } } else if let Some(tc) = part.get("toolCall") { @@ -485,20 +516,33 @@ impl LanguageModel for VertexModel { block_counter += 1; last_server_tool_call_id = Some(id.clone()); let args = tc.get("args").cloned().unwrap_or(json!({})); + let thought_signature = part + .get("thoughtSignature") + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string); + let server_meta = vertex_server_tool_metadata( + &id, + tool_type, + thought_signature.as_deref(), + ); yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), input: Value::String(args.to_string()), provider_executed: Some(true), dynamic: Some(true), - thought_signature: None, + thought_signature, invalid: None, error: None, - provider_metadata: None, + provider_metadata: Some(server_meta), }); // provider-executed → does NOT set has_tool_calls } else if let Some(tr) = part.get("toolResponse") { // Server-side tool response. + let tool_type = tr + .get("toolType") + .and_then(|v| v.as_str()) + .unwrap_or(""); let id = last_server_tool_call_id .take() .or_else(|| { @@ -510,14 +554,19 @@ impl LanguageModel for VertexModel { block_counter += 1; let response = tr.get("response").cloned().unwrap_or(json!({})); + let server_meta = vertex_server_tool_metadata( + &id, + tool_type, + part.get("thoughtSignature").and_then(|v| v.as_str()), + ); yield Ok(StreamPart::ToolResult { tool_call_id: id, - tool_name: String::new(), + tool_name: format!("server:{tool_type}"), result: response, is_error: None, preliminary: None, dynamic: None, - provider_metadata: None, + provider_metadata: Some(server_meta), }); } } @@ -565,16 +614,14 @@ impl LanguageModel for VertexModel { yield Ok(StreamPart::TextEnd { id, provider_metadata: None}); } - let provider_metadata = Some(serde_json::json!({ - "googleVertex": { - "promptFeedback": last_prompt_feedback, - "groundingMetadata": last_grounding_metadata, - "urlContextMetadata": last_url_context_metadata, - "safetyRatings": last_safety_ratings, - "usageMetadata": last_usage_metadata_value, - "finishMessage": last_finish_message, - } - })); + let provider_metadata = Some(vertex_provider_metadata(json!({ + "promptFeedback": last_prompt_feedback, + "groundingMetadata": last_grounding_metadata, + "urlContextMetadata": last_url_context_metadata, + "safetyRatings": last_safety_ratings, + "usageMetadata": last_usage_metadata_value, + "finishMessage": last_finish_message, + }))); yield Ok(StreamPart::Finish { finish_reason: if stream_errored { @@ -603,21 +650,98 @@ impl LanguageModel for VertexModel { // ── Helpers ────────────────────────────────────────────────────────────────── +fn vertex_provider_metadata(payload: Value) -> Value { + json!({ + "googleVertex": payload.clone(), + "vertex": payload, + }) +} + +fn vertex_server_tool_metadata( + tool_call_id: &str, + server_tool_type: &str, + thought_signature: Option<&str>, +) -> Value { + let mut payload = json!({ + "serverToolCallId": tool_call_id, + "serverToolType": server_tool_type, + }); + if let Some(signature) = thought_signature { + payload["thoughtSignature"] = json!(signature); + } + vertex_provider_metadata(payload) +} + /// Extract `GenerateContent` items from a non-streaming candidate. -fn extract_content_from_candidate(candidate: &Candidate) -> (Vec, bool) { +fn extract_content_from_candidate( + candidate: &Candidate, + code_execution_tool_name: &str, +) -> (Vec, bool) { let mut content = Vec::new(); let mut has_tool_calls = false; let mut source_id = 0usize; + let mut last_code_execution_tool_call_id: Option = None; + let mut last_server_tool_call_id: Option = None; let parts = candidate.content.as_ref().and_then(|c| c.parts.as_ref()); if let Some(parts) = parts { for part in parts { - if let Some(text) = part.get("text").and_then(|v| v.as_str()) { + if let Some(ec) = part.get("executableCode") { + let has_code = ec + .get("code") + .and_then(|v| v.as_str()) + .is_some_and(|code| !code.is_empty()); + if has_code { + let id = format!("call-{}", content.len()); + last_code_execution_tool_call_id = Some(id.clone()); + content.push(GenerateContent::ToolCall { + tool_call_id: id.clone(), + tool_name: code_execution_tool_name.to_string(), + input: Value::String(ec.to_string()), + provider_executed: Some(true), + dynamic: None, + thought_signature: None, + provider_metadata: Some(vertex_server_tool_metadata( + &id, + "code_execution", + None, + )), + }); + } + } else if let Some(cer) = part.get("codeExecutionResult") { + // One executableCode may be followed by multiple results. + if let Some(call_id) = last_code_execution_tool_call_id.as_ref() { + let outcome = cer.get("outcome").cloned().unwrap_or(json!(null)); + let output = cer + .get("output") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + content.push(GenerateContent::ToolResult { + tool_call_id: call_id.clone(), + tool_name: code_execution_tool_name.to_string(), + result: json!({ "outcome": outcome, "output": output }), + is_error: None, + preliminary: None, + dynamic: None, + provider_metadata: Some(vertex_server_tool_metadata( + call_id, + "code_execution", + None, + )), + }); + } + } else if let Some(text) = part.get("text").and_then(|v| v.as_str()) { if !text.is_empty() { + let provider_metadata = part + .get("thoughtSignature") + .and_then(|v| v.as_str()) + .map(|signature| { + vertex_provider_metadata(json!({ "thoughtSignature": signature })) + }); content.push(GenerateContent::Text { text: text.to_string(), - provider_metadata: None, + provider_metadata, }); } } else if let Some(fc) = part.get("functionCall") { @@ -636,6 +760,9 @@ fn extract_content_from_candidate(candidate: &Candidate) -> (Vec (Vec { // Skip provider-executed tool calls. - let is_provider_executed = provider_options - .as_ref() - .and_then(|po| po.get("xai")) - .and_then(|x| x.get("providerExecuted")) - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); + // The standardized field is authoritative. Fall back + // to the legacy provider option only when it is absent. + let is_provider_executed = provider_executed.unwrap_or_else(|| { + provider_options + .as_ref() + .and_then(|po| po.get("xai")) + .and_then(|x| x.get("providerExecuted")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }); if is_provider_executed { continue; } @@ -787,6 +792,14 @@ pub fn resolve_tool_name( .get("xai.code_execution") .cloned() .unwrap_or_else(|| "code_execution".to_string()); + let default_view_image = provider_tool_names + .get("xai.view_image") + .cloned() + .unwrap_or_else(|| "view_image".to_string()); + let default_view_x_video = provider_tool_names + .get("xai.view_x_video") + .cloned() + .unwrap_or_else(|| "view_x_video".to_string()); let default_mcp = provider_tool_names .get("xai.mcp") .cloned() @@ -805,6 +818,10 @@ pub fn resolve_tool_name( || part_type == "code_execution_call" { default_code + } else if name == "view_image" || part_type == "view_image_call" { + default_view_image + } else if name == "view_x_video" || part_type == "view_x_video_call" { + default_view_x_video } else if part_type == "mcp_call" { default_mcp } else if part_type == "file_search_call" { diff --git a/aimux-providers/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index 2935be8b..bca5882e 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -695,7 +695,7 @@ impl LanguageModel for XaiResponsesModel { yield Ok(StreamPart::ToolInputStart { id: part_id.to_string(), tool_name: tool_name.clone(), - provider_executed: None, + provider_executed: Some(true), dynamic: None, title: None, provider_metadata: None, @@ -768,7 +768,7 @@ impl LanguageModel for XaiResponsesModel { yield Ok(StreamPart::ToolInputStart { id: part_id.to_string(), tool_name: tool_name.clone(), - provider_executed: None, + provider_executed: Some(true), dynamic: None, title: None, provider_metadata: None, diff --git a/aimux-providers/tests/alibaba_test.rs b/aimux-providers/tests/alibaba_test.rs index 91416bc5..a4323ca6 100644 --- a/aimux-providers/tests/alibaba_test.rs +++ b/aimux-providers/tests/alibaba_test.rs @@ -304,6 +304,7 @@ async fn converts_tool_call_and_tool_result_messages() { tool_call_id: "call-1".to_string(), tool_name: "get_weather".to_string(), input: json!({"location": "SF"}), + provider_executed: None, thought_signature: None, provider_options: None, }], diff --git a/aimux-providers/tests/anthropic_assistant_tool_result_test.rs b/aimux-providers/tests/anthropic_assistant_tool_result_test.rs index 99a26aa6..363b2944 100644 --- a/aimux-providers/tests/anthropic_assistant_tool_result_test.rs +++ b/aimux-providers/tests/anthropic_assistant_tool_result_test.rs @@ -6,12 +6,8 @@ //! provider-executed block (`web_search_tool_result`, `mcp_tool_result`, …) //! instead, or the follow-up request is rejected with HTTP 400. //! -//! `generate_text` does not route provider-executed results into -//! `response_messages` yet — that needs `provider_executed` on the variant to -//! express upstream's `!part.providerExecuted` predicate, and ships with the -//! type change. The guard is in place first so the path is protected before it -//! opens; these tests construct the assistant prompt directly rather than -//! going through a generate call. +//! Core routes provider-executed calls and results into the same assistant +//! response message; these converter tests pin the replay wire blocks directly. //! //! Mirrors the assistant `case 'tool-result'` branch of //! `reference/vercel-ai/anthropic/src/convert-to-anthropic-prompt.ts` (:871-1285). @@ -21,9 +17,12 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::content::ContentPart; +use aimux_core::generate::{GenerateTextOptions, generate_text}; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + LanguageModelPrompt, LanguageModelPromptMessage, convert_to_language_model_prompt, +}; +use aimux_core::message::{MessageContent, ModelMessage, Role}; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::tool::{ProviderTool, Tool}; @@ -85,6 +84,36 @@ fn convert(prompt: LanguageModelPrompt, tools: Vec) -> (Vec, Vec parts.iter().find_map(|part| match part { + ContentPart::ToolCall { + provider_options, .. + } => provider_options.as_ref(), + _ => None, + }), + MessageContent::Text(_) => None, + }; + assert_eq!(response_metadata, Some(metadata)); + } } // ═════════════════════════════════════════════════════════════════════════════ @@ -1147,6 +1210,188 @@ mod do_stream { assert_eq!(finish.1.output_tokens.total, Some(65)); } + #[tokio::test] + async fn renamed_client_provider_tool_is_consistent_across_stream_boundary() { + let server = MockServer::start().await; + let sse_body = sse_stream(&[ + json!({ + "type": "message_start", + "message": { + "id": "msg_1", + "model": "claude-3-5-sonnet-latest", + "usage": { "input_tokens": 10, "output_tokens": 1 }, + }, + }), + json!({ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "tool_1", + "name": "computer", + "input": {}, + }, + }), + json!({ + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": "{\"action\":\"screenshot\"}", + }, + }), + json!({ "type": "content_block_stop", "index": 0 }), + json!({ + "type": "message_delta", + "delta": { "stop_reason": "tool_use" }, + "usage": { "output_tokens": 8 }, + }), + json!({ "type": "message_stop" }), + ]); + mock_sse(&server, &sse_body).await; + let model = make_model(&server); + let tool = Tool::Provider(ProviderTool { + id: "anthropic.computer_20250124".to_string(), + name: "myComputer".to_string(), + args: json!({ "displayWidthPx": 1280, "displayHeightPx": 720 }), + }); + let raw_options = CallOptions { + tools: Some(vec![tool.clone()]), + ..default_options(test_prompt()) + }; + + let raw_parts = collect_stream(model.do_stream(&raw_options).await.unwrap()).await; + assert!(raw_parts.iter().any(|part| matches!( + part, + StreamPart::ToolInputStart { tool_name, .. } if tool_name == "myComputer" + ))); + assert!(raw_parts.iter().any(|part| matches!( + part, + StreamPart::ToolCall { tool_name, .. } if tool_name == "myComputer" + ))); + + let result = stream_text( + &model, + "Take a screenshot", + GenerateTextOptions { + tools: Some(vec![tool]), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("stream_text should start") + .consume() + .await + .expect("renamed provider tool should pass Core validation"); + let call = result.tool_calls.first().expect("computer tool call"); + assert_eq!(call.tool_name, "myComputer"); + assert_eq!(call.input, json!({ "action": "screenshot" })); + assert_eq!(call.invalid, None); + } + + #[tokio::test] + async fn implicit_streamed_code_execution_is_dynamic_and_keeps_partial_input() { + let server = MockServer::start().await; + let sse_body = sse_stream(&[ + json!({ + "type": "message_start", + "message": { + "id": "msg_1", + "model": "claude-sonnet-4-5", + "usage": { "input_tokens": 10, "output_tokens": 1 }, + }, + }), + json!({ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "server_tool_use", + "id": "tool_1", + "name": "code_execution", + "input": {}, + }, + }), + json!({ + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": "{\"code\":\"print(2)\"}", + }, + }), + json!({ "type": "content_block_stop", "index": 0 }), + json!({ + "type": "message_delta", + "delta": { "stop_reason": "end_turn" }, + "usage": { "output_tokens": 8 }, + }), + json!({ "type": "message_stop" }), + ]); + mock_sse(&server, &sse_body).await; + let model = make_model(&server); + let web_tool = Tool::Provider(ProviderTool { + id: "anthropic.web_search_20260209".to_string(), + name: "mySearch".to_string(), + args: json!({}), + }); + let options = CallOptions { + tools: Some(vec![web_tool.clone()]), + ..default_options(test_prompt()) + }; + + let raw_parts = collect_stream(model.do_stream(&options).await.unwrap()).await; + assert!(raw_parts.iter().any(|part| matches!( + part, + StreamPart::ToolInputStart { + provider_executed: Some(true), + dynamic: Some(true), + .. + } + ))); + let raw_call = raw_parts + .iter() + .find_map(|part| match part { + StreamPart::ToolCall { + tool_name, + input, + dynamic, + .. + } => Some((tool_name, input, dynamic)), + _ => None, + }) + .expect("code execution tool call"); + assert_eq!(raw_call.0, "code_execution"); + let raw_input: Value = serde_json::from_str( + raw_call + .1 + .as_str() + .expect("provider input remains raw JSON"), + ) + .expect("valid tool input JSON"); + assert_eq!(raw_input["type"], "programmatic-tool-call"); + assert_eq!(raw_input["code"], "print(2)"); + assert_eq!(raw_call.2, &Some(true)); + + let result = stream_text( + &model, + "Search and analyze", + GenerateTextOptions { + tools: Some(vec![web_tool]), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("stream_text should start") + .consume() + .await + .expect("dynamic code execution should bypass local lookup"); + let call = result.tool_calls.first().expect("code execution call"); + assert_eq!(call.tool_name, "code_execution"); + assert_eq!(call.input["type"], "programmatic-tool-call"); + assert_eq!(call.dynamic, Some(true)); + assert_eq!(call.invalid, None); + } + /// TS: "should support tools with empty parameters in streaming" — a /// tool_use block with only the leading empty `input_json_delta` produces /// a ToolCall whose input is an empty JSON object. @@ -1553,4 +1798,168 @@ mod do_stream { Some("sig-part-1sig-part-2") ); } + + #[tokio::test] + async fn streamed_tool_search_results_follow_their_call_ids() { + let server = MockServer::start().await; + let sse_body = sse_stream(&[ + json!({ + "type": "message_start", + "message": { + "id": "msg_tool_search", + "model": "claude-sonnet-4-5", + "usage": { "input_tokens": 10 }, + }, + }), + json!({ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "server_tool_use", + "id": "bm25_call", + "name": "tool_search_tool_bm25", + "input": { "query": "weather" }, + }, + }), + json!({ "type": "content_block_stop", "index": 0 }), + json!({ + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_search_tool_result", + "tool_use_id": "bm25_call", + "content": { + "type": "tool_search_tool_search_result", + "tool_references": [{ + "type": "tool_reference", + "tool_name": "get_weather", + }], + }, + }, + }), + json!({ "type": "content_block_stop", "index": 1 }), + json!({ + "type": "content_block_start", + "index": 2, + "content_block": { + "type": "server_tool_use", + "id": "regex_call", + "name": "tool_search_tool_regex", + "input": { "pattern": "forecast.*" }, + }, + }), + json!({ "type": "content_block_stop", "index": 2 }), + json!({ + "type": "content_block_start", + "index": 3, + "content_block": { + "type": "tool_search_tool_result", + "tool_use_id": "regex_call", + "content": { + "type": "tool_search_tool_search_result", + "tool_references": [{ + "type": "tool_reference", + "tool_name": "get_forecast", + }], + }, + }, + }), + json!({ "type": "content_block_stop", "index": 3 }), + json!({ + "type": "message_delta", + "delta": { "stop_reason": "end_turn" }, + "usage": { "output_tokens": 20 }, + }), + json!({ "type": "message_stop" }), + ]); + mock_sse(&server, &sse_body).await; + let model = make_model(&server); + let options = CallOptions { + tools: Some(vec![ + Tool::Provider(ProviderTool { + id: "anthropic.tool_search_regex_20251119".to_string(), + name: "regexSearch".to_string(), + args: json!({}), + }), + Tool::Provider(ProviderTool { + id: "anthropic.tool_search_bm25_20251119".to_string(), + name: "semanticSearch".to_string(), + args: json!({}), + }), + ]), + ..default_options(test_prompt()) + }; + + let parts = collect_stream(model.do_stream(&options).await.unwrap()).await; + let result_names: std::collections::HashMap<&str, &str> = parts + .iter() + .filter_map(|part| match part { + StreamPart::ToolResult { + tool_call_id, + tool_name, + .. + } => Some((tool_call_id.as_str(), tool_name.as_str())), + _ => None, + }) + .collect(); + assert_eq!(result_names.get("bm25_call"), Some(&"semanticSearch")); + assert_eq!(result_names.get("regex_call"), Some(&"regexSearch")); + } + + #[tokio::test] + async fn streamed_programmatic_tool_call_keeps_caller_metadata() { + let server = MockServer::start().await; + let sse_body = sse_stream(&[ + json!({ + "type": "message_start", + "message": { + "id": "msg_programmatic", + "model": "claude-3-haiku-20240307", + "usage": { "input_tokens": 100 }, + }, + }), + json!({ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_programmatic", + "name": "test-tool", + "input": { "value": "from code" }, + "caller": { + "type": "code_execution_20260120", + "tool_id": "srvtoolu_code", + }, + }, + }), + json!({ "type": "content_block_stop", "index": 0 }), + json!({ + "type": "message_delta", + "delta": { "stop_reason": "tool_use" }, + "usage": { "output_tokens": 50 }, + }), + json!({ "type": "message_stop" }), + ]); + mock_sse(&server, &sse_body).await; + let model = make_model(&server); + let options = CallOptions { + tools: Some(vec![value_tool()]), + ..default_options(test_prompt()) + }; + + let parts = collect_stream(model.do_stream(&options).await.unwrap()).await; + let metadata = parts.iter().find_map(|part| match part { + StreamPart::ToolCall { + provider_metadata, .. + } => provider_metadata.as_ref(), + _ => None, + }); + assert_eq!( + metadata.expect("caller metadata")["anthropic"]["caller"], + json!({ + "type": "code_execution_20260120", + "toolId": "srvtoolu_code", + }) + ); + } } diff --git a/aimux-providers/tests/anthropic_provider_tools_test.rs b/aimux-providers/tests/anthropic_provider_tools_test.rs index 5bb068d2..68425824 100644 --- a/aimux-providers/tests/anthropic_provider_tools_test.rs +++ b/aimux-providers/tests/anthropic_provider_tools_test.rs @@ -1,4 +1,4 @@ -//! Rust port of the provider-defined-tools section of +//! Rust port of the provider-defined-tools section of //! `anthropic-language-model.test.ts` (doGenerate). //! //! Translated from the Vercel AI SDK TypeScript test suite: @@ -45,6 +45,7 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::content::ContentPart; +use aimux_core::generate::{GenerateTextOptions, generate_text}; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; @@ -553,10 +554,10 @@ mod web_search_tool { let mut options = default_options(test_prompt()); options.tools = Some(vec![provider_tool( "anthropic.web_search_20250305", - "web_search", + "mySearch", json!({ "maxUses": 5 }), )]); - let _result = model.do_generate(&options).await.unwrap(); + let raw_result = model.do_generate(&options).await.unwrap(); // TS expects content to contain: // 1. tool-call (providerExecuted: true, toolName: 'web_search') @@ -565,10 +566,116 @@ mod web_search_tool { // 4. text // The server_tool_use block is surfaced as a tool-call so the turn // round-trips; the result/source mapping is not yet asserted here. - assert!(_result - .content - .iter() - .any(|c| matches!(c, GenerateContent::ToolCall { tool_name, .. } if tool_name == "web_search"))); + assert!(raw_result.content.iter().any( + |c| matches!(c, GenerateContent::ToolCall { tool_name, .. } if tool_name == "mySearch") + )); + + let result = generate_text( + &model, + "Hello", + GenerateTextOptions { + tools: options.tools.clone(), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("renamed server tool should pass Core validation"); + let call = result.tool_calls.first().expect("web search tool call"); + assert_eq!(call.tool_name, "mySearch"); + assert_eq!(call.provider_executed, Some(true)); + assert_eq!(call.invalid, None); + } + + #[tokio::test] + async fn client_executed_provider_tool_uses_caller_name_at_core_boundary() { + let server = MockServer::start().await; + mock_json( + &server, + 200, + json!({ + "type": "message", + "id": "msg_test", + "model": "claude-3-5-sonnet-latest", + "content": [{ + "type": "tool_use", + "id": "tool_1", + "name": "computer", + "input": { "action": "screenshot" }, + }], + "stop_reason": "tool_use", + "usage": { "input_tokens": 10, "output_tokens": 20 }, + }), + ) + .await; + let model = make_model_with_id(&server, "claude-3-5-sonnet-latest"); + let tools = vec![provider_tool( + "anthropic.computer_20250124", + "myComputer", + json!({ "displayWidthPx": 1280, "displayHeightPx": 720 }), + )]; + + let result = generate_text( + &model, + "Take a screenshot", + GenerateTextOptions { + tools: Some(tools), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("renamed client-executed provider tool should validate"); + + let call = result.tool_calls.first().expect("computer tool call"); + assert_eq!(call.tool_name, "myComputer"); + assert_eq!(call.provider_executed, None); + assert_eq!(call.input, json!({ "action": "screenshot" })); + assert_eq!(call.invalid, None); + } + + #[tokio::test] + async fn implicit_code_execution_from_2026_web_tool_is_dynamic() { + let server = MockServer::start().await; + mock_json( + &server, + 200, + json!({ + "type": "message", + "id": "msg_test", + "model": "claude-sonnet-4-5", + "content": [{ + "type": "server_tool_use", + "id": "tool_1", + "name": "code_execution", + "input": { "code": "print(2)" }, + }], + "stop_reason": "end_turn", + "usage": { "input_tokens": 10, "output_tokens": 20 }, + }), + ) + .await; + let model = make_model_with_id(&server, "claude-sonnet-4-5"); + + let result = generate_text( + &model, + "Search, then analyze", + GenerateTextOptions { + tools: Some(vec![provider_tool( + "anthropic.web_search_20260209", + "mySearch", + json!({}), + )]), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("implicit code execution should bypass local tool lookup"); + + let call = result.tool_calls.first().expect("code execution call"); + assert_eq!(call.tool_name, "code_execution"); + assert_eq!(call.provider_executed, Some(true)); + assert_eq!(call.dynamic, Some(true)); + assert_eq!(call.invalid, None); + assert_eq!(call.input["type"], "programmatic-tool-call"); } /// TS: "should handle server-side web search errors" (L3636) @@ -980,6 +1087,108 @@ mod tool_search_tool { #[ignore = "snapshot test �?requires fixture + response parsing for server tools"] async fn should_include_tool_search_bm25_tool_call_and_result_in_content() {} + #[tokio::test] + async fn tool_search_results_follow_their_call_ids_when_both_variants_are_configured() { + let server = MockServer::start().await; + mock_json( + &server, + 200, + json!({ + "type": "message", + "id": "msg_tool_search", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "server_tool_use", + "id": "bm25_call", + "name": "tool_search_tool_bm25", + "input": { "query": "weather" }, + }, + { + "type": "tool_search_tool_result", + "tool_use_id": "bm25_call", + "content": { + "type": "tool_search_tool_search_result", + "tool_references": [{ + "type": "tool_reference", + "tool_name": "get_weather", + }], + }, + }, + { + "type": "server_tool_use", + "id": "regex_call", + "name": "tool_search_tool_regex", + "input": { "pattern": "forecast.*" }, + }, + { + "type": "tool_search_tool_result", + "tool_use_id": "regex_call", + "content": { + "type": "tool_search_tool_search_result", + "tool_references": [{ + "type": "tool_reference", + "tool_name": "get_forecast", + }], + }, + }, + ], + "stop_reason": "end_turn", + "usage": { "input_tokens": 10, "output_tokens": 20 }, + }), + ) + .await; + let model = make_model_with_id(&server, "claude-sonnet-4-5"); + let tools = vec![ + provider_tool( + "anthropic.tool_search_regex_20251119", + "regexSearch", + json!({}), + ), + provider_tool( + "anthropic.tool_search_bm25_20251119", + "semanticSearch", + json!({}), + ), + ]; + + let result = generate_text( + &model, + "Find weather tools", + GenerateTextOptions { + tools: Some(tools), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("both provider tools should validate at the Core boundary"); + + let result_names: HashMap<&str, &str> = result + .raw + .content + .iter() + .filter_map(|part| match part { + GenerateContent::ToolResult { + tool_call_id, + tool_name, + .. + } => Some((tool_call_id.as_str(), tool_name.as_str())), + _ => None, + }) + .collect(); + assert_eq!(result_names.get("bm25_call"), Some(&"semanticSearch")); + assert_eq!(result_names.get("regex_call"), Some(&"regexSearch")); + + let call_names: HashMap<&str, &str> = result + .tool_calls + .iter() + .map(|call| (call.tool_call_id.as_str(), call.tool_name.as_str())) + .collect(); + assert_eq!(call_names.get("bm25_call"), Some(&"semanticSearch")); + assert_eq!(call_names.get("regex_call"), Some(&"regexSearch")); + assert!(result.tool_calls.iter().all(|call| call.invalid.is_none())); + } + /// TS: "should correctly map tool_search_tool_result when result comes without /// server_tool_use in same response" (L4140, bm25 deferred) // TODO: requires GenerateContent variants for tool_search_tool_result diff --git a/aimux-providers/tests/cohere_remaining_test.rs b/aimux-providers/tests/cohere_remaining_test.rs index 36ccf876..51a93b10 100644 --- a/aimux-providers/tests/cohere_remaining_test.rs +++ b/aimux-providers/tests/cohere_remaining_test.rs @@ -441,6 +441,7 @@ fn convert_assistant_tool_call_message() { tool_call_id: "tool-call-1".to_string(), tool_name: "tool-1".to_string(), input: json!({ "test": "This is a tool message" }), + provider_executed: None, thought_signature: None, provider_options: None, }, diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index 7b1f2b76..c9a3c9c0 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -935,7 +935,10 @@ async fn finding_2_anthropic_bash_code_execution_result_passed_through() { let calls = tool_calls(&result.content); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, id, "call and result share the tool_use_id"); - assert_eq!(calls[0].1, "bash_code_execution"); + // The bash variant collapses into the caller's single code_execution + // tool; the wire name survives in the normalized input's `type`. + assert_eq!(calls[0].1, "code_execution"); + assert_eq!(calls[0].2["type"], json!("bash_code_execution")); } #[tokio::test] diff --git a/aimux-providers/tests/google_model_test.rs b/aimux-providers/tests/google_model_test.rs index 9176e1c9..e269d81e 100644 --- a/aimux-providers/tests/google_model_test.rs +++ b/aimux-providers/tests/google_model_test.rs @@ -1886,6 +1886,7 @@ mod request_body { tool_call_id: "call-1".to_string(), tool_name: "weather".to_string(), input: json!({ "location": "SF" }), + provider_executed: None, thought_signature: Some( "EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH".to_string(), ), @@ -1906,6 +1907,75 @@ mod request_body { assert!(part["functionCall"].get("thoughtSignature").is_none()); } + #[test] + fn assistant_server_tool_round_trips_with_shared_google_vertex_wire_shape() { + let prompt: LanguageModelPrompt = vec![ + LanguageModelPromptMessage { + role: Role::User, + content: vec![ContentPart::text("Search the web")], + ..Default::default() + }, + LanguageModelPromptMessage { + role: Role::Assistant, + content: vec![ + ContentPart::ToolCall { + tool_call_id: "logical-call-id".to_string(), + tool_name: "server:GOOGLE_SEARCH_WEB".to_string(), + input: json!(r#"{"query":"Singapore weather"}"#), + provider_executed: Some(true), + thought_signature: None, + provider_options: Some(json!({ + "google": { + "serverToolCallId": "server-call-1", + "serverToolType": "GOOGLE_SEARCH_WEB", + "thoughtSignature": "call-signature" + } + })), + }, + ContentPart::ToolResult { + tool_call_id: "logical-call-id".to_string(), + tool_name: Some("server:GOOGLE_SEARCH_WEB".to_string()), + result: json!({ "results": [{ "title": "Sunny" }] }), + is_error: None, + preliminary: None, + dynamic: None, + provider_options: Some(json!({ + "google": { + "serverToolCallId": "server-call-1", + "serverToolType": "GOOGLE_SEARCH_WEB", + "thoughtSignature": "result-signature" + } + })), + }, + ], + ..Default::default() + }, + ]; + + let body = build_request_body("gemini-3-pro-preview", &default_options(prompt)); + assert_eq!( + body["contents"][1]["parts"], + json!([ + { + "toolCall": { + "toolType": "GOOGLE_SEARCH_WEB", + "args": { "query": "Singapore weather" }, + "id": "server-call-1" + }, + "thoughtSignature": "call-signature" + }, + { + "toolResponse": { + "toolType": "GOOGLE_SEARCH_WEB", + "response": { "results": [{ "title": "Sunny" }] }, + "id": "server-call-1" + }, + "thoughtSignature": "result-signature" + } + ]) + ); + } + // ── tool result → functionResponse part in a user message ───────────────── #[test] diff --git a/aimux-providers/tests/google_provider_tools_test.rs b/aimux-providers/tests/google_provider_tools_test.rs index b6318c21..84374c77 100644 --- a/aimux-providers/tests/google_provider_tools_test.rs +++ b/aimux-providers/tests/google_provider_tools_test.rs @@ -32,9 +32,12 @@ use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::content::ContentPart; +use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + LanguageModelPrompt, LanguageModelPromptMessage, convert_to_language_model_prompt, +}; +use aimux_core::message::{ModelMessage, Role}; use aimux_core::options::{CallOptions, ProviderTool, Tool}; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; @@ -1064,12 +1067,8 @@ mod do_generate { // ── code execution tool calls ───────────────────────────────────────────── // - // The request-body assertion is a normal red test. The content assertions - // for the `tool-call` part compile (`GenerateContent::ToolCall` exists) but - // are red because `executableCode` parts are not parsed. The TS also expects - // a `tool-result` content item with `providerExecuted: true`; Rust's - // `GenerateContent` has no `ToolResult` variant and `ToolCall` has no - // `provider_executed` flag — see the `#[ignore]`'d test below. + // Gemini may emit more than one `codeExecutionResult` for a single + // `executableCode`; every result must retain the originating call id. #[tokio::test] async fn code_execution_request_body_contains_code_execution() { @@ -1150,6 +1149,101 @@ mod do_generate { // `code_execution_tool_result_content` below. } + #[tokio::test] + async fn renamed_code_execution_passes_core_generate_boundary() { + let server = MockServer::start().await; + mock_json_response( + &server, + "gemini-2.0-pro", + json!({ + "candidates": [{ + "content": { + "parts": [ + { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } } + ], + "role": "model" + }, + "finishReason": "STOP" + }] + }), + ) + .await; + let model = provider_at(&server.uri()).model("gemini-2.0-pro"); + let tool = provider_tool("google.code_execution", "runCode", json!({})); + + let result = generate_text( + &model, + "Run code", + GenerateTextOptions { + tools: Some(vec![tool.clone()]), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("renamed code execution tool should pass Core validation"); + + let call = result.tool_calls.first().expect("code execution call"); + assert_eq!(call.tool_name, "runCode"); + assert_eq!(call.provider_executed, Some(true)); + assert_eq!(call.invalid, None); + assert_eq!(call.input["code"], "print(2)"); + assert_eq!( + call.provider_metadata.as_ref().expect("call metadata")["google"], + json!({ + "serverToolCallId": call.tool_call_id, + "serverToolType": "code_execution", + }) + ); + let raw_results: Vec<_> = result + .raw + .content + .iter() + .filter_map(|content| match content { + GenerateContent::ToolResult { + tool_call_id, + tool_name, + result, + provider_metadata, + .. + } if tool_name == "runCode" => Some(( + tool_call_id, + result, + provider_metadata.as_ref().expect("result metadata"), + )), + _ => None, + }) + .collect(); + assert_eq!(raw_results.len(), 2); + assert!(raw_results.iter().all(|(id, _, metadata)| { + *id == &call.tool_call_id + && metadata["google"]["serverToolCallId"] == call.tool_call_id + && metadata["google"]["serverToolType"] == "code_execution" + })); + + let mut messages = vec![ModelMessage::user("Run code")]; + messages.extend(result.response_messages); + messages.push(ModelMessage::user("Continue")); + let mut next_options = CallOptions::new(convert_to_language_model_prompt(&messages, None)); + next_options.tools = Some(vec![tool]); + let replay = build_request_body("gemini-2.0-pro", &next_options); + let assistant = replay["contents"] + .as_array() + .unwrap() + .iter() + .find(|content| content["role"] == "model") + .expect("assistant replay content"); + assert_eq!( + assistant["parts"], + json!([ + { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } }, + ]) + ); + } + /// TS: "should handle code execution tool calls" — the `tool-result` portion. /// /// `GenerateContent::ToolResult` now exists, so the TS assertion is @@ -1167,7 +1261,8 @@ mod do_generate { "content": { "parts": [ { "executableCode": { "language": "PYTHON", "code": "print(1+1)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } } + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } } ], "role": "model" }, @@ -1197,8 +1292,8 @@ mod do_generate { let results = gen_tool_results(&result.content); assert_eq!( results.len(), - 1, - "one codeExecutionResult part → one tool-result" + 2, + "each codeExecutionResult part becomes a tool-result" ); assert_eq!(results[0].1, "code_execution"); assert_eq!( @@ -1210,6 +1305,11 @@ mod do_generate { results[0].0, calls[0].0, "the result must carry the id of the call it answers" ); + assert_eq!(results[1].0, calls[0].0); + assert_eq!( + results[1].2, + json!({ "outcome": "OUTCOME_OK", "output": "still 2" }) + ); assert!( !results[0].0.is_empty(), "tool_call_id must not be the empty string" @@ -1808,7 +1908,10 @@ mod do_stream { json!({ "candidates": [{ "content": { - "parts": [{ "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }] + "parts": [ + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "second result\n" } } + ] }, "finishReason": "STOP" }] @@ -1818,18 +1921,16 @@ mod do_stream { .await; let model = provider_at(&server.uri()).model("gemini-2.0-pro"); + let tool = provider_tool("google.code_execution", "runCode", json!({})); let result = model - .do_stream(&options_with_tools( - test_prompt(), - vec![code_execution_tool()], - )) + .do_stream(&options_with_tools(test_prompt(), vec![tool.clone()])) .await .expect("do_stream should succeed"); let parts = collect_stream(result).await; let calls = stream_tool_calls(&parts); let has_call = calls.iter().any(|(_, name, input)| { - name == "code_execution" + name == "runCode" && *input == json!(r#"{"language":"PYTHON","code":"print(\"hello\")"}"#) }); assert!( @@ -1845,9 +1946,74 @@ mod do_stream { has_result, "expected a code_execution tool-result, got {results:?}" ); - // NOTE: TS also asserts toolName: "code_execution" on the result and - // providerExecuted: true on the call — not expressible on current - // StreamPart variants. + assert_eq!(results.len(), 2); + assert!(results.iter().all(|(id, _)| id == &calls[0].0)); + assert!(parts.iter().any(|part| matches!( + part, + StreamPart::ToolCall { + tool_call_id, + tool_name, + provider_executed: Some(true), + provider_metadata: Some(metadata), + .. + } if tool_name == "runCode" + && metadata["google"] == json!({ + "serverToolCallId": tool_call_id, + "serverToolType": "code_execution", + }) + ))); + assert!(parts.iter().any(|part| matches!( + part, + StreamPart::ToolResult { + tool_call_id, + tool_name, + provider_metadata: Some(metadata), + .. + } if tool_name == "runCode" + && metadata["google"] == json!({ + "serverToolCallId": tool_call_id, + "serverToolType": "code_execution", + }) + ))); + + let result = stream_text( + &model, + "Run code", + GenerateTextOptions { + tools: Some(vec![tool.clone()]), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("stream_text should start") + .consume() + .await + .expect("renamed code execution tool should pass Core validation"); + let call = result.tool_calls.first().expect("code execution call"); + assert_eq!(call.tool_name, "runCode"); + assert_eq!(call.provider_executed, Some(true)); + assert_eq!(call.invalid, None); + + let mut messages = vec![ModelMessage::user("Run code")]; + messages.extend(result.response_messages); + messages.push(ModelMessage::user("Continue")); + let mut next_options = CallOptions::new(convert_to_language_model_prompt(&messages, None)); + next_options.tools = Some(vec![tool]); + let replay = build_request_body("gemini-2.0-pro", &next_options); + let assistant = replay["contents"] + .as_array() + .unwrap() + .iter() + .find(|content| content["role"] == "model") + .expect("assistant replay content"); + assert_eq!( + assistant["parts"], + json!([ + { "executableCode": { "language": "PYTHON", "code": "print(\"hello\")" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "second result\n" } }, + ]) + ); } #[tokio::test] diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index c5ebf629..52fe83bd 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -25,6 +25,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; +use aimux_core::generate::{GenerateTextOptions, generate_text}; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; @@ -573,6 +574,16 @@ async fn should_handle_mcp_tools_with_annotations() { GenerateContent::Source { id, .. } => assert_eq!(id, "id-1"), other => panic!("expected Source at [4], got {other:?}"), } + + let result = generate_text(&model, "Hello", GenerateTextOptions::default()) + .await + .expect("dynamic MCP call should pass Core validation without local tools"); + let call = result.tool_calls.first().expect("MCP tool call"); + assert_eq!(call.tool_name, "search"); + assert_eq!(call.provider_executed, Some(true)); + assert_eq!(call.dynamic, Some(true)); + assert_eq!(call.invalid, None); + assert_eq!(call.input, json!({ "query": "San Francisco tech events" })); } // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/mistral_remaining_test.rs b/aimux-providers/tests/mistral_remaining_test.rs index 5cef6263..b06ba233 100644 --- a/aimux-providers/tests/mistral_remaining_test.rs +++ b/aimux-providers/tests/mistral_remaining_test.rs @@ -384,6 +384,7 @@ fn convert_assistant_tool_call_message() { tool_call_id: "tool-call-1".to_string(), tool_name: "tool-1".to_string(), input: json!({ "test": "This is a tool message" }), + provider_executed: None, thought_signature: None, provider_options: None, }, diff --git a/aimux-providers/tests/vertex_anthropic_test.rs b/aimux-providers/tests/vertex_anthropic_test.rs index d8e5fc85..65e4798e 100644 --- a/aimux-providers/tests/vertex_anthropic_test.rs +++ b/aimux-providers/tests/vertex_anthropic_test.rs @@ -18,7 +18,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; -use aimux_core::options::CallOptions; +use aimux_core::options::{CallOptions, ProviderTool, Tool}; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::FinishReasonUnified; @@ -156,6 +156,46 @@ async fn vertex_anthropic_generate_text_response() { ); } +#[tokio::test] +async fn vertex_anthropic_generate_keeps_direct_caller_metadata() { + let server = MockServer::start().await; + mock_raw_predict_json( + &server, + 200, + json!({ + "id": "msg_direct", + "type": "message", + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_direct", + "name": "get_weather", + "input": { "city": "Tokyo" }, + "caller": { "type": "direct" }, + }], + "model": MODEL_ID, + "stop_reason": "tool_use", + "usage": { "input_tokens": 100, "output_tokens": 50 }, + }), + ) + .await; + + let result = make_model(&server) + .do_generate(&default_options(test_prompt())) + .await + .unwrap(); + let metadata = result.content.iter().find_map(|part| match part { + GenerateContent::ToolCall { + provider_metadata, .. + } => provider_metadata.as_ref(), + _ => None, + }); + assert_eq!( + metadata.expect("caller metadata")["anthropic"]["caller"], + json!({ "type": "direct" }) + ); +} + /// Test: the request hits the `/publishers/anthropic/models/{model}:rawPredict` /// path (i.e. the `/publishers/google` suffix is replaced) and carries the /// Bearer token Authorization header. @@ -304,6 +344,172 @@ async fn vertex_anthropic_stream_text() { } // ═════════════════════════════════════════════════════════════════════════════ +#[tokio::test] +async fn vertex_anthropic_streamed_tool_search_results_follow_their_call_ids() { + let server = MockServer::start().await; + let sse_body = sse_stream(&[ + json!({ + "type": "message_start", + "message": { + "id": "msg_tool_search", + "model": MODEL_ID, + "usage": { "input_tokens": 10 }, + }, + }), + json!({ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "server_tool_use", + "id": "bm25_call", + "name": "tool_search_tool_bm25", + "input": { "query": "weather" }, + }, + }), + json!({ "type": "content_block_stop", "index": 0 }), + json!({ + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_search_tool_result", + "tool_use_id": "bm25_call", + "content": { + "type": "tool_search_tool_search_result", + "tool_references": [{ + "type": "tool_reference", + "tool_name": "get_weather", + }], + }, + }, + }), + json!({ "type": "content_block_stop", "index": 1 }), + json!({ + "type": "content_block_start", + "index": 2, + "content_block": { + "type": "server_tool_use", + "id": "regex_call", + "name": "tool_search_tool_regex", + "input": { "pattern": "forecast.*" }, + }, + }), + json!({ "type": "content_block_stop", "index": 2 }), + json!({ + "type": "content_block_start", + "index": 3, + "content_block": { + "type": "tool_search_tool_result", + "tool_use_id": "regex_call", + "content": { + "type": "tool_search_tool_search_result", + "tool_references": [{ + "type": "tool_reference", + "tool_name": "get_forecast", + }], + }, + }, + }), + json!({ "type": "content_block_stop", "index": 3 }), + json!({ + "type": "message_delta", + "delta": { "stop_reason": "end_turn" }, + "usage": { "output_tokens": 20 }, + }), + json!({ "type": "message_stop" }), + ]); + mock_stream_raw_predict_sse(&server, &sse_body).await; + let model = make_model(&server); + let options = CallOptions { + tools: Some(vec![ + Tool::Provider(ProviderTool { + id: "anthropic.tool_search_regex_20251119".to_string(), + name: "regexSearch".to_string(), + args: json!({}), + }), + Tool::Provider(ProviderTool { + id: "anthropic.tool_search_bm25_20251119".to_string(), + name: "semanticSearch".to_string(), + args: json!({}), + }), + ]), + ..default_options(test_prompt()) + }; + + let parts = collect_stream(model.do_stream(&options).await.unwrap()).await; + let result_names: std::collections::HashMap<&str, &str> = parts + .iter() + .filter_map(|part| match part { + StreamPart::ToolResult { + tool_call_id, + tool_name, + .. + } => Some((tool_call_id.as_str(), tool_name.as_str())), + _ => None, + }) + .collect(); + assert_eq!(result_names.get("bm25_call"), Some(&"semanticSearch")); + assert_eq!(result_names.get("regex_call"), Some(&"regexSearch")); +} + +#[tokio::test] +async fn vertex_anthropic_stream_keeps_programmatic_caller_metadata() { + let server = MockServer::start().await; + let sse_body = sse_stream(&[ + json!({ + "type": "message_start", + "message": { + "id": "msg_programmatic", + "model": MODEL_ID, + "usage": { "input_tokens": 100 }, + }, + }), + json!({ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_programmatic", + "name": "query_database", + "input": { "sql": "SELECT 1" }, + "caller": { + "type": "code_execution_20250825", + "tool_id": "srvtoolu_code", + }, + }, + }), + json!({ "type": "content_block_stop", "index": 0 }), + json!({ + "type": "message_delta", + "delta": { "stop_reason": "tool_use" }, + "usage": { "output_tokens": 50 }, + }), + json!({ "type": "message_stop" }), + ]); + mock_stream_raw_predict_sse(&server, &sse_body).await; + let model = make_model(&server); + + let parts = collect_stream( + model + .do_stream(&default_options(test_prompt())) + .await + .unwrap(), + ) + .await; + let metadata = parts.iter().find_map(|part| match part { + StreamPart::ToolCall { + provider_metadata, .. + } => provider_metadata.as_ref(), + _ => None, + }); + assert_eq!( + metadata.expect("caller metadata")["anthropic"]["caller"], + json!({ + "type": "code_execution_20250825", + "toolId": "srvtoolu_code", + }) + ); +} + // Error handling // ═════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/vertex_model_test.rs b/aimux-providers/tests/vertex_model_test.rs index 5205efeb..220c2846 100644 --- a/aimux-providers/tests/vertex_model_test.rs +++ b/aimux-providers/tests/vertex_model_test.rs @@ -15,10 +15,13 @@ use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::content::ContentPart; +use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; +use aimux_core::language_model_message::{ + LanguageModelPrompt, LanguageModelPromptMessage, convert_to_language_model_prompt, +}; +use aimux_core::message::{ModelMessage, Role}; +use aimux_core::options::{CallOptions, ProviderTool, Tool}; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::FinishReasonUnified; @@ -145,7 +148,6 @@ async fn vertex_generate_text_response() { }), ) .await; - let model = make_model(&server); let result = model .do_generate(&default_options(test_prompt())) @@ -157,6 +159,12 @@ async fn vertex_generate_text_response() { assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); assert_eq!(result.usage.input_tokens.total, Some(5)); assert_eq!(result.usage.output_tokens.total, Some(10)); + let metadata = result + .provider_metadata + .as_ref() + .expect("provider metadata"); + assert_eq!(metadata["vertex"], metadata["googleVertex"]); + assert!(metadata.get("google").is_none()); } /// Test: non-streaming tool call extraction. @@ -202,6 +210,186 @@ async fn vertex_generate_tool_call() { assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } +#[tokio::test] +async fn vertex_renamed_code_execution_passes_core_generate_boundary() { + let server = MockServer::start().await; + mock_generate_content( + &server, + json!({ + "candidates": [{ + "content": { + "role": "model", + "parts": [ + { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } } + ] + }, + "finishReason": "STOP" + }] + }), + ) + .await; + let model = make_model(&server); + let tool = Tool::Provider(ProviderTool { + id: "google.code_execution".to_string(), + name: "runCode".to_string(), + args: json!({}), + }); + + let result = generate_text( + &model, + "Run code", + GenerateTextOptions { + tools: Some(vec![tool.clone()]), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("renamed Vertex code execution should pass Core validation"); + let call = result.tool_calls.first().expect("code execution call"); + assert_eq!(call.tool_name, "runCode"); + assert_eq!(call.provider_executed, Some(true)); + assert_eq!(call.invalid, None); + let call_metadata = call.provider_metadata.as_ref().expect("call metadata"); + assert_eq!( + call_metadata["googleVertex"], + json!({ + "serverToolCallId": call.tool_call_id, + "serverToolType": "code_execution", + }) + ); + assert_eq!(call_metadata["vertex"], call_metadata["googleVertex"]); + assert!(call_metadata.get("google").is_none()); + assert!(result.raw.content.iter().any(|content| matches!( + content, + GenerateContent::ToolResult { tool_name, .. } if tool_name == "runCode" + ))); + let result_ids: Vec<&str> = result + .raw + .content + .iter() + .filter_map(|content| match content { + GenerateContent::ToolResult { tool_call_id, .. } => Some(tool_call_id.as_str()), + _ => None, + }) + .collect(); + assert_eq!(result_ids.len(), 2); + assert!( + result_ids + .iter() + .all(|id| *id == call.tool_call_id.as_str()) + ); + + let mut messages = vec![ModelMessage::user("Run code")]; + messages.extend(result.response_messages); + messages.push(ModelMessage::user("Continue")); + let mut next_options = CallOptions::new(convert_to_language_model_prompt(&messages, None)); + next_options.tools = Some(vec![tool]); + let replay = model + .do_generate(&next_options) + .await + .expect("Vertex replay request should succeed") + .request_body + .expect("request body"); + let assistant = replay["contents"] + .as_array() + .unwrap() + .iter() + .find(|content| content["role"] == "model") + .expect("assistant replay content"); + assert_eq!( + assistant["parts"], + json!([ + { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } }, + ]) + ); +} + +#[tokio::test] +async fn vertex_server_tool_call_and_response_pass_core_generate_boundary() { + let server = MockServer::start().await; + mock_generate_content( + &server, + json!({ + "candidates": [{ + "content": { + "role": "model", + "parts": [ + { "toolCall": { + "toolType": "GOOGLE_SEARCH_WEB", + "args": { "query": "Singapore weather" }, + "id": "server-call-1" + }, "thoughtSignature": "call-signature" }, + { "toolResponse": { + "toolType": "GOOGLE_SEARCH_WEB", + "response": { "results": [{ "title": "Sunny" }] }, + "id": "server-call-1" + }, "thoughtSignature": "result-signature" } + ] + }, + "finishReason": "STOP" + }] + }), + ) + .await; + + let model = make_model(&server); + let result = generate_text(&model, "Search the web", GenerateTextOptions::default()) + .await + .expect("dynamic Vertex server tool should pass Core validation"); + + let call = result.tool_calls.first().expect("server tool call"); + assert_eq!(call.tool_call_id, "server-call-1"); + assert_eq!(call.tool_name, "server:GOOGLE_SEARCH_WEB"); + assert_eq!(call.input, json!({ "query": "Singapore weather" })); + assert_eq!(call.provider_executed, Some(true)); + assert_eq!(call.dynamic, Some(true)); + assert_eq!(call.invalid, None); + assert_eq!( + call.provider_metadata.as_ref().expect("call metadata")["googleVertex"], + json!({ + "serverToolCallId": "server-call-1", + "serverToolType": "GOOGLE_SEARCH_WEB", + "thoughtSignature": "call-signature" + }) + ); + assert_eq!( + call.provider_metadata.as_ref().unwrap()["vertex"], + call.provider_metadata.as_ref().unwrap()["googleVertex"] + ); + assert!( + call.provider_metadata + .as_ref() + .unwrap() + .get("google") + .is_none() + ); + assert!(result.raw.content.iter().any(|content| matches!( + content, + GenerateContent::ToolResult { + tool_call_id, + tool_name, + result, + dynamic: None, + provider_metadata: Some(metadata), + .. + } if tool_call_id == "server-call-1" + && tool_name == "server:GOOGLE_SEARCH_WEB" + && *result == json!({ "results": [{ "title": "Sunny" }] }) + && metadata["googleVertex"] == json!({ + "serverToolCallId": "server-call-1", + "serverToolType": "GOOGLE_SEARCH_WEB", + "thoughtSignature": "result-signature" + }) + && metadata["vertex"] == metadata["googleVertex"] + && metadata.get("google").is_none() + ))); + assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); +} + /// Vertex Gemini thinking models echo `thoughtSignature` on `functionCall` /// parts the same way the public Gemini API does — it must be preserved. #[tokio::test] @@ -245,6 +433,7 @@ async fn vertex_generate_tool_call_with_thought_signature() { tool_name, input, thought_signature, + provider_metadata, .. } => { assert_eq!(tool_call_id, "call_1"); @@ -254,6 +443,13 @@ async fn vertex_generate_tool_call_with_thought_signature() { thought_signature.as_deref(), Some("EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH") ); + let metadata = provider_metadata.as_ref().expect("thought metadata"); + assert_eq!( + metadata["googleVertex"]["thoughtSignature"], + json!("EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH") + ); + assert_eq!(metadata["vertex"], metadata["googleVertex"]); + assert!(metadata.get("google").is_none()); } other => panic!("expected ToolCall, got {other:?}"), } @@ -728,7 +924,10 @@ async fn vertex_stream_code_execution_tool_calls_and_results() { json!({ "candidates": [{ "content": { - "parts": [{ "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }] + "parts": [ + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "second result\n" } } + ] }, "finishReason": "STOP" }] @@ -736,18 +935,36 @@ async fn vertex_stream_code_execution_tool_calls_and_results() { ]), ) .await; + mock_generate_content( + &server, + json!({ + "candidates": [{ + "content": { "parts": [{ "text": "ok" }] }, + "finishReason": "STOP" + }] + }), + ) + .await; let model = make_model(&server); + let tool = Tool::Provider(ProviderTool { + id: "google.code_execution".to_string(), + name: "runCode".to_string(), + args: json!({}), + }); + let options = CallOptions { + tools: Some(vec![tool.clone()]), + ..default_options(test_prompt()) + }; let result = model - .do_stream(&default_options(test_prompt())) + .do_stream(&options) .await .expect("do_stream should succeed"); let parts = collect_stream(result).await; let calls = stream_tool_calls(&parts); let has_call = calls.iter().any(|(_, name, input)| { - name == "code_execution" - && *input == json!(r#"{"language":"PYTHON","code":"print(\"hello\")"}"#) + name == "runCode" && *input == json!(r#"{"language":"PYTHON","code":"print(\"hello\")"}"#) }); assert!( has_call, @@ -758,7 +975,7 @@ async fn vertex_stream_code_execution_tool_calls_and_results() { let results = stream_tool_results(&parts); let call_id = calls .iter() - .find(|(_, name, _)| name == "code_execution") + .find(|(_, name, _)| name == "runCode") .map(|(id, _, _)| id.clone()) .expect("code_execution call id"); let has_result = results.iter().any(|(id, output)| { @@ -768,6 +985,38 @@ async fn vertex_stream_code_execution_tool_calls_and_results() { has_result, "expected a code_execution tool-result, got {results:?}" ); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|(id, _)| id == &call_id)); + assert!(parts.iter().any(|part| matches!( + part, + StreamPart::ToolCall { + tool_call_id, + tool_name, + provider_metadata: Some(metadata), + .. + } if tool_name == "runCode" + && metadata["googleVertex"] == json!({ + "serverToolCallId": tool_call_id, + "serverToolType": "code_execution", + }) + && metadata["vertex"] == metadata["googleVertex"] + && metadata.get("google").is_none() + ))); + assert!(parts.iter().any(|part| matches!( + part, + StreamPart::ToolResult { + tool_call_id, + tool_name, + provider_metadata: Some(metadata), + .. + } if tool_name == "runCode" + && metadata["googleVertex"] == json!({ + "serverToolCallId": tool_call_id, + "serverToolType": "code_execution", + }) + && metadata["vertex"] == metadata["googleVertex"] + && metadata.get("google").is_none() + ))); // Provider-executed tool → Stop, not ToolCalls. let finish = parts.iter().find_map(|p| match p { @@ -778,6 +1027,50 @@ async fn vertex_stream_code_execution_tool_calls_and_results() { finish.expect("finish part").unified, FinishReasonUnified::Stop ); + + let result = stream_text( + &model, + "Run code", + GenerateTextOptions { + tools: Some(vec![tool.clone()]), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("stream_text should start") + .consume() + .await + .expect("renamed Vertex code execution should pass Core validation"); + let call = result.tool_calls.first().expect("code execution call"); + assert_eq!(call.tool_name, "runCode"); + assert_eq!(call.provider_executed, Some(true)); + assert_eq!(call.invalid, None); + + let mut messages = vec![ModelMessage::user("Run code")]; + messages.extend(result.response_messages); + messages.push(ModelMessage::user("Continue")); + let mut next_options = CallOptions::new(convert_to_language_model_prompt(&messages, None)); + next_options.tools = Some(vec![tool]); + let replay = model + .do_generate(&next_options) + .await + .expect("Vertex stream replay request should succeed") + .request_body + .expect("request body"); + let assistant = replay["contents"] + .as_array() + .unwrap() + .iter() + .find(|content| content["role"] == "model") + .expect("assistant replay content"); + assert_eq!( + assistant["parts"], + json!([ + { "executableCode": { "language": "PYTHON", "code": "print(\"hello\")" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "second result\n" } }, + ]) + ); } /// TS: "should stream code execution result with missing output field". @@ -902,6 +1195,50 @@ async fn vertex_stream_server_tool_call_and_response() { finish.expect("finish part").unified, FinishReasonUnified::Stop ); + + let result = stream_text(&model, "Search the web", GenerateTextOptions::default()) + .await + .expect("stream_text should start"); + let mut core_stream = result.stream; + let mut core_parts = Vec::new(); + while let Some(part) = core_stream.next().await { + core_parts.push(part.expect("Core stream part should succeed")); + } + + assert!(core_parts.iter().any(|part| matches!( + part, + StreamPart::ToolCall { + tool_call_id, + tool_name, + input, + provider_executed: Some(true), + dynamic: Some(true), + invalid: None, + provider_metadata: Some(metadata), + .. + } if tool_call_id == "server-call-1" + && tool_name == "server:GOOGLE_SEARCH_WEB" + && *input == json!({ "query": "San Francisco weather" }) + && metadata["googleVertex"]["serverToolCallId"] == "server-call-1" + && metadata["vertex"] == metadata["googleVertex"] + && metadata.get("google").is_none() + ))); + assert!(core_parts.iter().any(|part| matches!( + part, + StreamPart::ToolResult { + tool_call_id, + tool_name, + result, + dynamic: None, + provider_metadata: Some(metadata), + .. + } if tool_call_id == "server-call-1" + && tool_name == "server:GOOGLE_SEARCH_WEB" + && *result == json!({ "results": [{ "title": "Weather in SF" }] }) + && metadata["googleVertex"]["serverToolType"] == "GOOGLE_SEARCH_WEB" + && metadata["vertex"] == metadata["googleVertex"] + && metadata.get("google").is_none() + ))); } /// TS: "should stream source events" + "should deduplicate sources across @@ -1024,6 +1361,8 @@ async fn vertex_stream_finish_provider_metadata() { let pm = finish_provider_metadata(&parts).expect("finish part"); let vertex = &pm["googleVertex"]; + assert_eq!(&pm["vertex"], vertex); + assert!(pm.get("google").is_none()); assert!( !vertex.is_null() && vertex.as_object().map(|o| !o.is_empty()).unwrap_or(false), "googleVertex provider metadata should be non-empty, got {pm}" diff --git a/aimux-providers/tests/xai_responses_test.rs b/aimux-providers/tests/xai_responses_test.rs index ba25f913..24f936a7 100644 --- a/aimux-providers/tests/xai_responses_test.rs +++ b/aimux-providers/tests/xai_responses_test.rs @@ -1,4 +1,4 @@ -//! Rust translations of the AI SDK xAI Responses API tests. +//! Rust translations of the AI SDK xAI Responses API tests. //! //! Sources (TS → Rust): //! - `xai-responses-language-model.test.ts` → `do_generate` / `do_stream` mods @@ -18,6 +18,7 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::content::ContentPart; +use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::message::Role; @@ -1034,6 +1035,87 @@ mod settings { mod tools { use super::*; + #[tokio::test] + async fn renamed_view_tools_pass_core_validation() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/responses")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "resp_123", + "object": "response", + "status": "completed", + "model": "grok-4-fast-non-reasoning", + "output": [ + { + "type": "view_image_call", + "id": "image_1", + "name": "view_image", + "arguments": "{\"image_url\":\"https://example.com/image.png\"}", + "status": "completed" + }, + { + "type": "view_x_video_call", + "id": "video_1", + "name": "view_x_video", + "arguments": "{\"video_url\":\"https://x.com/i/status/1\"}", + "status": "completed" + } + ], + "usage": { "input_tokens": 10, "output_tokens": 5 } + }))) + .mount(&server) + .await; + + let provider = make_provider(&server); + let model = provider.responses_model("grok-4-fast-non-reasoning"); + let result = generate_text( + &model, + "Inspect the media", + GenerateTextOptions { + tools: Some(vec![ + Tool::Provider(ProviderTool { + id: "xai.view_image".to_string(), + name: "inspectImage".to_string(), + args: json!({}), + }), + Tool::Provider(ProviderTool { + id: "xai.view_x_video".to_string(), + name: "inspectVideo".to_string(), + args: json!({}), + }), + ]), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("renamed xAI view tools should pass Core validation"); + + assert_eq!(result.tool_calls.len(), 2); + assert_eq!(result.tool_calls[0].tool_name, "inspectImage"); + assert_eq!(result.tool_calls[1].tool_name, "inspectVideo"); + assert!( + result + .tool_calls + .iter() + .all(|call| { call.provider_executed == Some(true) && call.invalid.is_none() }) + ); + + let next_turn_prompt = aimux_core::language_model_message::convert_to_language_model_prompt( + &result.response_messages, + None, + ); + let (next_turn_input, warnings) = + aimux_providers::xai::responses::convert::convert_to_xai_responses_input( + &next_turn_prompt, + ) + .expect("Core response messages should convert for the next xAI turn"); + assert!(warnings.is_empty()); + assert!( + next_turn_input.is_empty(), + "provider-executed calls must not be replayed as client function calls" + ); + } + /// TS: should send web_search tool with args in request #[tokio::test] async fn web_search_tool_request() { @@ -1905,7 +1987,7 @@ mod do_stream { let options = CallOptions { tools: Some(vec![Tool::Provider(ProviderTool { id: "xai.web_search".to_string(), - name: "web_search".to_string(), + name: "mySearch".to_string(), args: json!({}), })]), ..default_options(test_prompt()) @@ -1925,9 +2007,36 @@ mod do_stream { }) = tool_call { assert_eq!(tool_call_id, "ws_123"); - assert_eq!(tool_name, "web_search"); + assert_eq!(tool_name, "mySearch"); assert_eq!(input, "{\"query\":\"test\"}"); } + + assert!(parts.iter().any(|part| matches!( + part, + StreamPart::ToolInputStart { + tool_name, + provider_executed: Some(true), + .. + } if tool_name == "mySearch" + ))); + + let result = stream_text( + &model, + "Search", + GenerateTextOptions { + tools: options.tools.clone(), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("stream_text should start") + .consume() + .await + .expect("renamed provider tool should pass Core validation"); + let call = result.tool_calls.first().expect("web search call"); + assert_eq!(call.tool_name, "mySearch"); + assert_eq!(call.provider_executed, Some(true)); + assert_eq!(call.invalid, None); } /// TS: should stream function tool call arguments @@ -2843,6 +2952,52 @@ mod convert_input { ); } + /// AI SDK: provider-executed calls are not replayed as client function calls. + #[test] + fn skips_provider_executed_tool_call_without_provider_options() { + let prompt = vec![LanguageModelPromptMessage { + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + tool_call_id: "server_call_123".to_string(), + tool_name: "web_search".to_string(), + input: json!({ "query": "weather" }), + provider_executed: Some(true), + thought_signature: None, + provider_options: None, + }], + ..Default::default() + }]; + + let (input, warnings) = convert_to_xai_responses_input(&prompt).unwrap(); + assert!(warnings.is_empty()); + assert!(input.is_empty()); + } + + /// An explicit false value must override stale legacy provider metadata. + #[test] + fn explicit_client_execution_overrides_legacy_provider_metadata() { + let prompt = vec![LanguageModelPromptMessage { + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + tool_call_id: "call_123".to_string(), + tool_name: "weather".to_string(), + input: json!({ "city": "Singapore" }), + provider_executed: Some(false), + thought_signature: None, + provider_options: Some(json!({ + "xai": { "providerExecuted": true } + })), + }], + ..Default::default() + }]; + + let (input, warnings) = convert_to_xai_responses_input(&prompt).unwrap(); + assert!(warnings.is_empty()); + assert_eq!(input.len(), 1); + assert_eq!(input[0]["type"], "function_call"); + assert_eq!(input[0]["call_id"], "call_123"); + } + /// TS: should convert tool results #[test] fn tool_result() { diff --git a/bindings/flutter/lib/types.dart b/bindings/flutter/lib/types.dart index a21ec5ff..7a8271ba 100644 --- a/bindings/flutter/lib/types.dart +++ b/bindings/flutter/lib/types.dart @@ -161,8 +161,9 @@ class ToolCall { final bool? isDynamic; @JsonKey(name: 'thought_signature') final String? thoughtSignature; - @JsonKey(name: 'provider_metadata') - final Map? providerMetadata; + /// Additional provider-specific metadata associated with this call. + @JsonKey(name: 'provider_metadata', includeIfNull: false) + final dynamic providerMetadata; /// Set by Core when the tool call stays invalid after optional repair. final bool? invalid; /// The typed lookup, parse, schema, or repair failure for an invalid call. @@ -1817,15 +1818,17 @@ final class ContentPartToolCall extends ContentPart { final String toolCallId; final String toolName; final dynamic input; + final bool? providerExecuted; final String? thoughtSignature; final Map? providerOptions; const ContentPartToolCall( - {required this.toolCallId, required this.toolName, required this.input, this.thoughtSignature, this.providerOptions}); + {required this.toolCallId, required this.toolName, required this.input, this.providerExecuted, this.thoughtSignature, this.providerOptions}); static ContentPartToolCall fromJson(Map json) => ContentPartToolCall( toolCallId: json['tool_call_id'] as String, toolName: json['tool_name'] as String, input: json['input'], + providerExecuted: json['provider_executed'] as bool?, thoughtSignature: json['thought_signature'] as String?, providerOptions: json['provider_options'] as Map?, ); @@ -1835,6 +1838,7 @@ final class ContentPartToolCall extends ContentPart { 'tool_call_id': toolCallId, 'tool_name': toolName, 'input': input, + if (providerExecuted != null) 'provider_executed': providerExecuted, if (thoughtSignature != null) 'thought_signature': thoughtSignature, if (providerOptions != null) 'provider_options': providerOptions, }; diff --git a/bindings/flutter/lib/types.g.dart b/bindings/flutter/lib/types.g.dart index b938b25f..a0a8f56f 100644 --- a/bindings/flutter/lib/types.g.dart +++ b/bindings/flutter/lib/types.g.dart @@ -58,8 +58,7 @@ ToolCall _$ToolCallFromJson(Map json) => ToolCall( providerExecuted: json['provider_executed'] as bool?, isDynamic: json['dynamic'] as bool?, thoughtSignature: json['thought_signature'] as String?, - providerMetadata: - json['provider_metadata'] as Map?, + providerMetadata: json['provider_metadata'], invalid: json['invalid'] as bool?, error: json['error'], ); @@ -71,7 +70,8 @@ Map _$ToolCallToJson(ToolCall instance) => { 'provider_executed': instance.providerExecuted, 'dynamic': instance.isDynamic, 'thought_signature': instance.thoughtSignature, - 'provider_metadata': instance.providerMetadata, + if (instance.providerMetadata != null) + 'provider_metadata': instance.providerMetadata, 'invalid': instance.invalid, 'error': instance.error, }; diff --git a/bindings/flutter/test/content_part_test.dart b/bindings/flutter/test/content_part_test.dart index 01484863..898211e9 100644 --- a/bindings/flutter/test/content_part_test.dart +++ b/bindings/flutter/test/content_part_test.dart @@ -171,17 +171,20 @@ void main() { toolCallId: 'call_1', toolName: 'get_weather', input: {'location': 'Tokyo'}, + providerExecuted: true, ); final json = original.toJson(); expect(json['type'], 'tool_call'); expect(json['tool_call_id'], 'call_1'); expect(json['tool_name'], 'get_weather'); + expect(json['provider_executed'], true); final decoded = ContentPart.fromJson(json); expect(decoded, isA()); final d = decoded as ContentPartToolCall; expect(d.toolCallId, 'call_1'); expect(d.toolName, 'get_weather'); expect(d.input, {'location': 'Tokyo'}); + expect(d.providerExecuted, true); }); test('ToolResult variant — uses result, not output', () { diff --git a/bindings/flutter/test/contract_test.dart b/bindings/flutter/test/contract_test.dart index e7c1919e..561130b9 100644 --- a/bindings/flutter/test/contract_test.dart +++ b/bindings/flutter/test/contract_test.dart @@ -134,5 +134,23 @@ void main() { expect(opts.seed, 42); expect(opts.maxRetries, 3); }); + + test('provider-executed transcript uses typed content parts', () { + final fixture = _loadFixtures().firstWhere( + (f) => f['name'] == 'model_message_provider_executed_tool_transcript', + ); + final message = jsonDecode(fixture['json'] as String) as Map; + final content = (message['content'] as List) + .cast>(); + final call = ContentPart.fromJson(content[0]) as ContentPartToolCall; + final result = ContentPart.fromJson(content[1]) as ContentPartToolResult; + + expect(call.providerExecuted, true); + expect(call.toolName, 'search'); + expect(result.toolName, 'search'); + expect(result.isError, false); + expect(result.preliminary, true); + expect(result.isDynamic, true); + }); }); } diff --git a/bindings/flutter/test/typed_round_trip_test.dart b/bindings/flutter/test/typed_round_trip_test.dart index c1ede0a3..0bb43261 100644 --- a/bindings/flutter/test/typed_round_trip_test.dart +++ b/bindings/flutter/test/typed_round_trip_test.dart @@ -33,24 +33,42 @@ Map deepFlatten(Map json) => jsonDecode(jsonEncode(json)) as Map; void main() { - test('ToolCall preserves provider_metadata', () { - final original = ToolCall( - toolCallId: 'call_1', - toolName: 'get_weather', - input: {'location': 'Tokyo'}, - providerMetadata: { - 'openai': {'item_id': 'item_1'}, - }, - ); + group('ToolCall round-trip', () { + test('preserves provider_metadata', () { + final original = ToolCall( + toolCallId: 'call_1', + toolName: 'get_weather', + input: {'city': 'Paris'}, + providerMetadata: { + 'openai': {'itemId': 'item_1'}, + }, + ); + final json = original.toJson(); + expect(json['provider_metadata'], { + 'openai': {'itemId': 'item_1'}, + }); - final json = original.toJson(); - expect(json['provider_metadata'], { - 'openai': {'item_id': 'item_1'}, + final decoded = ToolCall.fromJson(json); + expect(decoded.providerMetadata, original.providerMetadata); }); - final decoded = ToolCall.fromJson(json); - expect(decoded.providerMetadata, { - 'openai': {'item_id': 'item_1'}, + test('provider_metadata accepts arbitrary JSON and omits null', () { + final scalar = ToolCall( + toolCallId: 'call_scalar', + toolName: 'tool', + input: const {}, + providerMetadata: 'opaque-provider-token', + ); + expect(scalar.toJson()['provider_metadata'], 'opaque-provider-token'); + expect(ToolCall.fromJson(scalar.toJson()).providerMetadata, + 'opaque-provider-token'); + + final absent = ToolCall( + toolCallId: 'call_absent', + toolName: 'tool', + input: const {}, + ); + expect(absent.toJson(), isNot(contains('provider_metadata'))); }); }); diff --git a/bindings/go/roundtrip_test.go b/bindings/go/roundtrip_test.go index 54843bb9..091ea918 100644 --- a/bindings/go/roundtrip_test.go +++ b/bindings/go/roundtrip_test.go @@ -116,7 +116,7 @@ func TestToolCallRoundTrip(t *testing.T) { Input: json.RawMessage(`{"location":"Tokyo"}`), ProviderExecuted: &pe, Dynamic: &dyn, - ProviderMetadata: json.RawMessage(`{"openai":{"item_id":"item_1"}}`), + ProviderMetadata: json.RawMessage(`{"openai":{"itemId":"item_1"}}`), } b, err := json.Marshal(original) if err != nil { diff --git a/bindings/go/types.go b/bindings/go/types.go index ffb3aee6..cc75fe58 100644 --- a/bindings/go/types.go +++ b/bindings/go/types.go @@ -109,6 +109,7 @@ type ToolCall struct { ProviderExecuted *bool `json:"provider_executed,omitempty"` Dynamic *bool `json:"dynamic,omitempty"` ThoughtSignature *string `json:"thought_signature,omitempty"` + // ProviderMetadata carries provider-specific data associated with this call. ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` // Invalid is set by Core when the tool call stays invalid after optional repair. Invalid *bool `json:"invalid,omitempty"` diff --git a/bindings/go/wire_format_test.go b/bindings/go/wire_format_test.go index c54932e3..4a283411 100644 --- a/bindings/go/wire_format_test.go +++ b/bindings/go/wire_format_test.go @@ -15,9 +15,18 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "testing" ) +func jsonStructurallyEqual(a, b string) bool { + var av, bv any + if json.Unmarshal([]byte(a), &av) != nil || json.Unmarshal([]byte(b), &bv) != nil { + return false + } + return reflect.DeepEqual(av, bv) +} + // fixtureCase is a single entry in the wire-format.json fixture. type fixtureCase struct { Name string `json:"name"` @@ -64,7 +73,7 @@ func TestWireFormatConsistency(t *testing.T) { if err := json.Unmarshal([]byte(wireJSON), &tc2); err != nil { t.Fatalf("failed to unmarshal ToolChoice %s: %v", wireJSON, err) } - if string(tc2) != wireJSON { + if !jsonStructurallyEqual(string(tc2), wireJSON) { t.Errorf("round-trip mismatch: got %s, want %s", tc2, wireJSON) } @@ -163,7 +172,7 @@ func TestWireFormatConsistency(t *testing.T) { t.Fatalf("failed to unmarshal Role: %v", err) } reencoded, _ := json.Marshal(r) - if string(reencoded) != wireJSON { + if !jsonStructurallyEqual(string(reencoded), wireJSON) { t.Errorf("round-trip mismatch: got %s, want %s", reencoded, wireJSON) } @@ -180,7 +189,7 @@ func TestWireFormatConsistency(t *testing.T) { } // Round-trip: re-encode and verify structure. reencoded, _ := json.Marshal(msg) - if string(reencoded) != wireJSON { + if !jsonStructurallyEqual(string(reencoded), wireJSON) { t.Errorf("round-trip mismatch: got %s, want %s", reencoded, wireJSON) } @@ -190,7 +199,7 @@ func TestWireFormatConsistency(t *testing.T) { t.Fatalf("failed to unmarshal FinishReasonUnified: %v", err) } reencoded, _ := json.Marshal(fr) - if string(reencoded) != wireJSON { + if !jsonStructurallyEqual(string(reencoded), wireJSON) { t.Errorf("round-trip mismatch: got %s, want %s", reencoded, wireJSON) } @@ -200,7 +209,7 @@ func TestWireFormatConsistency(t *testing.T) { t.Fatalf("failed to unmarshal ReasoningEffort: %v", err) } reencoded, _ := json.Marshal(re) - if string(reencoded) != wireJSON { + if !jsonStructurallyEqual(string(reencoded), wireJSON) { t.Errorf("round-trip mismatch: got %s, want %s", reencoded, wireJSON) } @@ -235,7 +244,7 @@ func TestWireFormatConsistency(t *testing.T) { if err := json.Unmarshal([]byte(wireJSON), &gc); err != nil { t.Fatalf("failed to unmarshal GenerateContent %s: %v", wireJSON, err) } - if string(gc) != wireJSON { + if !jsonStructurallyEqual(string(gc), wireJSON) { t.Errorf("round-trip mismatch: got %s, want %s", gc, wireJSON) } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Types.java b/bindings/java/src/main/java/ai/arcships/aimux/Types.java index 024b67ff..0795f223 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Types.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Types.java @@ -409,6 +409,7 @@ public static class ToolCall { @JsonProperty("input") private JsonNode input = emptyObject(); @JsonProperty("provider_executed") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; + @JsonProperty("thought_signature") private String thoughtSignature; @JsonProperty("provider_metadata") private JsonNode providerMetadata; @JsonProperty("invalid") private Boolean invalid; @JsonProperty("error") private JsonNode error; @@ -417,12 +418,13 @@ public static class ToolCall { ToolCall() {} private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean providerExecuted, Boolean dynamic, - JsonNode providerMetadata, Boolean invalid, JsonNode error) { + String thoughtSignature, JsonNode providerMetadata, Boolean invalid, JsonNode error) { this.toolCallId = toolCallId; this.toolName = toolName; this.input = input; this.providerExecuted = providerExecuted; this.dynamic = dynamic; + this.thoughtSignature = thoughtSignature; this.providerMetadata = providerMetadata; this.invalid = invalid; this.error = error; @@ -433,6 +435,7 @@ private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean pro public JsonNode getInput() { return input; } public Boolean getProviderExecuted() { return providerExecuted; } public Boolean getDynamic() { return dynamic; } + public String getThoughtSignature() { return thoughtSignature; } public JsonNode getProviderMetadata() { return providerMetadata; } /** Set by Core when the tool call stays invalid after optional repair. */ public Boolean getInvalid() { return invalid; } @@ -447,6 +450,7 @@ public static class Builder { private JsonNode input = emptyObject(); private Boolean providerExecuted; private Boolean dynamic; + private String thoughtSignature; private JsonNode providerMetadata; private Boolean invalid; private JsonNode error; @@ -456,13 +460,14 @@ public static class Builder { public Builder input(JsonNode v) { this.input = v; return this; } public Builder providerExecuted(Boolean v) { this.providerExecuted = v; return this; } public Builder dynamic(Boolean v) { this.dynamic = v; return this; } + public Builder thoughtSignature(String v) { this.thoughtSignature = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } public Builder invalid(Boolean v) { this.invalid = v; return this; } public Builder error(JsonNode v) { this.error = v; return this; } public ToolCall build() { - return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata, invalid, - error); + return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, providerMetadata, + invalid, error); } } @@ -476,6 +481,7 @@ public boolean equals(Object o) { && Objects.equals(input, that.input) && Objects.equals(providerExecuted, that.providerExecuted) && Objects.equals(dynamic, that.dynamic) + && Objects.equals(thoughtSignature, that.thoughtSignature) && Objects.equals(providerMetadata, that.providerMetadata) && Objects.equals(invalid, that.invalid) && Objects.equals(error, that.error); @@ -483,8 +489,8 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata, invalid, - error); + return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, providerMetadata, + invalid, error); } } @@ -1265,21 +1271,28 @@ public static class ToolCall extends ContentPart { @JsonProperty("tool_call_id") private String toolCallId = ""; @JsonProperty("tool_name") private String toolName = ""; @JsonProperty("input") private JsonNode input = emptyObject(); + @JsonProperty("provider_executed") private Boolean providerExecuted; + @JsonProperty("thought_signature") private String thoughtSignature; @JsonProperty("provider_options") private JsonNode providerOptions; @JsonCreator ToolCall() {} - private ToolCall(String toolCallId, String toolName, JsonNode input, JsonNode providerOptions) { + private ToolCall(String toolCallId, String toolName, JsonNode input, + Boolean providerExecuted, String thoughtSignature, JsonNode providerOptions) { this.toolCallId = toolCallId; this.toolName = toolName; this.input = input; + this.providerExecuted = providerExecuted; + this.thoughtSignature = thoughtSignature; this.providerOptions = providerOptions; } public String getToolCallId() { return toolCallId; } public String getToolName() { return toolName; } public JsonNode getInput() { return input; } + public Boolean getProviderExecuted() { return providerExecuted; } + public String getThoughtSignature() { return thoughtSignature; } public JsonNode getProviderOptions() { return providerOptions; } public static Builder builder() { return new Builder(); } @@ -1288,14 +1301,20 @@ public static class Builder { private String toolCallId = ""; private String toolName = ""; private JsonNode input = emptyObject(); + private Boolean providerExecuted; + private String thoughtSignature; private JsonNode providerOptions; public Builder toolCallId(String v) { this.toolCallId = v; return this; } public Builder toolName(String v) { this.toolName = v; return this; } public Builder input(JsonNode v) { this.input = v; return this; } + public Builder providerExecuted(Boolean v) { this.providerExecuted = v; return this; } + public Builder thoughtSignature(String v) { this.thoughtSignature = v; return this; } public Builder providerOptions(JsonNode v) { this.providerOptions = v; return this; } - public ToolCall build() { return new ToolCall(toolCallId, toolName, input, providerOptions); } + public ToolCall build() { + return new ToolCall(toolCallId, toolName, input, providerExecuted, thoughtSignature, providerOptions); + } } @Override @@ -1306,11 +1325,15 @@ public boolean equals(Object o) { return Objects.equals(toolCallId, that.toolCallId) && Objects.equals(toolName, that.toolName) && Objects.equals(input, that.input) + && Objects.equals(providerExecuted, that.providerExecuted) + && Objects.equals(thoughtSignature, that.thoughtSignature) && Objects.equals(providerOptions, that.providerOptions); } @Override - public int hashCode() { return Objects.hash(toolCallId, toolName, input, providerOptions); } + public int hashCode() { + return Objects.hash(toolCallId, toolName, input, providerExecuted, thoughtSignature, providerOptions); + } } public static class ToolResult extends ContentPart { @@ -2171,18 +2194,20 @@ public static class ToolCall extends GenerateContent { @JsonProperty("input") private JsonNode input = emptyObject(); @JsonProperty("provider_executed") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; + @JsonProperty("thought_signature") private String thoughtSignature; @JsonProperty("provider_metadata") private JsonNode providerMetadata; @JsonCreator ToolCall() {} private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean providerExecuted, - Boolean dynamic, JsonNode providerMetadata) { + Boolean dynamic, String thoughtSignature, JsonNode providerMetadata) { this.toolCallId = toolCallId; this.toolName = toolName; this.input = input; this.providerExecuted = providerExecuted; this.dynamic = dynamic; + this.thoughtSignature = thoughtSignature; this.providerMetadata = providerMetadata; } @@ -2191,6 +2216,7 @@ private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean pro public JsonNode getInput() { return input; } public Boolean getProviderExecuted() { return providerExecuted; } public Boolean getDynamic() { return dynamic; } + public String getThoughtSignature() { return thoughtSignature; } public JsonNode getProviderMetadata() { return providerMetadata; } public static Builder builder() { return new Builder(); } @@ -2201,6 +2227,7 @@ public static class Builder { private JsonNode input = emptyObject(); private Boolean providerExecuted; private Boolean dynamic; + private String thoughtSignature; private JsonNode providerMetadata; public Builder toolCallId(String v) { this.toolCallId = v; return this; } @@ -2208,10 +2235,12 @@ public static class Builder { public Builder input(JsonNode v) { this.input = v; return this; } public Builder providerExecuted(Boolean v) { this.providerExecuted = v; return this; } public Builder dynamic(Boolean v) { this.dynamic = v; return this; } + public Builder thoughtSignature(String v) { this.thoughtSignature = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } public ToolCall build() { - return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata); + return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, + providerMetadata); } } @@ -2225,12 +2254,14 @@ public boolean equals(Object o) { && Objects.equals(input, that.input) && Objects.equals(providerExecuted, that.providerExecuted) && Objects.equals(dynamic, that.dynamic) + && Objects.equals(thoughtSignature, that.thoughtSignature) && Objects.equals(providerMetadata, that.providerMetadata); } @Override public int hashCode() { - return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata); + return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, + providerMetadata); } } @@ -3386,6 +3417,7 @@ public static class ToolCall extends StreamPart { @JsonProperty("input") private JsonNode input = emptyObject(); @JsonProperty("provider_executed") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; + @JsonProperty("thought_signature") private String thoughtSignature; @JsonProperty("provider_metadata") private JsonNode providerMetadata; @JsonProperty("invalid") private Boolean invalid; @JsonProperty("error") private JsonNode error; @@ -3394,12 +3426,14 @@ public static class ToolCall extends StreamPart { ToolCall() {} private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean providerExecuted, - Boolean dynamic, JsonNode providerMetadata, Boolean invalid, JsonNode error) { + Boolean dynamic, String thoughtSignature, JsonNode providerMetadata, Boolean invalid, + JsonNode error) { this.toolCallId = toolCallId; this.toolName = toolName; this.input = input; this.providerExecuted = providerExecuted; this.dynamic = dynamic; + this.thoughtSignature = thoughtSignature; this.providerMetadata = providerMetadata; this.invalid = invalid; this.error = error; @@ -3410,6 +3444,7 @@ private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean pro public JsonNode getInput() { return input; } public Boolean getProviderExecuted() { return providerExecuted; } public Boolean getDynamic() { return dynamic; } + public String getThoughtSignature() { return thoughtSignature; } public JsonNode getProviderMetadata() { return providerMetadata; } /** Set by Core when the tool call stays invalid after optional repair. */ public Boolean getInvalid() { return invalid; } @@ -3424,6 +3459,7 @@ public static class Builder { private JsonNode input = emptyObject(); private Boolean providerExecuted; private Boolean dynamic; + private String thoughtSignature; private JsonNode providerMetadata; private Boolean invalid; private JsonNode error; @@ -3433,13 +3469,14 @@ public static class Builder { public Builder input(JsonNode v) { this.input = v; return this; } public Builder providerExecuted(Boolean v) { this.providerExecuted = v; return this; } public Builder dynamic(Boolean v) { this.dynamic = v; return this; } + public Builder thoughtSignature(String v) { this.thoughtSignature = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } public Builder invalid(Boolean v) { this.invalid = v; return this; } public Builder error(JsonNode v) { this.error = v; return this; } public ToolCall build() { - return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata, - invalid, error); + return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, + providerMetadata, invalid, error); } } @@ -3453,6 +3490,7 @@ public boolean equals(Object o) { && Objects.equals(input, that.input) && Objects.equals(providerExecuted, that.providerExecuted) && Objects.equals(dynamic, that.dynamic) + && Objects.equals(thoughtSignature, that.thoughtSignature) && Objects.equals(providerMetadata, that.providerMetadata) && Objects.equals(invalid, that.invalid) && Objects.equals(error, that.error); @@ -3460,8 +3498,8 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata, - invalid, error); + return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, + providerMetadata, invalid, error); } } diff --git a/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java b/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java index 4d846a79..30f7b1d1 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java @@ -79,6 +79,48 @@ private static boolean jsonEquals(String a, String b) throws IOException { return Types.AimuxJson.MAPPER.readTree(a).equals(Types.AimuxJson.MAPPER.readTree(b)); } + @Test + void topLevelToolCallProviderMetadataRoundTrips() throws Exception { + String json = "{\"tool_call_id\":\"call_1\",\"tool_name\":\"get_weather\"," + + "\"input\":{\"city\":\"Paris\"}," + + "\"thought_signature\":\"sig_top\"," + + "\"provider_metadata\":{\"openai\":{\"itemId\":\"item_1\"}}}"; + + Types.ToolCall call = Types.AimuxJson.MAPPER.readValue(json, Types.ToolCall.class); + assertThat(call.getProviderMetadata().path("openai").path("itemId").asText()) + .isEqualTo("item_1"); + assertThat(call.getThoughtSignature()).isEqualTo("sig_top"); + assertThat(jsonEquals(Types.AimuxJson.MAPPER.writeValueAsString(call), json)).isTrue(); + + JsonNode metadata = Types.AimuxJson.MAPPER.readTree("{\"openai\":{\"itemId\":\"item_2\"}}"); + assertThat(Types.ToolCall.builder().providerMetadata(metadata).build().getProviderMetadata()) + .isEqualTo(metadata); + } + + @Test + void providerExecutedToolTranscriptMessageRoundTrips() throws Exception { + String json = fixtureJson( + loadFixtures(), "model_message_provider_executed_tool_transcript"); + Types.ModelMessage message = + Types.AimuxJson.MAPPER.readValue(json, Types.ModelMessage.class); + + assertThat(message.getRole()).isEqualTo(Types.Role.ASSISTANT); + assertThat(message.getContentParts()).hasSize(2); + Types.ContentPart.ToolCall call = + (Types.ContentPart.ToolCall) message.getContentParts().get(0); + assertThat(call.getProviderExecuted()).isTrue(); + assertThat(call.getThoughtSignature()).isEqualTo("sig_provider"); + Types.ContentPart.ToolResult result = + (Types.ContentPart.ToolResult) message.getContentParts().get(1); + assertThat(result.getToolName()).isEqualTo("search"); + assertThat(result.getIsError()).isFalse(); + assertThat(result.getPreliminary()).isTrue(); + assertThat(result.getDynamic()).isTrue(); + + String reencoded = Types.AimuxJson.MAPPER.writeValueAsString(message); + assertThat(jsonEquals(reencoded, json)).isTrue(); + } + // ── ToolChoice: round-trip + semantic type assertions ─────────────────── @Test @@ -164,6 +206,18 @@ void streamPartRawRoundTripAndType() throws Exception { assertThat(Types.AimuxJson.MAPPER.writeValueAsString(part)).isEqualTo(json); } + @Test + void streamPartToolCallThoughtSignatureRoundTrips() throws Exception { + String json = "{\"ToolCall\":{\"tool_call_id\":\"stream_1\",\"tool_name\":\"search\"," + + "\"input\":{\"query\":\"Rust\"},\"thought_signature\":\"sig_stream\"}}"; + + Types.StreamPart part = Types.AimuxJson.MAPPER.readValue(json, Types.StreamPart.class); + assertThat(part).isInstanceOf(Types.StreamPart.ToolCall.class); + Types.StreamPart.ToolCall call = (Types.StreamPart.ToolCall) part; + assertThat(call.getThoughtSignature()).isEqualTo("sig_stream"); + assertThat(jsonEquals(Types.AimuxJson.MAPPER.writeValueAsString(call), json)).isTrue(); + } + /// RFC-0016 M10: `Usage.raw` with a vendor-specific field survives a /// Java round-trip (NON_NULL omits a null raw, keeps a non-null one). @Test @@ -239,8 +293,11 @@ void generateContentFixturesDecodeIntoTheirVariants() throws Exception { Types.GenerateContent.ToolCall toolCall = (Types.GenerateContent.ToolCall) byName.get("generate_content_tool_call"); assertThat(toolCall.getToolCallId()).isEqualTo("call_1"); - assertThat(toolCall.getInput().path("city").asText()).isEqualTo("Paris"); + assertThat(toolCall.getInput().asText()).isEqualTo("{\"city\":\"Paris\"}"); assertThat(toolCall.getProviderExecuted()).isTrue(); + assertThat(toolCall.getThoughtSignature()).isEqualTo("sig_abc"); + String toolCallJson = fixtureJson(fixtures, "generate_content_tool_call"); + assertThat(jsonEquals(Types.AimuxJson.MAPPER.writeValueAsString(toolCall), toolCallJson)).isTrue(); Types.GenerateContent.File file = (Types.GenerateContent.File) byName.get("generate_content_file"); diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt index fc107968..0b211bad 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt @@ -169,6 +169,7 @@ data class ToolCall( @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, val invalid: Boolean? = null, val error: JsonElement? = null, + @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, ) // ───────────────────────────────────────────────────────────────────────────── @@ -392,6 +393,7 @@ sealed interface ContentPart { val input: JsonElement = JsonObject(emptyMap()), @SerialName("thought_signature") val thoughtSignature: String? = null, @SerialName("provider_options") val providerOptions: JsonElement? = null, + @SerialName("provider_executed") val providerExecuted: Boolean? = null, ) : ContentPart @Serializable diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt index 00dc0074..bfede7f2 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt @@ -83,6 +83,29 @@ class ContractTest { assertThat(reencoded).contains("\"session_id\":\"sess-1\"") } + @Test + fun `provider executed tool transcript message round-trips`() { + val json = fixtureJson( + loadFixtures(), + "model_message_provider_executed_tool_transcript", + ) + val message = AimuxJson.decodeFromString(json) + assertThat(message.role).isEqualTo(Role.ASSISTANT) + val parts = message.contentParts!! + assertThat(parts).hasSize(2) + val call = parts[0] as ContentPart.ToolCall + assertThat(call.providerExecuted).isTrue() + val result = parts[1] as ContentPart.ToolResult + assertThat(result.toolName).isEqualTo("search") + assertThat(result.isError).isFalse() + assertThat(result.preliminary).isTrue() + assertThat(result.dynamic).isTrue() + + val reencoded = AimuxJson.encodeToString(ModelMessage.serializer(), message) + assertThat(AimuxJson.parseToJsonElement(reencoded)) + .isEqualTo(AimuxJson.parseToJsonElement(json)) + } + /// Every `GenerateContent` fixture decodes into its concrete variant. /// /// The variant type is asserted, not merely the absence of an exception: @@ -112,7 +135,7 @@ class ContractTest { // the nested file union, and Source's optionals. val toolCall = byName["generate_content_tool_call"] as GenerateContent.ToolCall assertThat(toolCall.toolCallId).isEqualTo("call_1") - assertThat(toolCall.input.jsonObject["city"]?.jsonPrimitive?.content).isEqualTo("Paris") + assertThat(toolCall.input.jsonPrimitive.content).isEqualTo("{\"city\":\"Paris\"}") assertThat(toolCall.providerExecuted).isTrue() val file = byName["generate_content_file"] as GenerateContent.File diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt index ed59e794..578584ac 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt @@ -258,20 +258,17 @@ class TypedModelRoundTripTest { @Test fun `top-level ToolCall provider metadata round-trips`() { - val metadata = JsonObject( - mapOf("google" to JsonObject(mapOf("cache_id" to JsonPrimitive("cache-1")))) - ) val original = ToolCall( toolCallId = "call_1", toolName = "get_weather", - providerMetadata = metadata, + input = JsonObject(mapOf("city" to JsonPrimitive("Paris"))), + providerMetadata = JsonObject( + mapOf("openai" to JsonObject(mapOf("itemId" to JsonPrimitive("item_1")))), + ), ) - val json = AimuxJson.encodeToString(ToolCall.serializer(), original) - val decoded = AimuxJson.decodeFromString(ToolCall.serializer(), json) - assertThat(json).contains("\"provider_metadata\"") - assertThat(decoded.providerMetadata).isEqualTo(metadata) + val decoded = AimuxJson.decodeFromString(ToolCall.serializer(), json) assertThat(decoded).isEqualTo(original) } @@ -387,12 +384,14 @@ class TypedModelRoundTripTest { toolCallId = "call_1", toolName = "get_weather", input = JsonObject(mapOf("location" to JsonPrimitive("Tokyo"))), + providerExecuted = true, providerOptions = null, ) val json = AimuxJson.encodeToString(ContentPart.serializer(), original) val decoded = AimuxJson.decodeFromString(ContentPart.serializer(), json) assertThat(decoded).isEqualTo(original) assertThat(json).contains("\"tool_call\"") + assertThat(json).contains("\"provider_executed\":true") } @Test diff --git a/bindings/node/src/types/ContentPart.ts b/bindings/node/src/types/ContentPart.ts index c5ba1029..a7538a15 100644 --- a/bindings/node/src/types/ContentPart.ts +++ b/bindings/node/src/types/ContentPart.ts @@ -21,6 +21,12 @@ provider_options?: JsonValue | null, } | { "type": "tool_call", tool_call_id: st * Arguments as a JSON value (usually an object). */ input: JsonValue, +/** + * Whether the tool call is executed by the provider rather than by + * the client. This is part of the prompt contract because providers + * need it to replay server tool calls on a later turn. + */ +provider_executed?: boolean | null, /** * Provider-assigned thought signature (e.g. Google Gemini * `thoughtSignature`). Must be echoed back verbatim on the follow-up diff --git a/bindings/python/python/aimux/wrapper.py b/bindings/python/python/aimux/wrapper.py index 6357d8a5..ca86c99d 100644 --- a/bindings/python/python/aimux/wrapper.py +++ b/bindings/python/python/aimux/wrapper.py @@ -731,6 +731,7 @@ class _ToolCallContentPart(BaseModel): tool_call_id: str tool_name: str input: Any + provider_executed: Optional[bool] = None thought_signature: Optional[str] = None provider_options: Optional[Any] = None diff --git a/bindings/python/tests/test_contract.py b/bindings/python/tests/test_contract.py index 5b5471cc..66df3eff 100644 --- a/bindings/python/tests/test_contract.py +++ b/bindings/python/tests/test_contract.py @@ -10,7 +10,7 @@ import json from pathlib import Path -from aimux.wrapper import GenerateContent, GenerateTextOptions, parse_stream_part +from aimux.wrapper import GenerateContent, GenerateTextOptions, ModelMessage, parse_stream_part _REPO_ROOT = Path(__file__).resolve().parents[3] @@ -95,7 +95,7 @@ def test_generate_content_fixtures_decode_into_typed_variants(): # Source's optionals. tool_call = by_name["generate_content_tool_call"] assert tool_call.tool_call_id == "call_1" - assert tool_call.input == {"city": "Paris"} + assert tool_call.input == '{"city":"Paris"}' assert tool_call.provider_executed is True file_part = by_name["generate_content_file"] @@ -104,3 +104,20 @@ def test_generate_content_fixtures_decode_into_typed_variants(): source = by_name["generate_content_source_unset_optionals"] assert source.url is None assert source.title is None + + +def test_provider_executed_tool_transcript_message_fixture_roundtrips(): + message = ModelMessage.model_validate_json( + _fixture_json("model_message_provider_executed_tool_transcript") + ) + call, result = message.content + assert call.provider_executed is True + assert call.tool_name == "search" + assert result.tool_name == "search" + assert result.is_error is False + assert result.preliminary is True + assert result.dynamic is True + + reencoded = message.model_dump_json(exclude_none=True) + decoded = ModelMessage.model_validate_json(reencoded) + assert decoded == message diff --git a/bindings/swift/Sources/Aimux/Types.swift b/bindings/swift/Sources/Aimux/Types.swift index 533c6682..c337097b 100644 --- a/bindings/swift/Sources/Aimux/Types.swift +++ b/bindings/swift/Sources/Aimux/Types.swift @@ -435,13 +435,16 @@ public enum ContentPart: Codable, Equatable { case fileUrl(url: String, mediaType: String, providerOptions: JSONValue?) case fileReference(mediaType: String, reference: JSONValue, filename: String?, providerOptions: JSONValue?) case reasoning(text: String, signature: String?, providerOptions: JSONValue?) - case toolCall(toolCallId: String, toolName: String, input: JSONValue, providerOptions: JSONValue?) + case toolCall(toolCallId: String, toolName: String, input: JSONValue, + providerExecuted: Bool? = nil, thoughtSignature: String? = nil, + providerOptions: JSONValue?) case toolResult(toolCallId: String, result: JSONValue, toolName: String?, isError: Bool?, preliminary: Bool?, dynamic: Bool?, providerOptions: JSONValue?) private enum Field: String, CodingKey { case text, image, data, mediaType = "media_type", filename, url, reference case signature, toolCallId = "tool_call_id", toolName = "tool_name", input, result + case providerExecuted = "provider_executed", thoughtSignature = "thought_signature" case isError = "is_error", preliminary, dynamic case providerOptions = "provider_options" } @@ -485,6 +488,8 @@ public enum ContentPart: Codable, Equatable { self = .toolCall(toolCallId: try c.decode(String.self, forKey: AnyCodingKey("tool_call_id")), toolName: try c.decode(String.self, forKey: AnyCodingKey("tool_name")), input: try c.decode(JSONValue.self, forKey: AnyCodingKey("input")), + providerExecuted: try c.decodeIfPresent(Bool.self, forKey: AnyCodingKey("provider_executed")), + thoughtSignature: try c.decodeIfPresent(String.self, forKey: AnyCodingKey("thought_signature")), providerOptions: try po()) case "tool_result": self = .toolResult(toolCallId: try c.decode(String.self, forKey: AnyCodingKey("tool_call_id")), @@ -539,11 +544,13 @@ public enum ContentPart: Codable, Equatable { try c.encode(text, forKey: AnyCodingKey("text")) try c.encodeIfPresent(signature, forKey: AnyCodingKey("signature")) try c.encodeIfPresent(po, forKey: AnyCodingKey("provider_options")) - case .toolCall(let toolCallId, let toolName, let input, let po): + case .toolCall(let toolCallId, let toolName, let input, let providerExecuted, let thoughtSignature, let po): try c.encode("tool_call", forKey: AnyCodingKey("type")) try c.encode(toolCallId, forKey: AnyCodingKey("tool_call_id")) try c.encode(toolName, forKey: AnyCodingKey("tool_name")) try c.encode(input, forKey: AnyCodingKey("input")) + try c.encodeIfPresent(providerExecuted, forKey: AnyCodingKey("provider_executed")) + try c.encodeIfPresent(thoughtSignature, forKey: AnyCodingKey("thought_signature")) try c.encodeIfPresent(po, forKey: AnyCodingKey("provider_options")) case .toolResult(let toolCallId, let result, let toolName, let isError, let preliminary, let dynamic, let po): try c.encode("tool_result", forKey: AnyCodingKey("type")) @@ -756,7 +763,8 @@ public enum FileData: Codable, Equatable { public enum GenerateContent: Codable, Equatable { case text(text: String, providerMetadata: JSONValue?) case toolCall(toolCallId: String, toolName: String, input: JSONValue, - providerExecuted: Bool?, dynamic: Bool?, providerMetadata: JSONValue?) + providerExecuted: Bool?, dynamic: Bool?, thoughtSignature: String? = nil, + providerMetadata: JSONValue?) case source(id: String, sourceType: String, url: String?, title: String?, providerMetadata: JSONValue?) case reasoning(text: String, providerMetadata: JSONValue?) @@ -767,7 +775,8 @@ public enum GenerateContent: Codable, Equatable { private enum Field: String, CodingKey { case text case toolCallId = "tool_call_id", toolName = "tool_name", input, result - case providerExecuted = "provider_executed", dynamic, providerMetadata = "provider_metadata" + case providerExecuted = "provider_executed", dynamic + case thoughtSignature = "thought_signature", providerMetadata = "provider_metadata" case id, sourceType = "source_type", url, title case isError = "is_error", preliminary case data, mediaType = "media_type" @@ -789,6 +798,7 @@ public enum GenerateContent: Codable, Equatable { input: try n.decode(JSONValue.self, forKey: .input), providerExecuted: try n.decodeIfPresent(Bool.self, forKey: .providerExecuted), dynamic: try n.decodeIfPresent(Bool.self, forKey: .dynamic), + thoughtSignature: try n.decodeIfPresent(String.self, forKey: .thoughtSignature), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) case "Source": self = .source(id: try n.decode(String.self, forKey: .id), @@ -823,13 +833,14 @@ public enum GenerateContent: Codable, Equatable { var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Text")) try n.encode(text, forKey: .text) try n.encodeIfPresent(pm, forKey: .providerMetadata) - case .toolCall(let toolCallId, let toolName, let input, let pe, let dyn, let pm): + case .toolCall(let toolCallId, let toolName, let input, let pe, let dyn, let signature, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolCall")) try n.encode(toolCallId, forKey: .toolCallId) try n.encode(toolName, forKey: .toolName) try n.encode(input, forKey: .input) try n.encodeIfPresent(pe, forKey: .providerExecuted) try n.encodeIfPresent(dyn, forKey: .dynamic) + try n.encodeIfPresent(signature, forKey: .thoughtSignature) try n.encodeIfPresent(pm, forKey: .providerMetadata) case .source(let id, let sourceType, let url, let title, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Source")) @@ -1200,7 +1211,8 @@ public enum StreamPart: Codable, Equatable { case toolInputDelta(id: String, delta: String, providerMetadata: JSONValue?) case toolInputEnd(id: String, providerMetadata: JSONValue?) case toolCall(toolCallId: String, toolName: String, input: JSONValue, - providerExecuted: Bool?, dynamic: Bool?, providerMetadata: JSONValue?, + providerExecuted: Bool?, dynamic: Bool?, thoughtSignature: String? = nil, + providerMetadata: JSONValue?, invalid: Bool?, error: JSONValue?) case toolResult(toolCallId: String, toolName: String, result: JSONValue, isError: Bool?, preliminary: Bool?, dynamic: Bool?, providerMetadata: JSONValue?) @@ -1221,7 +1233,7 @@ public enum StreamPart: Codable, Equatable { case finishReason = "finish_reason", providerMetadata = "provider_metadata" case error case toolName = "tool_name", toolCallId = "tool_call_id", input, result - case providerExecuted = "provider_executed", dynamic, invalid + case providerExecuted = "provider_executed", dynamic, thoughtSignature = "thought_signature", invalid case isError = "is_error", preliminary case timestamp, modelId = "model_id" case sourceType = "source_type", url, title @@ -1276,6 +1288,7 @@ public enum StreamPart: Codable, Equatable { input: try n.decode(JSONValue.self, forKey: .input), providerExecuted: try n.decodeIfPresent(Bool.self, forKey: .providerExecuted), dynamic: try n.decodeIfPresent(Bool.self, forKey: .dynamic), + thoughtSignature: try n.decodeIfPresent(String.self, forKey: .thoughtSignature), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata), invalid: try n.decodeIfPresent(Bool.self, forKey: .invalid), error: try n.decodeIfPresent(JSONValue.self, forKey: .error)) @@ -1353,11 +1366,12 @@ public enum StreamPart: Codable, Equatable { case .toolInputEnd(let id, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolInputEnd")) try n.encode(id, forKey: .id); try n.encodeIfPresent(pm, forKey: .providerMetadata) - case .toolCall(let toolCallId, let toolName, let input, let pe, let dyn, let pm, let inv, let err): + case .toolCall(let toolCallId, let toolName, let input, let pe, let dyn, let signature, let pm, let inv, let err): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolCall")) try n.encode(toolCallId, forKey: .toolCallId); try n.encode(toolName, forKey: .toolName) try n.encode(input, forKey: .input) try n.encodeIfPresent(pe, forKey: .providerExecuted); try n.encodeIfPresent(dyn, forKey: .dynamic) + try n.encodeIfPresent(signature, forKey: .thoughtSignature) try n.encodeIfPresent(pm, forKey: .providerMetadata) try n.encodeIfPresent(inv, forKey: .invalid); try n.encodeIfPresent(err, forKey: .error) case .toolResult(let toolCallId, let toolName, let result, let ie, let prel, let dyn, let pm): diff --git a/bindings/swift/Tests/AimuxTests/ContractTests.swift b/bindings/swift/Tests/AimuxTests/ContractTests.swift index f1ad0ce2..463e8d90 100644 --- a/bindings/swift/Tests/AimuxTests/ContractTests.swift +++ b/bindings/swift/Tests/AimuxTests/ContractTests.swift @@ -64,6 +64,76 @@ final class ContractTests: XCTestCase { XCTAssertEqual(decoded, again, "fixture '\(fixture.name)' does not survive a Swift encode/decode round-trip") } + func testTopLevelToolCallProviderMetadataRoundTrips() throws { + let json = #"{"tool_call_id":"call_1","tool_name":"get_weather","input":{"city":"Paris"},"provider_metadata":{"openai":{"itemId":"item_1"}}}"# + let call = try JSONDecoder().decode(ToolCall.self, from: Data(json.utf8)) + XCTAssertEqual(call.providerMetadata?["openai"]?["itemId"]?.stringValue, "item_1") + + let encoded = try JSONEncoder().encode(call) + let decoded = try JSONDecoder().decode(ToolCall.self, from: encoded) + XCTAssertEqual(decoded, call) + } + + func testProviderExecutedToolTranscriptMessageRoundTrips() throws { + let fixtures = try loadFixtures() + guard let fixture = fixtures.first(where: { + $0.name == "model_message_provider_executed_tool_transcript" + }) else { + return XCTFail("provider-executed transcript fixture is missing") + } + let message = try JSONDecoder().decode( + ModelMessage.self, + from: Data(fixture.json.utf8) + ) + guard case .parts(let parts) = message.content else { + return XCTFail("expected multipart assistant message") + } + guard case .toolCall(_, let name, _, let providerExecuted, + let thoughtSignature, _) = parts[0] else { + return XCTFail("expected tool call") + } + XCTAssertEqual(name, "search") + XCTAssertEqual(providerExecuted, true) + XCTAssertEqual(thoughtSignature, "sig_provider") + guard case .toolResult(_, _, let resultName, let isError, + let preliminary, let dynamic, _) = parts[1] else { + return XCTFail("expected tool result") + } + XCTAssertEqual(resultName, "search") + XCTAssertEqual(isError, false) + XCTAssertEqual(preliminary, true) + XCTAssertEqual(dynamic, true) + + let reencoded = try JSONEncoder().encode(message) + XCTAssertEqual(try JSONDecoder().decode(ModelMessage.self, from: reencoded), message) + } + + func testResultToolCallThoughtSignaturesRoundTrip() throws { + let fixtures = try loadFixtures() + guard let fixture = fixtures.first(where: { $0.name == "generate_content_tool_call" }) else { + return XCTFail("generate-content tool-call fixture is missing") + } + let content = try JSONDecoder().decode( + GenerateContent.self, + from: Data(fixture.json.utf8) + ) + guard case .toolCall(_, _, _, _, _, let generateSignature, _) = content else { + return XCTFail("expected generate-content tool call") + } + XCTAssertEqual(generateSignature, "sig_abc") + let contentData = try JSONEncoder().encode(content) + XCTAssertEqual(try JSONDecoder().decode(GenerateContent.self, from: contentData), content) + + let streamJSON = #"{"ToolCall":{"tool_call_id":"stream_1","tool_name":"search","input":{"query":"Rust"},"thought_signature":"sig_stream"}}"# + let part = try JSONDecoder().decode(StreamPart.self, from: Data(streamJSON.utf8)) + guard case .toolCall(_, _, _, _, _, let streamSignature, _, _, _) = part else { + return XCTFail("expected stream tool call") + } + XCTAssertEqual(streamSignature, "sig_stream") + let partData = try JSONEncoder().encode(part) + XCTAssertEqual(try JSONDecoder().decode(StreamPart.self, from: partData), part) + } + // MARK: - every fixture decodes /// A fixture type with no case here fails rather than being skipped: diff --git a/bindings/swift/Tests/AimuxTests/WrapperTests.swift b/bindings/swift/Tests/AimuxTests/WrapperTests.swift index 428579bb..3241b030 100644 --- a/bindings/swift/Tests/AimuxTests/WrapperTests.swift +++ b/bindings/swift/Tests/AimuxTests/WrapperTests.swift @@ -89,7 +89,7 @@ final class WrapperTests: XCTestCase { // Structured raw.content contains a ToolCall variant mirroring the call. let toolContents = result.raw.content.compactMap { part -> ToolCall? in - if case .toolCall(let id, let name, let input, _, _, _) = part { + if case .toolCall(let id, let name, let input, _, _, _, _) = part { return ToolCall(toolCallId: id, toolName: name, input: input) } return nil @@ -254,7 +254,7 @@ final class WrapperTests: XCTestCase { // The complete ToolCall part carries the tool name and structured input. let toolCall = parts.compactMap { part -> (String, JSONValue)? in - if case .toolCall(_, let name, let input, _, _, _, _, _) = part { return (name, input) } + if case .toolCall(_, let name, let input, _, _, _, _, _, _) = part { return (name, input) } return nil }.first XCTAssertEqual(toolCall?.0, "get_weather") diff --git a/contract-tests/fixtures/wire-format.json b/contract-tests/fixtures/wire-format.json index d0c8c32c..b6f41405 100644 --- a/contract-tests/fixtures/wire-format.json +++ b/contract-tests/fixtures/wire-format.json @@ -98,8 +98,8 @@ { "name": "generate_content_tool_call", "type": "GenerateContent", - "json": "{\"ToolCall\":{\"tool_call_id\":\"call_1\",\"tool_name\":\"get_weather\",\"input\":{\"city\":\"Paris\"},\"provider_executed\":true,\"thought_signature\":\"sig_abc\"}}", - "description": "Tool call with provider_executed and thought_signature set; dynamic and provider_metadata unset and therefore absent. `input` is an object here — the ticket that moves tool-call input to a JSON string will change this fixture, which is the point." + "json": "{\"ToolCall\":{\"tool_call_id\":\"call_1\",\"tool_name\":\"get_weather\",\"input\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"provider_executed\":true,\"thought_signature\":\"sig_abc\"}}", + "description": "Raw provider tool call with provider_executed and thought_signature set; dynamic and provider_metadata unset and therefore absent. `input` is the provider's serialized JSON argument text; Core parses it before exposing the top-level ToolCall." }, { "name": "generate_content_source", @@ -149,6 +149,12 @@ "json": "{\"role\":\"user\",\"content\":\"Hello\"}", "description": "ModelMessage with text content" }, + { + "name": "model_message_provider_executed_tool_transcript", + "type": "ModelMessage", + "json": "{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_call\",\"tool_call_id\":\"srv_1\",\"tool_name\":\"search\",\"input\":{\"query\":\"Rust\"},\"provider_executed\":true,\"thought_signature\":\"sig_provider\",\"provider_options\":{\"anthropic\":{\"type\":\"mcp-tool-use\",\"serverName\":\"docs\"}}},{\"type\":\"tool_result\",\"tool_call_id\":\"srv_1\",\"result\":{\"answer\":42},\"tool_name\":\"search\",\"is_error\":false,\"preliminary\":true,\"dynamic\":true,\"provider_options\":{\"anthropic\":{\"resultId\":\"result_1\"}}}]}", + "description": "Provider-executed tool call and result stay together in an assistant message for replay. ToolCall carries provider_executed (but no dynamic field in the prompt contract); ToolResult carries replay metadata." + }, { "name": "finish_reason_unified_stop", "type": "FinishReasonUnified", @@ -161,4 +167,4 @@ "json": "\"high\"", "description": "ReasoningEffort high variant" } -] \ No newline at end of file +] diff --git a/contract-tests/run-node.ts b/contract-tests/run-node.ts index 9fac2e41..19ed553c 100644 --- a/contract-tests/run-node.ts +++ b/contract-tests/run-node.ts @@ -130,6 +130,24 @@ function testModelMessage() { if (f.name === 'model_message_text') { assert(parsed.role === 'user' && parsed.content === 'Hello', f.name, `expected role:"user", content:"Hello", got ${JSON.stringify(parsed)}`) + } else if (f.name === 'model_message_provider_executed_tool_transcript') { + const [call, result] = parsed.content ?? [] + assert( + parsed.role === 'assistant' && call?.type === 'tool_call' && result?.type === 'tool_result', + `${f.name} order`, + `expected assistant [tool_call, tool_result], got ${JSON.stringify(parsed)}`, + ) + assert( + call?.provider_executed === true && call?.thought_signature === 'sig_provider', + `${f.name} provider execution`, + `provider_executed/thought_signature missing: ${JSON.stringify(call)}`, + ) + assert( + call?.provider_options?.anthropic?.serverName === 'docs' && + result?.provider_options?.anthropic?.resultId === 'result_1', + `${f.name} metadata`, + `provider metadata missing: ${JSON.stringify(parsed.content)}`, + ) } } } diff --git a/tools/aimux-web/src/wire.rs b/tools/aimux-web/src/wire.rs index f4d923eb..27c64691 100644 --- a/tools/aimux-web/src/wire.rs +++ b/tools/aimux-web/src/wire.rs @@ -113,6 +113,8 @@ pub enum WireContentPart { tool_call_id: String, tool_name: String, input: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_executed: Option, }, ToolResult { tool_call_id: String, @@ -160,10 +162,12 @@ fn to_content_part(p: &WireContentPart) -> ContentPart { tool_call_id, tool_name, input, + provider_executed, } => ContentPart::ToolCall { tool_call_id: tool_call_id.clone(), tool_name: tool_name.clone(), input: input.clone(), + provider_executed: *provider_executed, thought_signature: None, provider_options: None, }, @@ -418,6 +422,7 @@ mod tests { tool_call_id: "c1".into(), tool_name: "calc".into(), input: serde_json::json!({"expr": "1+1"}), + provider_executed: None, }, ], }, diff --git a/tools/aimux-web/web/src/types/ContentPart.ts b/tools/aimux-web/web/src/types/ContentPart.ts index c5ba1029..56108da8 100644 --- a/tools/aimux-web/web/src/types/ContentPart.ts +++ b/tools/aimux-web/web/src/types/ContentPart.ts @@ -21,6 +21,12 @@ provider_options?: JsonValue | null, } | { "type": "tool_call", tool_call_id: st * Arguments as a JSON value (usually an object). */ input: JsonValue, +/** + * Whether the tool call is executed by the provider rather than by + * the client. This is part of the prompt contract because providers + * need it to replay server tool calls on a later turn. + */ +provider_executed?: boolean | null, /** * Provider-assigned thought signature (e.g. Google Gemini * `thoughtSignature`). Must be echoed back verbatim on the follow-up From 97491f9afc6164b7845b1da7a2c4356f15343df9 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Tue, 25 Aug 2026 15:54:27 +0800 Subject: [PATCH 04/19] refactor(core): extract response-message assembly into its own module Mirror the AI SDK's file split: to-response-messages logic moves out of generate.rs into response_messages.rs as a ResponseMessageBuilder that owns all assembly state (parts, text/reasoning buffers, provider options, the reasoning aggregate). Both the streaming consume loop and the non-streaming content loop shrink to per-part method calls; the twenty-odd inline flush sequences collapse into the builder's flush discipline. Also aggregate the remaining provider duplication: the streamed tool-input finalizer (empty input -> "{}" + code-execution wire-name re-wrap) that was inlined in both the Anthropic and Vertex-Anthropic stream loops becomes anthropic::stream::finalize_streamed_tool_input. raw_tool_input moves next to the rest of the parse contract in tool.rs. --- aimux-core/src/generate.rs | 343 +++--------------- aimux-core/src/lib.rs | 1 + aimux-core/src/response_messages.rs | 229 ++++++++++++ aimux-core/src/tool.rs | 10 + aimux-providers/src/anthropic/stream.rs | 49 ++- aimux-providers/src/vertex/anthropic_model.rs | 27 +- 6 files changed, 327 insertions(+), 332 deletions(-) create mode 100644 aimux-core/src/response_messages.rs diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index c6d2bc97..0da077aa 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -16,11 +16,10 @@ use serde_json::Value; use tracing::Instrument; use ts_rs::TS; -use crate::content::ContentPart; use crate::error::AiMuxError; use crate::language_model::LanguageModel; use crate::language_model_message::convert_to_language_model_prompt; -use crate::message::{MessageContent, ModelMessage, ModelPrompt, Role}; +use crate::message::{ModelMessage, ModelPrompt}; use crate::options::{CallOptions, ResponseFormat, ToolChoice}; use crate::result::{ FilePart, GenerateContent, GenerateResult, ReasoningPart, SourcePart, StreamResult, @@ -43,56 +42,6 @@ fn is_output_chunk(part: &StreamPart) -> bool { } } -/// Match the AI SDK's response-message safety rule for invalid tool calls: -/// malformed primitive input must not be replayed as a prompt tool-call input. -/// JavaScript's `typeof value === "object"` includes arrays and null, so those -/// values are intentionally retained here as well. -fn response_tool_call_input(input: &Value, invalid: Option) -> Value { - if invalid == Some(true) && !matches!(input, Value::Object(_) | Value::Array(_) | Value::Null) { - Value::Object(serde_json::Map::new()) - } else { - input.clone() - } -} - -fn flush_response_text( - parts: &mut Vec, - text: &mut String, - provider_options: &mut Option, -) { - if !text.is_empty() { - parts.push(ContentPart::Text { - text: std::mem::take(text), - provider_options: provider_options.take(), - }); - } else { - *provider_options = None; - } -} - -fn flush_response_reasoning( - parts: &mut Vec, - reasoning: &mut Vec, - text: &mut String, - provider_options: &mut Option, -) { - if text.is_empty() && provider_options.is_none() { - return; - } - - let text = std::mem::take(text); - if !text.is_empty() { - reasoning.push(ReasoningPart { text: text.clone() }); - } - let provider_options = provider_options.take(); - let signature = extract_reasoning_signature(provider_options.as_ref()); - parts.push(ContentPart::Reasoning { - text, - signature, - provider_options, - }); -} - // ───────────────────────────────────────────────────────────────────────────── // User-facing options // ───────────────────────────────────────────────────────────────────────────── @@ -361,8 +310,6 @@ impl StreamTextResult { /// underlying model stream. pub async fn consume(self) -> Result { let mut text = String::new(); - let mut reasoning: Vec = Vec::new(); - let mut reasoning_text_buf = String::new(); let mut tool_calls: Vec = Vec::new(); let mut sources: Vec = Vec::new(); let mut files: Vec = Vec::new(); @@ -375,10 +322,7 @@ impl StreamTextResult { let mut usage = Usage::default(); let mut finish_provider_metadata: Option = None; let mut response: Option = None; - let mut response_content_parts: Vec = Vec::new(); - let mut response_text_buf = String::new(); - let mut response_text_provider_options: Option = None; - let mut response_reasoning_provider_options: Option = None; + let mut rm = crate::response_messages::ResponseMessageBuilder::new(); let mut saw_output = false; let mut saw_finish = false; @@ -405,100 +349,29 @@ impl StreamTextResult { match part { StreamPart::TextStart { provider_metadata, .. - } => { - flush_response_reasoning( - &mut response_content_parts, - &mut reasoning, - &mut reasoning_text_buf, - &mut response_reasoning_provider_options, - ); - // A new text segment establishes its position immediately; - // flush a preceding implicit segment before starting it. - flush_response_text( - &mut response_content_parts, - &mut response_text_buf, - &mut response_text_provider_options, - ); - response_text_provider_options = provider_metadata; - } + } => rm.text_start(provider_metadata), StreamPart::TextDelta { delta, provider_metadata, .. } => { - flush_response_reasoning( - &mut response_content_parts, - &mut reasoning, - &mut reasoning_text_buf, - &mut response_reasoning_provider_options, - ); text.push_str(&delta); - response_text_buf.push_str(&delta); - if provider_metadata.is_some() { - response_text_provider_options = provider_metadata; - } + rm.text_delta(&delta, provider_metadata); } StreamPart::TextEnd { provider_metadata, .. - } => { - if provider_metadata.is_some() { - response_text_provider_options = provider_metadata; - } - flush_response_text( - &mut response_content_parts, - &mut response_text_buf, - &mut response_text_provider_options, - ); - } + } => rm.text_end(provider_metadata), StreamPart::ReasoningStart { provider_metadata, .. - } => { - flush_response_text( - &mut response_content_parts, - &mut response_text_buf, - &mut response_text_provider_options, - ); - flush_response_reasoning( - &mut response_content_parts, - &mut reasoning, - &mut reasoning_text_buf, - &mut response_reasoning_provider_options, - ); - response_reasoning_provider_options = provider_metadata; - } + } => rm.reasoning_start(provider_metadata), StreamPart::ReasoningDelta { delta, provider_metadata, .. - } => { - flush_response_text( - &mut response_content_parts, - &mut response_text_buf, - &mut response_text_provider_options, - ); - reasoning_text_buf.push_str(&delta); - if provider_metadata.is_some() { - response_reasoning_provider_options = provider_metadata; - } - } + } => rm.reasoning_delta(&delta, provider_metadata), StreamPart::ReasoningEnd { provider_metadata, .. - } => { - flush_response_text( - &mut response_content_parts, - &mut response_text_buf, - &mut response_text_provider_options, - ); - if provider_metadata.is_some() { - response_reasoning_provider_options = provider_metadata; - } - flush_response_reasoning( - &mut response_content_parts, - &mut reasoning, - &mut reasoning_text_buf, - &mut response_reasoning_provider_options, - ); - } + } => rm.reasoning_end(provider_metadata), StreamPart::ToolCall { tool_call_id, tool_name, @@ -511,38 +384,19 @@ impl StreamTextResult { error, .. } => { - flush_response_text( - &mut response_content_parts, - &mut response_text_buf, - &mut response_text_provider_options, - ); - flush_response_reasoning( - &mut response_content_parts, - &mut reasoning, - &mut reasoning_text_buf, - &mut response_reasoning_provider_options, - ); - let response_input = response_tool_call_input(&input, invalid); - let response_provider_options = provider_metadata.clone(); - tool_calls.push(crate::tool::ToolCall { - tool_call_id: tool_call_id.clone(), - tool_name: tool_name.clone(), - input: input.clone(), + let call = crate::tool::ToolCall { + tool_call_id, + tool_name, + input, provider_executed, dynamic, - thought_signature: thought_signature.clone(), + thought_signature, provider_metadata, invalid, error, - }); - response_content_parts.push(ContentPart::ToolCall { - tool_call_id, - tool_name, - input: response_input, - provider_executed, - thought_signature, - provider_options: response_provider_options, - }); + }; + rm.tool_call(&call); + tool_calls.push(call); } StreamPart::ToolResult { tool_call_id, @@ -552,34 +406,15 @@ impl StreamTextResult { preliminary, dynamic, provider_metadata, - } => { - // Preliminary server-tool results are transient stream - // updates. The provider contract requires a later final - // result, and only that final value belongs in the replay - // transcript for the next model turn. - if preliminary != Some(true) { - flush_response_text( - &mut response_content_parts, - &mut response_text_buf, - &mut response_text_provider_options, - ); - flush_response_reasoning( - &mut response_content_parts, - &mut reasoning, - &mut reasoning_text_buf, - &mut response_reasoning_provider_options, - ); - response_content_parts.push(ContentPart::ToolResult { - tool_call_id, - tool_name: Some(tool_name), - result, - is_error, - preliminary, - dynamic, - provider_options: provider_metadata, - }); - } - } + } => rm.tool_result( + tool_call_id, + tool_name, + result, + is_error, + preliminary, + dynamic, + provider_metadata, + ), StreamPart::Source { id, source_type, @@ -607,17 +442,6 @@ impl StreamTextResult { usage: u, provider_metadata: pm, } => { - flush_response_text( - &mut response_content_parts, - &mut response_text_buf, - &mut response_text_provider_options, - ); - flush_response_reasoning( - &mut response_content_parts, - &mut reasoning, - &mut reasoning_text_buf, - &mut response_reasoning_provider_options, - ); raw_finish_reason = fr.raw.clone(); finish_reason = fr; usage = u.clone(); @@ -664,25 +488,10 @@ impl StreamTextResult { }; } - flush_response_text( - &mut response_content_parts, - &mut response_text_buf, - &mut response_text_provider_options, - ); - flush_response_reasoning( - &mut response_content_parts, - &mut reasoning, - &mut reasoning_text_buf, - &mut response_reasoning_provider_options, - ); - let response_messages = if response_content_parts.is_empty() { - Vec::new() - } else { - vec![ModelMessage { - role: Role::Assistant, - content: MessageContent::Parts(response_content_parts), - }] - }; + let crate::response_messages::ResponseMessages { + messages: response_messages, + reasoning, + } = rm.finish(); let reasoning_text = reasoning .iter() @@ -833,11 +642,10 @@ pub async fn generate_text( // 4. Extract text, tool calls, reasoning, sources, files from content. let mut text = String::new(); let mut tool_calls = Vec::new(); - let mut reasoning = Vec::new(); let mut sources = Vec::new(); let mut files = Vec::new(); // Build the assistant response message content parts in parallel. - let mut response_content_parts: Vec = Vec::new(); + let mut rm = crate::response_messages::ResponseMessageBuilder::new(); for content in &result.content { match content { GenerateContent::Text { @@ -845,12 +653,7 @@ pub async fn generate_text( provider_metadata, } => { text.push_str(t); - if !t.is_empty() { - response_content_parts.push(ContentPart::Text { - text: t.clone(), - provider_options: provider_metadata.clone(), - }); - } + rm.text(t, provider_metadata.as_ref()); } GenerateContent::ToolCall { tool_call_id, @@ -866,7 +669,7 @@ pub async fn generate_text( RawToolCall { tool_call_id: tool_call_id.clone(), tool_name: tool_name.clone(), - input: raw_tool_input(input), + input: crate::tool::raw_tool_input(input), provider_executed: *provider_executed, dynamic: *dynamic, thought_signature: thought_signature.clone(), @@ -878,35 +681,14 @@ pub async fn generate_text( operation_instructions.as_deref(), ) .await; - response_content_parts.push(ContentPart::ToolCall { - tool_call_id: parsed.tool_call_id.clone(), - tool_name: parsed.tool_name.clone(), - input: response_tool_call_input(&parsed.input, parsed.invalid), - provider_executed: parsed.provider_executed, - thought_signature: parsed.thought_signature.clone(), - provider_options: parsed.provider_metadata.clone(), - }); + rm.tool_call(&parsed); tool_calls.push(parsed); } GenerateContent::Reasoning { text: rtext, provider_metadata, } => { - reasoning.push(ReasoningPart { - text: rtext.clone(), - }); - // Reasoning MUST go into responseMessages — it carries the - // thinking-block signature (Anthropic: provider_metadata - // .anthropic.signature; Bedrock: .bedrock.signature / - // .amazonBedrock.signature) which must be echoed back - // verbatim on the next turn for extended-thinking models. - // Consistent with AI SDK's toResponseMessages. - let signature = extract_reasoning_signature(provider_metadata.as_ref()); - response_content_parts.push(ContentPart::Reasoning { - text: rtext.clone(), - signature, - provider_options: provider_metadata.clone(), - }); + rm.reasoning(rtext, provider_metadata.as_ref()); } GenerateContent::Source { id, @@ -941,21 +723,23 @@ pub async fn generate_text( dynamic, provider_metadata, } => { - if *preliminary != Some(true) { - response_content_parts.push(ContentPart::ToolResult { - tool_call_id: tool_call_id.clone(), - tool_name: Some(tool_name.clone()), - result: result.clone(), - is_error: *is_error, - preliminary: *preliminary, - dynamic: *dynamic, - provider_options: provider_metadata.clone(), - }); - } + rm.tool_result( + tool_call_id.clone(), + tool_name.clone(), + result.clone(), + *is_error, + *preliminary, + *dynamic, + provider_metadata.clone(), + ); } } } + let crate::response_messages::ResponseMessages { + messages: response_messages, + reasoning, + } = rm.finish(); let reasoning_text = reasoning .iter() .map(|r| r.text.as_str()) @@ -972,15 +756,6 @@ pub async fn generate_text( ); } - let response_messages = if response_content_parts.is_empty() { - Vec::new() - } else { - vec![ModelMessage { - role: Role::Assistant, - content: MessageContent::Parts(response_content_parts), - }] - }; - // Extract fields before moving `result` into `raw`. let raw_finish_reason = result.finish_reason.raw.clone(); let provider_metadata = result.provider_metadata.clone(); @@ -1083,20 +858,6 @@ pub async fn generate_object( /// Extract the reasoning signature from provider metadata, checking all known /// provider keys (Anthropic, Bedrock). -fn extract_reasoning_signature(provider_metadata: Option<&Value>) -> Option { - let m = provider_metadata?; - for key in &["anthropic", "bedrock", "amazonBedrock"] { - if let Some(sig) = m - .get(key) - .and_then(|p| p.get("signature")) - .and_then(|s| s.as_str()) - { - return Some(sig.to_string()); - } - } - None -} - /// Stream text from the model. /// /// # Example @@ -1353,7 +1114,7 @@ pub async fn stream_text( RawToolCall { tool_call_id, tool_name, - input: raw_tool_input(&input), + input: crate::tool::raw_tool_input(&input), provider_executed, dynamic, thought_signature, @@ -1413,14 +1174,6 @@ impl Drop for AbortOnDrop { self.0.abort(); } } - -fn raw_tool_input(input: &Value) -> String { - match input { - Value::String(input) => input.clone(), - input => serde_json::to_string(input).expect("serializing serde_json::Value cannot fail"), - } -} - /// Run `do_generate` inside the RFC-0014 `generate` span and emit the /// `generate_end` event. A plain async fn (rather than an inline async block) /// so the `?` error type is pinned by the declared return type. diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index a1164ce3..879ecc01 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -39,6 +39,7 @@ pub mod provider; pub mod recording; pub mod replay; pub mod reranking_model; +pub(crate) mod response_messages; pub mod result; pub mod retry; pub mod router; diff --git a/aimux-core/src/response_messages.rs b/aimux-core/src/response_messages.rs new file mode 100644 index 00000000..59c3e63a --- /dev/null +++ b/aimux-core/src/response_messages.rs @@ -0,0 +1,229 @@ +//! Assemble the assistant message replayed to the model on the next turn. +//! +//! Rust port of the AI SDK's `toResponseMessages` +//! (`packages/ai/src/generate-text/to-response-messages.ts`): text and +//! reasoning arrive as deltas and must be flushed as positioned segments the +//! moment a part of a different kind lands, so the replayed transcript keeps +//! provider order. Tool calls/results and reasoning signatures are replayed +//! verbatim — Anthropic/Bedrock thinking signatures must round-trip exactly. + +use serde_json::Value; + +use crate::content::ContentPart; +use crate::message::{MessageContent, ModelMessage, Role}; +use crate::result::ReasoningPart; + +/// Match the AI SDK's response-message safety rule for invalid tool calls: +/// malformed primitive input must not be replayed as a prompt tool-call input. +/// JavaScript's `typeof value === "object"` includes arrays and null, so those +/// values are intentionally retained here as well. +pub(crate) fn response_tool_call_input(input: &Value, invalid: Option) -> Value { + if invalid == Some(true) && !matches!(input, Value::Object(_) | Value::Array(_) | Value::Null) { + Value::Object(serde_json::Map::new()) + } else { + input.clone() + } +} + +/// Reasoning signature echoed back on the next turn (Anthropic: +/// `provider_metadata.anthropic.signature`; Bedrock: `.bedrock.signature` / +/// `.amazonBedrock.signature`). +pub(crate) fn extract_reasoning_signature(provider_metadata: Option<&Value>) -> Option { + let metadata = provider_metadata?; + ["anthropic", "bedrock", "amazonBedrock"] + .iter() + .find_map(|ns| metadata.get(ns)?.get("signature")?.as_str()) + .map(str::to_owned) +} + +/// Accumulates response-message content parts in provider order. +/// +/// Streaming feeds it per-event (`text_start`/`text_delta`/…); the +/// non-streaming path feeds whole segments (`text`/`reasoning`). Both paths +/// share the flush discipline and the tool-call/result placement rules. +#[derive(Default)] +pub(crate) struct ResponseMessageBuilder { + parts: Vec, + text_buf: String, + text_provider_options: Option, + reasoning_buf: String, + reasoning_provider_options: Option, + reasoning: Vec, +} + +/// What the builder produced: the replayable assistant message plus the +/// reasoning aggregate surfaced on the result. +pub(crate) struct ResponseMessages { + pub messages: Vec, + pub reasoning: Vec, +} + +impl ResponseMessageBuilder { + pub fn new() -> Self { + Self::default() + } + + // ── Streaming events ──────────────────────────────────────────────── + + pub fn text_start(&mut self, provider_metadata: Option) { + self.flush_reasoning(); + // A new text segment establishes its position immediately; flush a + // preceding implicit segment before starting it. + self.flush_text(); + self.text_provider_options = provider_metadata; + } + + pub fn text_delta(&mut self, delta: &str, provider_metadata: Option) { + self.flush_reasoning(); + self.text_buf.push_str(delta); + if provider_metadata.is_some() { + self.text_provider_options = provider_metadata; + } + } + + pub fn text_end(&mut self, provider_metadata: Option) { + if provider_metadata.is_some() { + self.text_provider_options = provider_metadata; + } + self.flush_text(); + } + + pub fn reasoning_start(&mut self, provider_metadata: Option) { + self.flush_text(); + self.flush_reasoning(); + self.reasoning_provider_options = provider_metadata; + } + + pub fn reasoning_delta(&mut self, delta: &str, provider_metadata: Option) { + self.flush_text(); + self.reasoning_buf.push_str(delta); + if provider_metadata.is_some() { + self.reasoning_provider_options = provider_metadata; + } + } + + pub fn reasoning_end(&mut self, provider_metadata: Option) { + self.flush_text(); + if provider_metadata.is_some() { + self.reasoning_provider_options = provider_metadata; + } + self.flush_reasoning(); + } + + // ── Whole segments (non-streaming) ────────────────────────────────── + + pub fn text(&mut self, text: &str, provider_metadata: Option<&Value>) { + if !text.is_empty() { + self.parts.push(ContentPart::Text { + text: text.to_owned(), + provider_options: provider_metadata.cloned(), + }); + } + } + + pub fn reasoning(&mut self, text: &str, provider_metadata: Option<&Value>) { + // Pushed unconditionally: redacted thinking has empty text but its + // provider metadata must still be replayed. + self.reasoning.push(ReasoningPart { + text: text.to_owned(), + }); + let signature = extract_reasoning_signature(provider_metadata); + self.parts.push(ContentPart::Reasoning { + text: text.to_owned(), + signature, + provider_options: provider_metadata.cloned(), + }); + } + + // ── Tool parts (both paths) ───────────────────────────────────────── + + pub fn tool_call(&mut self, call: &crate::tool::ToolCall) { + self.flush_text(); + self.flush_reasoning(); + self.parts.push(ContentPart::ToolCall { + tool_call_id: call.tool_call_id.clone(), + tool_name: call.tool_name.clone(), + input: response_tool_call_input(&call.input, call.invalid), + provider_executed: call.provider_executed, + thought_signature: call.thought_signature.clone(), + provider_options: call.provider_metadata.clone(), + }); + } + + #[allow(clippy::too_many_arguments)] + pub fn tool_result( + &mut self, + tool_call_id: String, + tool_name: String, + result: Value, + is_error: Option, + preliminary: Option, + dynamic: Option, + provider_options: Option, + ) { + // Preliminary server-tool results are transient stream updates. The + // provider contract requires a later final result, and only that + // final value belongs in the replay transcript for the next turn. + if preliminary == Some(true) { + return; + } + self.flush_text(); + self.flush_reasoning(); + self.parts.push(ContentPart::ToolResult { + tool_call_id, + tool_name: Some(tool_name), + result, + is_error, + preliminary, + dynamic, + provider_options, + }); + } + + // ── Finalization ──────────────────────────────────────────────────── + + pub fn finish(mut self) -> ResponseMessages { + self.flush_text(); + self.flush_reasoning(); + let messages = if self.parts.is_empty() { + Vec::new() + } else { + vec![ModelMessage { + role: Role::Assistant, + content: MessageContent::Parts(self.parts), + }] + }; + ResponseMessages { + messages, + reasoning: self.reasoning, + } + } + + fn flush_text(&mut self) { + if !self.text_buf.is_empty() { + self.parts.push(ContentPart::Text { + text: std::mem::take(&mut self.text_buf), + provider_options: self.text_provider_options.take(), + }); + } else { + self.text_provider_options = None; + } + } + + fn flush_reasoning(&mut self) { + if self.reasoning_buf.is_empty() && self.reasoning_provider_options.is_none() { + return; + } + let text = std::mem::take(&mut self.reasoning_buf); + if !text.is_empty() { + self.reasoning.push(ReasoningPart { text: text.clone() }); + } + let provider_options = self.reasoning_provider_options.take(); + let signature = extract_reasoning_signature(provider_options.as_ref()); + self.parts.push(ContentPart::Reasoning { + text, + signature, + provider_options, + }); + } +} diff --git a/aimux-core/src/tool.rs b/aimux-core/src/tool.rs index 9117880a..c538399d 100644 --- a/aimux-core/src/tool.rs +++ b/aimux-core/src/tool.rs @@ -354,6 +354,16 @@ fn contains_forbidden_prototype(value: &Value) -> bool { } } +/// Recover the provider's raw argument text: string inputs pass through +/// verbatim (possibly malformed JSON awaiting parse/repair); anything already +/// structured re-serializes. +pub(crate) fn raw_tool_input(input: &Value) -> String { + match input { + Value::String(input) => input.clone(), + input => serde_json::to_string(input).expect("serializing serde_json::Value cannot fail"), + } +} + fn valid_tool_call(tool_call: RawToolCall, input: Value, dynamic: Option) -> ToolCall { ToolCall { tool_call_id: tool_call.tool_call_id, diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index 4f79f3b9..0d3bfa6b 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -294,6 +294,30 @@ pub(crate) fn server_tool_provider_name(name: &str) -> &str { } } +/// Finalize a streamed tool-call input accumulated from `input_json_delta`s: +/// empty input normalizes to `"{}"` per the upstream provider, and 2025 +/// code-execution variants re-wrap under their wire name so the transcript +/// replays verbatim. +pub(crate) fn finalize_streamed_tool_input( + mut accumulated_json: String, + provider_tool_name: Option<&str>, + provider_tool_input_type: Option<&str>, +) -> String { + if accumulated_json.is_empty() { + accumulated_json = "{}".to_string(); + } + if provider_tool_name == Some("code_execution") + && let Ok(parsed) = serde_json::from_str::(&accumulated_json) + { + let wire_name = match provider_tool_input_type { + Some(name @ ("text_editor_code_execution" | "bash_code_execution")) => name, + _ => "code_execution", + }; + accumulated_json = normalized_server_tool_input(wire_name, &parsed).to_string(); + } + accumulated_json +} + pub(crate) fn normalized_server_tool_input(name: &str, input: &Value) -> Value { let input_type = if matches!(name, "text_editor_code_execution" | "bash_code_execution") { Some(name) @@ -1262,7 +1286,7 @@ pub(crate) async fn anthropic_stream_core( BlockState::ToolUse { id, name, - mut accumulated_json, + accumulated_json, provider_executed, dynamic, provider_tool_name, @@ -1274,23 +1298,12 @@ pub(crate) async fn anthropic_stream_core( id: id.clone(), provider_metadata: None, }); - if accumulated_json.is_empty() { - accumulated_json = "{}".to_string(); - } - if provider_tool_name.as_deref() == Some("code_execution") - && let Ok(parsed) = serde_json::from_str::(&accumulated_json) - { - let wire_name = match provider_tool_input_type.as_deref() { - Some(name @ ("text_editor_code_execution" | "bash_code_execution")) => name, - _ => "code_execution", - }; - accumulated_json = normalized_server_tool_input( - wire_name, - &parsed, - ) - .to_string(); - } - let input = Value::String(accumulated_json); + let input = + Value::String(finalize_streamed_tool_input( + accumulated_json, + provider_tool_name.as_deref(), + provider_tool_input_type.as_deref(), + )); yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: name, diff --git a/aimux-providers/src/vertex/anthropic_model.rs b/aimux-providers/src/vertex/anthropic_model.rs index 87a70e45..f707441e 100644 --- a/aimux-providers/src/vertex/anthropic_model.rs +++ b/aimux-providers/src/vertex/anthropic_model.rs @@ -31,7 +31,7 @@ use aimux_provider_utils::{HttpRequest, RetryConfig}; use crate::anthropic::convert::{build_request_body_with_warnings, parse_stop_reason}; use crate::anthropic::stream::{ - initial_tool_input, normalized_server_tool_input, server_tool_provider_name, + finalize_streamed_tool_input, initial_tool_input, server_tool_provider_name, stream_parts_for_result_block, tool_call_caller_metadata, }; use crate::anthropic::tool_name_mapping::ToolNameMapping; @@ -526,7 +526,7 @@ impl LanguageModel for VertexAnthropicModel { BlockState::ToolUse { id, name, - mut accumulated_json, + accumulated_json, provider_executed, dynamic, provider_tool_name, @@ -535,23 +535,12 @@ impl LanguageModel for VertexAnthropicModel { .. } => { yield Ok(StreamPart::ToolInputEnd { id: id.clone(), provider_metadata: None}); - if accumulated_json.is_empty() { - accumulated_json = "{}".to_string(); - } - if provider_tool_name.as_deref() == Some("code_execution") - && let Ok(parsed) = serde_json::from_str::(&accumulated_json) - { - let wire_name = match provider_tool_input_type.as_deref() { - Some(name @ ("text_editor_code_execution" | "bash_code_execution")) => name, - _ => "code_execution", - }; - accumulated_json = normalized_server_tool_input( - wire_name, - &parsed, - ) - .to_string(); - } - let input = Value::String(accumulated_json); + let input = + Value::String(finalize_streamed_tool_input( + accumulated_json, + provider_tool_name.as_deref(), + provider_tool_input_type.as_deref(), + )); yield Ok(StreamPart::ToolCall { tool_call_id: id, tool_name: name, From 4935ffc57c6d55511c399670dcd529257ae281c2 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Tue, 25 Aug 2026 16:11:09 +0800 Subject: [PATCH 05/19] refactor(core): split the tool-parse contract out of the tool types tool.rs mixed the tool type definitions with the parse contract. Match the AI SDK's split: parse_tool_call.rs now owns RawToolCall, the repair callback and its context, parse_tool_call, JSON parsing with the prototype-pollution guard, schema validation, and raw_tool_input; tool.rs keeps only the types (Tool/FunctionTool/ProviderTool/ToolCall/ToolResult/ToolChoice). No compatibility re-exports: importers move to aimux_core::parse_tool_call. --- aimux-core/src/generate.rs | 7 +- aimux-core/src/lib.rs | 7 +- aimux-core/src/parse_tool_call.rs | 306 ++++++++++++++++++++++++++++ aimux-core/src/tool.rs | 293 -------------------------- aimux-core/tests/tool_input_test.rs | 3 +- 5 files changed, 315 insertions(+), 301 deletions(-) create mode 100644 aimux-core/src/parse_tool_call.rs diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 0da077aa..2a4e00f0 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -21,12 +21,13 @@ use crate::language_model::LanguageModel; use crate::language_model_message::convert_to_language_model_prompt; use crate::message::{ModelMessage, ModelPrompt}; use crate::options::{CallOptions, ResponseFormat, ToolChoice}; +use crate::parse_tool_call::{RawToolCall, ToolCallRepair, parse_tool_call}; use crate::result::{ FilePart, GenerateContent, GenerateResult, ReasoningPart, SourcePart, StreamResult, StreamTextResultAggregated, }; use crate::stream_part::StreamPart; -use crate::tool::{RawToolCall, Tool, ToolCallRepair, parse_tool_call}; +use crate::tool::Tool; use crate::types::{FinishReason, ReasoningEffort, Usage, Warning}; use crate::{AbortSignal, retry, timeout}; @@ -669,7 +670,7 @@ pub async fn generate_text( RawToolCall { tool_call_id: tool_call_id.clone(), tool_name: tool_name.clone(), - input: crate::tool::raw_tool_input(input), + input: crate::parse_tool_call::raw_tool_input(input), provider_executed: *provider_executed, dynamic: *dynamic, thought_signature: thought_signature.clone(), @@ -1114,7 +1115,7 @@ pub async fn stream_text( RawToolCall { tool_call_id, tool_name, - input: crate::tool::raw_tool_input(&input), + input: crate::parse_tool_call::raw_tool_input(&input), provider_executed, dynamic, thought_signature, diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index 879ecc01..adcf2c35 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -35,6 +35,7 @@ pub mod model_catalogue; pub mod model_id; pub mod openai_output; pub mod options; +pub mod parse_tool_call; pub mod provider; pub mod recording; pub mod replay; @@ -86,6 +87,7 @@ pub mod prelude { encode_chunk_sse, to_chat_completion, to_chat_completion_stream, }; pub use crate::options::{CallOptions, ResponseFormat, ToolChoice}; + pub use crate::parse_tool_call::{RawToolCall, ToolCallRepair, ToolCallRepairContext}; pub use crate::provider::Provider; pub use crate::reranking_model::{ RerankingCallOptions, RerankingModel, RerankingResult, rerank, @@ -107,10 +109,7 @@ pub mod prelude { }; pub use crate::speech_model::{SpeechCallOptions, SpeechModel, SpeechResult, generate_speech}; pub use crate::stream_part::StreamPart; - pub use crate::tool::{ - FunctionTool, ProviderTool, RawToolCall, Tool, ToolCall, ToolCallRepair, - ToolCallRepairContext, ToolResult, - }; + pub use crate::tool::{FunctionTool, ProviderTool, Tool, ToolCall, ToolResult}; pub use crate::transcription_model::{ AudioChunk, InputAudioFormat, TranscriptionCallOptions, TranscriptionModel, TranscriptionResult, TranscriptionStreamOptions, TranscriptionStreamPart, diff --git a/aimux-core/src/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs new file mode 100644 index 00000000..fb4bed7a --- /dev/null +++ b/aimux-core/src/parse_tool_call.rs @@ -0,0 +1,306 @@ +//! Parse and validate provider tool calls at the Core boundary. +//! +//! Rust port of the AI SDK's `parse-tool-call.ts` +//! (`packages/ai/src/generate-text/parse-tool-call.ts`). Providers deliver the +//! model's raw argument text; Core owns JSON parsing, prototype-pollution +//! rejection, schema validation, and the one-shot repair callback. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use serde_json::Value; + +use crate::error::AiMuxError; +use crate::tool::{Tool, ToolCall}; +use crate::types::ProviderMetadata; + +/// Provider-facing tool call before Core parses and validates its input. +#[derive(Debug, Clone)] +pub struct RawToolCall { + pub tool_call_id: String, + pub tool_name: String, + pub input: String, + pub provider_executed: Option, + pub dynamic: Option, + pub thought_signature: Option, + pub provider_metadata: Option, +} + +/// Context supplied to a one-shot tool-call repair callback. +#[derive(Debug, Clone)] +pub struct ToolCallRepairContext { + pub instructions: Option, + /// Deprecated AI SDK-compatible alias for `instructions`. + pub system: Option, + pub messages: Vec, + pub tool_call: RawToolCall, + pub tools: Vec, + pub error: AiMuxError, +} + +impl ToolCallRepairContext { + /// Return the JSON Schema for a named function tool in this repair step. + /// + /// Never fails, matching the AI SDK's `inputSchema` repair argument: a + /// name that does not resolve to a function tool (unknown — the NoSuchTool + /// repair scenario — or a provider tool, which carries no schema at this + /// layer) yields the AI SDK's default empty-object schema. + #[must_use] + pub fn input_schema(&self, tool_name: &str) -> Value { + self.tools + .iter() + .find_map(|tool| match tool { + Tool::Function(tool) if tool.name == tool_name => Some(tool.input_schema.clone()), + _ => None, + }) + .unwrap_or_else(|| { + serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) + }) + } +} + +type ToolCallRepairFuture = + Pin, AiMuxError>> + Send>>; + +/// Async callback that may replace one invalid tool call. +/// +/// Core invokes this at most once, and parses and validates the returned call +/// from scratch. Returning `None` keeps the original validation error. +#[derive(Clone)] +pub struct ToolCallRepair(Arc ToolCallRepairFuture + Send + Sync>); + +impl ToolCallRepair { + #[must_use] + pub fn new(repair: F) -> Self + where + F: Fn(ToolCallRepairContext) -> Fut + Send + Sync + 'static, + Fut: Future, AiMuxError>> + Send + 'static, + { + Self(Arc::new(move |context| Box::pin(repair(context)))) + } + + async fn repair( + &self, + context: ToolCallRepairContext, + ) -> Result, AiMuxError> { + (self.0)(context).await + } +} + +impl std::fmt::Debug for ToolCallRepair { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ToolCallRepair()") + } +} + +/// Parse and validate a provider tool call using the AI SDK operation contract. +/// +/// JSON is parsed exactly; partial-JSON repair is deliberately not automatic. +/// When a tool set was supplied, lookup, parsing, or schema validation failure +/// gives the optional repair callback one attempt. As in AI SDK, calls made +/// without a tool set bypass repair. A remaining failure is represented on the +/// returned call so callers retain both the model output and its typed error. +pub async fn parse_tool_call( + tool_call: RawToolCall, + tools: Option<&[Tool]>, + repair_tool_call: Option<&ToolCallRepair>, + messages: &[crate::message::ModelMessage], + instructions: Option<&str>, +) -> ToolCall { + let Some(tools) = tools else { + let parsed = if tool_call.provider_executed == Some(true) && tool_call.dynamic == Some(true) + { + parse_json_input(&tool_call) + } else { + Err(AiMuxError::NoSuchTool { + tool_name: tool_call.tool_name.clone(), + available_tools: None, + }) + }; + return match parsed { + Ok(input) => valid_tool_call(tool_call, input, Some(true)), + Err(error) => invalid_tool_call(tool_call, error), + }; + }; + + match parse_and_validate_tool_call(&tool_call, tools) { + Ok((input, dynamic)) => valid_tool_call(tool_call, input, dynamic), + Err(original_error) => { + if let Some(repair_tool_call) = repair_tool_call { + let context = ToolCallRepairContext { + instructions: instructions.map(str::to_owned), + system: instructions.map(str::to_owned), + messages: messages.to_vec(), + tool_call: tool_call.clone(), + tools: tools.to_vec(), + error: original_error.clone(), + }; + match repair_tool_call.repair(context).await { + Ok(Some(repaired)) => match parse_and_validate_tool_call(&repaired, tools) { + Ok((input, dynamic)) => return valid_tool_call(repaired, input, dynamic), + Err(repaired_error) => { + return invalid_tool_call(tool_call, repaired_error); + } + }, + Ok(None) => {} + Err(repair_error) => { + return invalid_tool_call( + tool_call, + AiMuxError::ToolCallRepair { + original_error: Box::new(original_error), + cause: Box::new(repair_error), + }, + ); + } + } + } + invalid_tool_call(tool_call, original_error) + } + } +} + +fn parse_and_validate_tool_call( + tool_call: &RawToolCall, + tools: &[Tool], +) -> Result<(Value, Option), AiMuxError> { + let tool = tools.iter().find(|tool| match tool { + Tool::Function(tool) => tool.name == tool_call.tool_name, + Tool::Provider(tool) => tool.name == tool_call.tool_name, + }); + + let provider_dynamic = + tool_call.provider_executed == Some(true) && tool_call.dynamic == Some(true); + let Some(tool) = tool else { + if provider_dynamic { + return parse_json_input(tool_call).map(|input| (input, Some(true))); + } + return Err(AiMuxError::NoSuchTool { + tool_name: tool_call.tool_name.clone(), + available_tools: Some( + tools + .iter() + .map(|tool| match tool { + Tool::Function(tool) => tool.name.clone(), + Tool::Provider(tool) => tool.name.clone(), + }) + .collect(), + ), + }); + }; + + let input = parse_json_input(tool_call)?; + let Tool::Function(function_tool) = tool else { + return Ok((input, None)); + }; + let validator = jsonschema::validator_for(&function_tool.input_schema).map_err(|error| { + AiMuxError::InvalidToolInput { + tool_name: tool_call.tool_name.clone(), + tool_input: tool_call.input.clone(), + cause: format!("input schema is invalid: {error}"), + } + })?; + // Cause wording matches the AI SDK's `TypeValidationError` template; + // `Value` Display is compact JSON, the `JSON.stringify` equivalent. + validator + .validate(&input) + .map_err(|error| AiMuxError::InvalidToolInput { + tool_name: tool_call.tool_name.clone(), + tool_input: tool_call.input.clone(), + cause: format!("Type validation failed: Value: {input}.\nError message: {error}"), + })?; + Ok((input, None)) +} + +fn parse_json_input(tool_call: &RawToolCall) -> Result { + if tool_call.input.trim().is_empty() { + return Ok(Value::Object(serde_json::Map::new())); + } + // Cause wording matches the AI SDK's `JSONParseError` template. + let value: Value = + serde_json::from_str(&tool_call.input).map_err(|error| AiMuxError::InvalidToolInput { + tool_name: tool_call.tool_name.clone(), + tool_input: tool_call.input.clone(), + cause: format!( + "JSON parsing failed: Text: {}.\nError message: {error}", + tool_call.input + ), + })?; + if contains_forbidden_prototype(&value) { + return Err(AiMuxError::InvalidToolInput { + tool_name: tool_call.tool_name.clone(), + tool_input: tool_call.input.clone(), + cause: format!( + "JSON parsing failed: Text: {}.\nError message: Object contains forbidden prototype property", + tool_call.input + ), + }); + } + Ok(value) +} + +// Port of the AI SDK's secure JSON parse (fastify/secure-json-parse): a +// `__proto__` key, or a `constructor` object carrying a `prototype` key, +// anywhere in the tree marks the input invalid. Rust has no prototype +// pollution, but the parsed value crosses the FFI into JS and Python, and +// the valid/invalid classification must match upstream. +fn contains_forbidden_prototype(value: &Value) -> bool { + match value { + Value::Object(map) => { + let constructor_prototype = map + .get("constructor") + .and_then(Value::as_object) + .is_some_and(|constructor| constructor.contains_key("prototype")); + if map.contains_key("__proto__") || constructor_prototype { + return true; + } + map.values().any(contains_forbidden_prototype) + } + Value::Array(items) => items.iter().any(contains_forbidden_prototype), + _ => false, + } +} + +/// Recover the provider's raw argument text: string inputs pass through +/// verbatim (possibly malformed JSON awaiting parse/repair); anything already +/// structured re-serializes. +pub(crate) fn raw_tool_input(input: &Value) -> String { + match input { + Value::String(input) => input.clone(), + input => serde_json::to_string(input).expect("serializing serde_json::Value cannot fail"), + } +} + +fn valid_tool_call(tool_call: RawToolCall, input: Value, dynamic: Option) -> ToolCall { + ToolCall { + tool_call_id: tool_call.tool_call_id, + tool_name: tool_call.tool_name, + input, + provider_executed: tool_call.provider_executed, + dynamic, + thought_signature: tool_call.thought_signature, + provider_metadata: tool_call.provider_metadata, + invalid: None, + error: None, + } +} + +fn invalid_tool_call(tool_call: RawToolCall, error: AiMuxError) -> ToolCall { + let input = serde_json::from_str(&tool_call.input) + .unwrap_or_else(|_| Value::String(tool_call.input.clone())); + ToolCall { + tool_call_id: tool_call.tool_call_id, + tool_name: tool_call.tool_name, + input, + provider_executed: tool_call.provider_executed, + dynamic: Some(true), + thought_signature: tool_call.thought_signature, + provider_metadata: tool_call.provider_metadata, + invalid: Some(true), + error: Some(error), + } +} diff --git a/aimux-core/src/tool.rs b/aimux-core/src/tool.rs index c538399d..7c1176da 100644 --- a/aimux-core/src/tool.rs +++ b/aimux-core/src/tool.rs @@ -1,9 +1,6 @@ //! Tool / function-calling types. use std::collections::HashMap; -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -104,296 +101,6 @@ impl From for Tool { } } -/// Provider-facing tool call before Core parses and validates its input. -#[derive(Debug, Clone)] -pub struct RawToolCall { - pub tool_call_id: String, - pub tool_name: String, - pub input: String, - pub provider_executed: Option, - pub dynamic: Option, - pub thought_signature: Option, - pub provider_metadata: Option, -} - -/// Context supplied to a one-shot tool-call repair callback. -#[derive(Debug, Clone)] -pub struct ToolCallRepairContext { - pub instructions: Option, - /// Deprecated AI SDK-compatible alias for `instructions`. - pub system: Option, - pub messages: Vec, - pub tool_call: RawToolCall, - pub tools: Vec, - pub error: AiMuxError, -} - -impl ToolCallRepairContext { - /// Return the JSON Schema for a named function tool in this repair step. - /// - /// Never fails, matching the AI SDK's `inputSchema` repair argument: a - /// name that does not resolve to a function tool (unknown — the NoSuchTool - /// repair scenario — or a provider tool, which carries no schema at this - /// layer) yields the AI SDK's default empty-object schema. - #[must_use] - pub fn input_schema(&self, tool_name: &str) -> Value { - self.tools - .iter() - .find_map(|tool| match tool { - Tool::Function(tool) if tool.name == tool_name => Some(tool.input_schema.clone()), - _ => None, - }) - .unwrap_or_else(|| { - serde_json::json!({ - "type": "object", - "properties": {}, - "additionalProperties": false - }) - }) - } -} - -type ToolCallRepairFuture = - Pin, AiMuxError>> + Send>>; - -/// Async callback that may replace one invalid tool call. -/// -/// Core invokes this at most once, and parses and validates the returned call -/// from scratch. Returning `None` keeps the original validation error. -#[derive(Clone)] -pub struct ToolCallRepair(Arc ToolCallRepairFuture + Send + Sync>); - -impl ToolCallRepair { - #[must_use] - pub fn new(repair: F) -> Self - where - F: Fn(ToolCallRepairContext) -> Fut + Send + Sync + 'static, - Fut: Future, AiMuxError>> + Send + 'static, - { - Self(Arc::new(move |context| Box::pin(repair(context)))) - } - - async fn repair( - &self, - context: ToolCallRepairContext, - ) -> Result, AiMuxError> { - (self.0)(context).await - } -} - -impl std::fmt::Debug for ToolCallRepair { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("ToolCallRepair()") - } -} - -/// Parse and validate a provider tool call using the AI SDK operation contract. -/// -/// JSON is parsed exactly; partial-JSON repair is deliberately not automatic. -/// When a tool set was supplied, lookup, parsing, or schema validation failure -/// gives the optional repair callback one attempt. As in AI SDK, calls made -/// without a tool set bypass repair. A remaining failure is represented on the -/// returned call so callers retain both the model output and its typed error. -pub async fn parse_tool_call( - tool_call: RawToolCall, - tools: Option<&[Tool]>, - repair_tool_call: Option<&ToolCallRepair>, - messages: &[crate::message::ModelMessage], - instructions: Option<&str>, -) -> ToolCall { - let Some(tools) = tools else { - let parsed = if tool_call.provider_executed == Some(true) && tool_call.dynamic == Some(true) - { - parse_json_input(&tool_call) - } else { - Err(AiMuxError::NoSuchTool { - tool_name: tool_call.tool_name.clone(), - available_tools: None, - }) - }; - return match parsed { - Ok(input) => valid_tool_call(tool_call, input, Some(true)), - Err(error) => invalid_tool_call(tool_call, error), - }; - }; - - match parse_and_validate_tool_call(&tool_call, tools) { - Ok((input, dynamic)) => valid_tool_call(tool_call, input, dynamic), - Err(original_error) => { - if let Some(repair_tool_call) = repair_tool_call { - let context = ToolCallRepairContext { - instructions: instructions.map(str::to_owned), - system: instructions.map(str::to_owned), - messages: messages.to_vec(), - tool_call: tool_call.clone(), - tools: tools.to_vec(), - error: original_error.clone(), - }; - match repair_tool_call.repair(context).await { - Ok(Some(repaired)) => match parse_and_validate_tool_call(&repaired, tools) { - Ok((input, dynamic)) => return valid_tool_call(repaired, input, dynamic), - Err(repaired_error) => { - return invalid_tool_call(tool_call, repaired_error); - } - }, - Ok(None) => {} - Err(repair_error) => { - return invalid_tool_call( - tool_call, - AiMuxError::ToolCallRepair { - original_error: Box::new(original_error), - cause: Box::new(repair_error), - }, - ); - } - } - } - invalid_tool_call(tool_call, original_error) - } - } -} - -fn parse_and_validate_tool_call( - tool_call: &RawToolCall, - tools: &[Tool], -) -> Result<(Value, Option), AiMuxError> { - let tool = tools.iter().find(|tool| match tool { - Tool::Function(tool) => tool.name == tool_call.tool_name, - Tool::Provider(tool) => tool.name == tool_call.tool_name, - }); - - let provider_dynamic = - tool_call.provider_executed == Some(true) && tool_call.dynamic == Some(true); - let Some(tool) = tool else { - if provider_dynamic { - return parse_json_input(tool_call).map(|input| (input, Some(true))); - } - return Err(AiMuxError::NoSuchTool { - tool_name: tool_call.tool_name.clone(), - available_tools: Some( - tools - .iter() - .map(|tool| match tool { - Tool::Function(tool) => tool.name.clone(), - Tool::Provider(tool) => tool.name.clone(), - }) - .collect(), - ), - }); - }; - - let input = parse_json_input(tool_call)?; - let Tool::Function(function_tool) = tool else { - return Ok((input, None)); - }; - let validator = jsonschema::validator_for(&function_tool.input_schema).map_err(|error| { - AiMuxError::InvalidToolInput { - tool_name: tool_call.tool_name.clone(), - tool_input: tool_call.input.clone(), - cause: format!("input schema is invalid: {error}"), - } - })?; - // Cause wording matches the AI SDK's `TypeValidationError` template; - // `Value` Display is compact JSON, the `JSON.stringify` equivalent. - validator - .validate(&input) - .map_err(|error| AiMuxError::InvalidToolInput { - tool_name: tool_call.tool_name.clone(), - tool_input: tool_call.input.clone(), - cause: format!("Type validation failed: Value: {input}.\nError message: {error}"), - })?; - Ok((input, None)) -} - -fn parse_json_input(tool_call: &RawToolCall) -> Result { - if tool_call.input.trim().is_empty() { - return Ok(Value::Object(serde_json::Map::new())); - } - // Cause wording matches the AI SDK's `JSONParseError` template. - let value: Value = - serde_json::from_str(&tool_call.input).map_err(|error| AiMuxError::InvalidToolInput { - tool_name: tool_call.tool_name.clone(), - tool_input: tool_call.input.clone(), - cause: format!( - "JSON parsing failed: Text: {}.\nError message: {error}", - tool_call.input - ), - })?; - if contains_forbidden_prototype(&value) { - return Err(AiMuxError::InvalidToolInput { - tool_name: tool_call.tool_name.clone(), - tool_input: tool_call.input.clone(), - cause: format!( - "JSON parsing failed: Text: {}.\nError message: Object contains forbidden prototype property", - tool_call.input - ), - }); - } - Ok(value) -} - -// Port of the AI SDK's secure JSON parse (fastify/secure-json-parse): a -// `__proto__` key, or a `constructor` object carrying a `prototype` key, -// anywhere in the tree marks the input invalid. Rust has no prototype -// pollution, but the parsed value crosses the FFI into JS and Python, and -// the valid/invalid classification must match upstream. -fn contains_forbidden_prototype(value: &Value) -> bool { - match value { - Value::Object(map) => { - let constructor_prototype = map - .get("constructor") - .and_then(Value::as_object) - .is_some_and(|constructor| constructor.contains_key("prototype")); - if map.contains_key("__proto__") || constructor_prototype { - return true; - } - map.values().any(contains_forbidden_prototype) - } - Value::Array(items) => items.iter().any(contains_forbidden_prototype), - _ => false, - } -} - -/// Recover the provider's raw argument text: string inputs pass through -/// verbatim (possibly malformed JSON awaiting parse/repair); anything already -/// structured re-serializes. -pub(crate) fn raw_tool_input(input: &Value) -> String { - match input { - Value::String(input) => input.clone(), - input => serde_json::to_string(input).expect("serializing serde_json::Value cannot fail"), - } -} - -fn valid_tool_call(tool_call: RawToolCall, input: Value, dynamic: Option) -> ToolCall { - ToolCall { - tool_call_id: tool_call.tool_call_id, - tool_name: tool_call.tool_name, - input, - provider_executed: tool_call.provider_executed, - dynamic, - thought_signature: tool_call.thought_signature, - provider_metadata: tool_call.provider_metadata, - invalid: None, - error: None, - } -} - -fn invalid_tool_call(tool_call: RawToolCall, error: AiMuxError) -> ToolCall { - let input = serde_json::from_str(&tool_call.input) - .unwrap_or_else(|_| Value::String(tool_call.input.clone())); - ToolCall { - tool_call_id: tool_call.tool_call_id, - tool_name: tool_call.tool_name, - input, - provider_executed: tool_call.provider_executed, - dynamic: Some(true), - thought_signature: tool_call.thought_signature, - provider_metadata: tool_call.provider_metadata, - invalid: Some(true), - error: Some(error), - } -} - /// A tool call requested by the model. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] diff --git a/aimux-core/tests/tool_input_test.rs b/aimux-core/tests/tool_input_test.rs index 83e884b9..d9888ab9 100644 --- a/aimux-core/tests/tool_input_test.rs +++ b/aimux-core/tests/tool_input_test.rs @@ -8,9 +8,10 @@ use aimux_core::generate::{ use aimux_core::language_model::LanguageModel; use aimux_core::openai_output::OpenAiStreamOptions; use aimux_core::options::CallOptions; +use aimux_core::parse_tool_call::{RawToolCall, ToolCallRepair, parse_tool_call}; use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, RawToolCall, Tool, ToolCallRepair, parse_tool_call}; +use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; use async_trait::async_trait; use futures::StreamExt; From b265847a0eecedb1f818482be149f7835b8edcd8 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Tue, 25 Aug 2026 16:27:57 +0800 Subject: [PATCH 06/19] refactor(core)!: type the provider tool-input boundary as String MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GenerateContent::ToolCall.input was a serde_json::Value that only ever carried the provider's raw argument text wrapped in Value::String — the contract lived in a doc comment, so nothing stopped a provider from parsing the arguments itself and handing Core a structured value. Typing the field as String makes 'providers never parse tool input' a compile error to violate, and drops the unwrap dance on the Core side. The wire format is unchanged: Value::String(text) and String serialize identically, so no binding or fixture moves. StreamPart::ToolCall.input stays a Value: unlike GenerateContent it is dual-use — providers emit raw text, Core replaces it in-flight with the parsed value, and the user-facing stream sees the parsed one. Separating those two directions means splitting the stream-part type the way the AI SDK splits LanguageModelV2StreamPart from TextStreamPart, which is its own PR. --- aimux-core/src/generate.rs | 2 +- aimux-core/src/openai_output.rs | 19 ++++++++++--------- aimux-core/src/replay.rs | 2 +- aimux-core/src/result.rs | 7 ++++--- aimux-core/tests/m7_aggregation_test.rs | 2 +- .../tests/response_messages_tool_test.rs | 4 ++-- aimux-core/tests/tool_input_test.rs | 2 +- aimux-providers/src/anthropic/stream.rs | 6 +++--- aimux-providers/src/bedrock/model.rs | 2 +- aimux-providers/src/cohere/model.rs | 2 +- aimux-providers/src/google/model.rs | 6 +++--- aimux-providers/src/huggingface/responses.rs | 6 +++--- aimux-providers/src/mistral/model.rs | 2 +- aimux-providers/src/open_responses.rs | 2 +- aimux-providers/src/openai/model.rs | 2 +- .../src/openai/responses/responses_convert.rs | 8 +++----- aimux-providers/src/vertex/model.rs | 6 +++--- aimux-providers/src/xai/model.rs | 2 +- aimux-providers/src/xai/responses/mod.rs | 6 +++--- .../tests/anthropic_aws_model_test.rs | 2 +- aimux-providers/tests/anthropic_model_test.rs | 2 +- aimux-providers/tests/bedrock_model_test.rs | 2 +- .../tests/bedrock_remaining_test.rs | 2 +- .../tests/data_loss_regression_test.rs | 6 ++---- .../tests/google_provider_tools_test.rs | 2 +- aimux-providers/tests/vertex_model_test.rs | 2 +- bindings/node/src/types/GenerateContent.ts | 7 ++++--- 27 files changed, 56 insertions(+), 57 deletions(-) diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 2a4e00f0..dc17ce0f 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -670,7 +670,7 @@ pub async fn generate_text( RawToolCall { tool_call_id: tool_call_id.clone(), tool_name: tool_name.clone(), - input: crate::parse_tool_call::raw_tool_input(input), + input: input.clone(), provider_executed: *provider_executed, dynamic: *dynamic, thought_signature: thought_signature.clone(), diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index b10ff719..e580d777 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -245,12 +245,13 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio input, .. } => { - let arguments = match input { - // Provider results carry string-native arguments verbatim; - // serializing the Value again would add an extra JSON layer. - serde_json::Value::String(input) => input.clone(), - serde_json::Value::Null => "{}".to_string(), - input => input.to_string(), + // The provider's raw argument text passes through verbatim; + // OpenAI's wire format requires a JSON object even when the + // model emitted no arguments at all. + let arguments = if input.is_empty() { + "{}".to_string() + } else { + input.clone() }; tool_calls.push(ChatCompletionToolCall { id: tool_call_id.clone(), @@ -1174,7 +1175,7 @@ mod tests { GenerateContent::ToolCall { tool_call_id: "call_abc".to_string(), tool_name: "get_weather".to_string(), - input: json!({"city": "Tokyo"}), + input: r#"{"city":"Tokyo"}"#.to_string(), provider_executed: None, dynamic: None, thought_signature: None, @@ -1209,7 +1210,7 @@ mod tests { let result = make_result(vec![GenerateContent::ToolCall { tool_call_id: "call_raw".to_string(), tool_name: "get_weather".to_string(), - input: json!(r#"{"city":"Tokyo"}"#), + input: r#"{"city":"Tokyo"}"#.to_string(), provider_executed: None, dynamic: None, thought_signature: None, @@ -1231,7 +1232,7 @@ mod tests { let result = make_result(vec![GenerateContent::ToolCall { tool_call_id: "call_abc".to_string(), tool_name: "get_weather".to_string(), - input: json!({"city": "Tokyo"}), + input: r#"{"city":"Tokyo"}"#.to_string(), provider_executed: None, dynamic: None, thought_signature: None, diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index c523b1a0..79c599fe 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -591,7 +591,7 @@ fn rebuild_generate_result(rec: &Recording) -> Result Result, AiMu .get("arguments") .and_then(|v| v.as_str()) .unwrap_or("{}"); - let input = Value::String(arguments.to_string()); + let input = arguments.to_string(); content.push(GenerateContent::ToolCall { tool_call_id: call_id.to_string(), tool_name: name.to_string(), @@ -1158,7 +1158,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu .get("arguments") .and_then(|v| v.as_str()) .unwrap_or("{}"); - let input = Value::String(arguments.to_string()); + let input = arguments.to_string(); content.push(GenerateContent::ToolCall { tool_call_id: id.to_string(), tool_name: name.to_string(), @@ -1188,7 +1188,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu content.push(GenerateContent::ToolCall { tool_call_id: id.to_string(), tool_name: "list_tools".to_string(), - input: Value::String(json!({ "server_label": server_label }).to_string()), + input: json!({ "server_label": server_label }).to_string(), provider_executed: Some(true), dynamic: Some(true), thought_signature: None, diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index ef7e7020..52d63e87 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -274,7 +274,7 @@ impl LanguageModel for MistralModel { // Tool calls. if let Some(tool_calls) = choice.message.tool_calls { for tc in tool_calls { - let input = Value::String(tc.function.arguments); + let input = tc.function.arguments; content.push(GenerateContent::ToolCall { tool_call_id: tc.id, tool_name: tc.function.name, diff --git a/aimux-providers/src/open_responses.rs b/aimux-providers/src/open_responses.rs index 0921b99d..340237e5 100644 --- a/aimux-providers/src/open_responses.rs +++ b/aimux-providers/src/open_responses.rs @@ -395,7 +395,7 @@ impl LanguageModel for OpenResponsesModel { .get("arguments") .and_then(|a| a.as_str()) .unwrap_or("{}"); - let input = Value::String(arguments.to_string()); + let input = arguments.to_string(); content.push(GenerateContent::ToolCall { tool_call_id: call_id, tool_name: name, diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 002f04aa..6445d52c 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -325,7 +325,7 @@ pub async fn execute_generate( } if let Some(tool_calls) = choice.message.tool_calls { for tc in tool_calls { - let input = Value::String(tc.function.arguments); + let input = tc.function.arguments; content.push(GenerateContent::ToolCall { tool_call_id: tc.id, tool_name: tc.function.name, diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_convert.rs index 01ef3cd1..ea51c247 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_convert.rs @@ -189,7 +189,7 @@ pub fn build_responses_generate_result( .and_then(|v| v.as_str()) .unwrap_or("{}") .to_string(); - let input = Value::String(arguments); + let input = arguments; content.push(GenerateContent::ToolCall { tool_call_id: call_id, tool_name: name, @@ -217,10 +217,8 @@ pub fn build_responses_generate_result( .unwrap_or("") .to_string(); let input_str = part.get("input").and_then(|v| v.as_str()).unwrap_or("{}"); - let input = Value::String( - serde_json::to_string(input_str) - .expect("serializing a custom-tool input string cannot fail"), - ); + let input = serde_json::to_string(input_str) + .expect("serializing a custom-tool input string cannot fail"); content.push(GenerateContent::ToolCall { tool_call_id: call_id, tool_name: name, diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index ddec5545..e151adfd 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -698,7 +698,7 @@ fn extract_content_from_candidate( content.push(GenerateContent::ToolCall { tool_call_id: id.clone(), tool_name: code_execution_tool_name.to_string(), - input: Value::String(ec.to_string()), + input: ec.to_string(), provider_executed: Some(true), dynamic: None, thought_signature: None, @@ -766,7 +766,7 @@ fn extract_content_from_candidate( content.push(GenerateContent::ToolCall { tool_call_id: id, tool_name: name, - input: Value::String(input.to_string()), + input: input.to_string(), provider_executed: None, dynamic: None, thought_signature, @@ -791,7 +791,7 @@ fn extract_content_from_candidate( content.push(GenerateContent::ToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), - input: Value::String(input.to_string()), + input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), thought_signature, diff --git a/aimux-providers/src/xai/model.rs b/aimux-providers/src/xai/model.rs index c439c48e..99cce132 100644 --- a/aimux-providers/src/xai/model.rs +++ b/aimux-providers/src/xai/model.rs @@ -170,7 +170,7 @@ impl LanguageModel for XaiModel { // Extract tool calls if let Some(tool_calls) = choice.message.tool_calls { for tc in tool_calls { - let input = Value::String(tc.function.arguments); + let input = tc.function.arguments; content.push(GenerateContent::ToolCall { tool_call_id: tc.id, tool_name: tc.function.name, diff --git a/aimux-providers/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index bca5882e..e8beb368 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -135,7 +135,7 @@ impl LanguageModel for XaiResponsesModel { content.push(GenerateContent::ToolCall { tool_call_id: part_id.to_string(), tool_name: tool_name.clone(), - input: Value::String(String::new()), + input: String::new(), provider_executed: Some(true), dynamic: None, thought_signature: None, @@ -192,7 +192,7 @@ impl LanguageModel for XaiResponsesModel { content.push(GenerateContent::ToolCall { tool_call_id: part_id.to_string(), tool_name, - input: Value::String(tool_input), + input: tool_input, provider_executed: Some(true), dynamic: None, thought_signature: None, @@ -245,7 +245,7 @@ impl LanguageModel for XaiResponsesModel { let call_id = part.get("call_id").and_then(|v| v.as_str()).unwrap_or(""); let name = part.get("name").and_then(|v| v.as_str()).unwrap_or(""); let arguments = part.get("arguments").and_then(|v| v.as_str()).unwrap_or(""); - let input = Value::String(arguments.to_string()); + let input = arguments.to_string(); content.push(GenerateContent::ToolCall { tool_call_id: call_id.to_string(), tool_name: name.to_string(), diff --git a/aimux-providers/tests/anthropic_aws_model_test.rs b/aimux-providers/tests/anthropic_aws_model_test.rs index e44175ad..8b618fdb 100644 --- a/aimux-providers/tests/anthropic_aws_model_test.rs +++ b/aimux-providers/tests/anthropic_aws_model_test.rs @@ -86,7 +86,7 @@ fn as_text(item: &GenerateContent) -> &str { } } -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &Value) { +fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { match item { GenerateContent::ToolCall { tool_call_id, diff --git a/aimux-providers/tests/anthropic_model_test.rs b/aimux-providers/tests/anthropic_model_test.rs index 95db86c7..d567328c 100644 --- a/aimux-providers/tests/anthropic_model_test.rs +++ b/aimux-providers/tests/anthropic_model_test.rs @@ -133,7 +133,7 @@ fn as_text(item: &GenerateContent) -> &str { } /// Helper to destructure a `GenerateContent::ToolCall`. -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &Value) { +fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { match item { GenerateContent::ToolCall { tool_call_id, diff --git a/aimux-providers/tests/bedrock_model_test.rs b/aimux-providers/tests/bedrock_model_test.rs index 28f652f8..eebc91f3 100644 --- a/aimux-providers/tests/bedrock_model_test.rs +++ b/aimux-providers/tests/bedrock_model_test.rs @@ -65,7 +65,7 @@ fn as_text(item: &GenerateContent) -> &str { } } -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &Value) { +fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { match item { GenerateContent::ToolCall { tool_call_id, diff --git a/aimux-providers/tests/bedrock_remaining_test.rs b/aimux-providers/tests/bedrock_remaining_test.rs index 5ca80bb9..c17fbb90 100644 --- a/aimux-providers/tests/bedrock_remaining_test.rs +++ b/aimux-providers/tests/bedrock_remaining_test.rs @@ -97,7 +97,7 @@ fn as_text(item: &GenerateContent) -> &str { } } -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &Value) { +fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { match item { GenerateContent::ToolCall { tool_call_id, diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index c9a3c9c0..3810d3c0 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -223,10 +223,8 @@ fn tool_calls(content: &[GenerateContent]) -> Vec> { // Provider results intentionally carry the exact wire string; // parse only in this assertion helper so nested data-loss // checks remain readable without weakening that boundary. - let parsed_input = input - .as_str() - .and_then(|raw| serde_json::from_str(raw).ok()) - .unwrap_or_else(|| input.clone()); + let parsed_input = + serde_json::from_str(input).unwrap_or_else(|_| Value::String(input.clone())); Some(( tool_call_id.as_str(), tool_name.as_str(), diff --git a/aimux-providers/tests/google_provider_tools_test.rs b/aimux-providers/tests/google_provider_tools_test.rs index 84374c77..c05de148 100644 --- a/aimux-providers/tests/google_provider_tools_test.rs +++ b/aimux-providers/tests/google_provider_tools_test.rs @@ -197,7 +197,7 @@ fn stream_sources(parts: &[StreamPart]) -> Vec<(String, String, Option, } /// Extract `(tool_call_id, tool_name, input)` from `GenerateContent::ToolCall`. -fn gen_tool_calls(content: &[GenerateContent]) -> Vec<(String, String, Value)> { +fn gen_tool_calls(content: &[GenerateContent]) -> Vec<(String, String, String)> { content .iter() .filter_map(|c| match c { diff --git a/aimux-providers/tests/vertex_model_test.rs b/aimux-providers/tests/vertex_model_test.rs index 220c2846..29556217 100644 --- a/aimux-providers/tests/vertex_model_test.rs +++ b/aimux-providers/tests/vertex_model_test.rs @@ -98,7 +98,7 @@ fn as_text(item: &GenerateContent) -> &str { } } -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &Value) { +fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { match item { GenerateContent::ToolCall { tool_call_id, diff --git a/bindings/node/src/types/GenerateContent.ts b/bindings/node/src/types/GenerateContent.ts index 7de2d7f1..78841445 100644 --- a/bindings/node/src/types/GenerateContent.ts +++ b/bindings/node/src/types/GenerateContent.ts @@ -7,10 +7,11 @@ import type { JsonValue } from "./serde_json/JsonValue"; */ export type GenerateContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": { tool_call_id: string, tool_name: string, /** - * Raw provider input. Providers put serialized argument text in a - * `Value::String`; `generate_text` parses and validates it. + * The model's raw argument text, exactly as the provider delivered + * it (possibly malformed). Providers never parse it — `generate_text` + * owns parsing, schema validation, and repair. */ -input: JsonValue, +input: string, /** * Whether the tool call will be executed by the provider. * If false/unset, the tool call is executed by the client. From b2aa752f0ee9408a396a198495c5e2586a992664 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Wed, 26 Aug 2026 02:28:09 +0800 Subject: [PATCH 07/19] fix: repair two defects the master merge introduced - anthropic/stream.rs: clippy 1.98's manual_unwrap_or fires on the wire-name match. Its suggested `unwrap_or` is not equivalent (an unrecognized Some(name) must collapse to code_execution, not pass through), so express the same rule with filter + unwrap_or. - kotlin Types.kt: the merge added providerMetadata to ToolCall twice, which kotlinx.serialization rejects as a duplicate serial name. --- aimux-providers/src/anthropic/stream.rs | 9 +++++---- .../kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt | 1 - 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index 6cfd4f03..604222d7 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -309,10 +309,11 @@ pub(crate) fn finalize_streamed_tool_input( if provider_tool_name == Some("code_execution") && let Ok(parsed) = serde_json::from_str::(&accumulated_json) { - let wire_name = match provider_tool_input_type { - Some(name @ ("text_editor_code_execution" | "bash_code_execution")) => name, - _ => "code_execution", - }; + // Only the two known operation names ride through; anything else + // collapses to the caller's single code_execution tool. + let wire_name = provider_tool_input_type + .filter(|name| matches!(*name, "text_editor_code_execution" | "bash_code_execution")) + .unwrap_or("code_execution"); accumulated_json = normalized_server_tool_input(wire_name, &parsed).to_string(); } accumulated_json diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt index 0b211bad..c933f344 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt @@ -169,7 +169,6 @@ data class ToolCall( @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, val invalid: Boolean? = null, val error: JsonElement? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, ) // ───────────────────────────────────────────────────────────────────────────── From 7231686e1018aa6a5cf4044f03315af05d6985cf Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Thu, 3 Sep 2026 23:29:15 +0800 Subject: [PATCH 08/19] fix(core): stop overloading Value::String for raw tool-call arguments parsed_tool_call_arguments() used Value::String plus the invalid flag as a carrier for the provider's raw argument text, but a legitimately parsed JSON string (e.g. "hello") and malformed text wrapped as a string fallback both serialize to the identical Value::String, so a valid quoted string got re-emitted unquoted (invalid JSON), and Value::Null was unconditionally rewritten to "{}" even for valid calls. AiMuxError::InvalidToolInput already carries tool_input: the byte-for-byte provider text, set from RawToolCall.input on both the JSON-parse-failure and schema-validation-failure paths. Use it directly for invalid calls instead of re-deriving anything from the parsed Value, and drop the Null special case so a valid call's null round-trips as null instead of {}. Threads the resolved AiMuxError into the four call sites (streaming StreamPart::ToolCall and the non-streaming generate_text_as_openai path) that build OpenAI-compatible tool_calls[].function.arguments. --- aimux-core/src/generate.rs | 1 + aimux-core/src/openai_output.rs | 174 ++++++++++++++++++++++++++++++-- 2 files changed, 164 insertions(+), 11 deletions(-) diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index dc17ce0f..af6660bf 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -1278,6 +1278,7 @@ pub async fn generate_text_as_openai( arguments: parsed_tool_call_arguments( &tool_call.input, tool_call.invalid, + tool_call.error.as_ref(), ), }, }) diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index e580d777..6035d6a5 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -733,6 +733,7 @@ impl StreamState { tool_name, input, invalid, + error, .. } => { // Complete tool call (e.g. from non-streaming-style providers). @@ -745,7 +746,7 @@ impl StreamState { } let index = if let Some(acc) = self.tool_calls.get_mut(tool_call_id) { acc.name.clone_from(tool_name); - acc.arguments = parsed_tool_call_arguments(input, *invalid); + acc.arguments = parsed_tool_call_arguments(input, *invalid, error.as_ref()); acc.index } else { let index = self.next_tool_index; @@ -756,7 +757,11 @@ impl StreamState { index, id: tool_call_id.clone(), name: tool_name.clone(), - arguments: parsed_tool_call_arguments(input, *invalid), + arguments: parsed_tool_call_arguments( + input, + *invalid, + error.as_ref(), + ), }, ); self.tool_call_order.push(tool_call_id.clone()); @@ -785,7 +790,8 @@ impl StreamState { }]; chunks.push(chunk); } else if self.tool_call_opened.contains(tool_call_id) { - let full_arguments = parsed_tool_call_arguments(input, *invalid); + let full_arguments = + parsed_tool_call_arguments(input, *invalid, error.as_ref()); let missing_arguments = self .tool_calls .get(tool_call_id) @@ -830,7 +836,7 @@ impl StreamState { index, id: tool_call_id.clone(), name: tool_name.clone(), - arguments: parsed_tool_call_arguments(input, *invalid), + arguments: parsed_tool_call_arguments(input, *invalid, error.as_ref()), }, ); self.tool_call_order.push(tool_call_id.clone()); @@ -1087,14 +1093,36 @@ fn now_unix() -> u64 { .unwrap_or(0) } -pub(crate) fn parsed_tool_call_arguments(input: &Value, invalid: Option) -> String { - match input { - // Core uses a string carrier to retain malformed input on invalid - // calls; emitting it verbatim avoids adding a second JSON layer. - Value::String(raw) if invalid == Some(true) => raw.clone(), - Value::Null => "{}".to_string(), - input => input.to_string(), +/// Render a Core-parsed `StreamPart::ToolCall`'s arguments as OpenAI-compatible +/// wire text: the provider's raw argument text verbatim for an invalid call, +/// compact JSON of the parsed value otherwise. +/// +/// `input: Value` alone cannot carry this distinction: a syntactically valid +/// JSON string like `"hello"` parses to `Value::String("hello")`, and so does +/// malformed text (e.g. bare `hello`) that Core falls back to wrapping +/// verbatim — both produce the identical `Value`, and `Value::Null` on a +/// *valid* call must stay `null`, not get rewritten to `{}`. `AiMuxError` +/// resolves the ambiguity: `InvalidToolInput` (the only invalid-call error +/// carrying argument text) always sets `tool_input` to `RawToolCall.input`, +/// the byte-for-byte provider text, whether the failure was a JSON parse +/// error or a schema mismatch on already-valid JSON — so it is used verbatim +/// instead of re-deriving anything from `input`. An invalid call without that +/// error shape (e.g. `NoSuchTool`, which never parsed the text at all) has no +/// recoverable raw text; compact-serializing `input` is the best available +/// fallback there. +pub(crate) fn parsed_tool_call_arguments( + input: &Value, + invalid: Option, + error: Option<&AiMuxError>, +) -> String { + if invalid == Some(true) + && let Some(AiMuxError::InvalidToolInput { tool_input, .. }) = error + { + return tool_input.clone(); } + // `Value` Display is compact JSON, the `JSON.stringify` equivalent — + // correct for every shape, `null` included. + input.to_string() } /// Generate a short random ID (24 hex chars, similar to OpenAI's chatcmpl IDs). @@ -1226,6 +1254,130 @@ mod tests { ); } + // `parsed_tool_call_arguments` renders the Core-parsed `StreamPart::ToolCall` + // surface for OpenAI-compatible output. Regression coverage for the P1 + // finding on PR #165: `Value::String` alone can't distinguish a validly + // parsed JSON string from malformed text wrapped as a fallback string, and + // `Value::Null` on a valid call must not be rewritten to `{}`. + + #[test] + fn parsed_tool_call_arguments_invalid_json_string_round_trips_with_quotes() { + // Raw text `"hello"` (a syntactically valid JSON string) fails a + // schema that expects an object. `input` is the parsed value + // (`Value::String("hello")`, quotes stripped by JSON parsing); the + // typed error carries the original raw text with quotes intact. + let error = AiMuxError::InvalidToolInput { + tool_name: "get_weather".to_string(), + tool_input: r#""hello""#.to_string(), + cause: "Type validation failed: Value: \"hello\".\nError message: ...".to_string(), + }; + let arguments = parsed_tool_call_arguments(&json!("hello"), Some(true), Some(&error)); + assert_eq!(arguments, r#""hello""#); + // The bug reproduced here: emitting `input` (the parsed string's + // content) verbatim would yield bare `hello`, which is not valid JSON. + assert_ne!(arguments, "hello"); + } + + #[test] + fn parsed_tool_call_arguments_invalid_malformed_json_round_trips_verbatim() { + // Raw text `{"a":` never parses as JSON at all; Core's fallback wraps + // it verbatim as `Value::String("{\"a\":")`. + let error = AiMuxError::InvalidToolInput { + tool_name: "get_weather".to_string(), + tool_input: r#"{"a":"#.to_string(), + cause: "JSON parsing failed: Text: {\"a\":.\nError message: ...".to_string(), + }; + let arguments = parsed_tool_call_arguments( + &Value::String(r#"{"a":"#.to_string()), + Some(true), + Some(&error), + ); + assert_eq!(arguments, r#"{"a":"#); + } + + #[test] + fn parsed_tool_call_arguments_null_is_not_rewritten_to_empty_object() { + // A valid call whose parsed input happens to be `null` (no schema + // constraint rejected it) must round-trip as `null`, not `{}`. + let arguments = parsed_tool_call_arguments(&Value::Null, None, None); + assert_eq!(arguments, "null"); + } + + #[test] + fn parsed_tool_call_arguments_valid_call_uses_compact_parsed_json() { + let arguments = parsed_tool_call_arguments(&json!({"city": "Tokyo"}), None, None); + assert_eq!(arguments, r#"{"city":"Tokyo"}"#); + } + + #[tokio::test] + async fn test_stream_invalid_tool_call_round_trips_raw_text_not_double_encoded() { + // End-to-end: `parse_tool_call` builds the invalid call the way Core + // actually does, then the OpenAI-compat stream conversion renders its + // arguments. Guards the whole pipeline, not just the unit above. + let tool = crate::tool::Tool::Function(crate::tool::FunctionTool::new( + "get_weather", + json!({"type": "object", "properties": {}, "additionalProperties": false}), + )); + let parsed = crate::parse_tool_call::parse_tool_call( + crate::parse_tool_call::RawToolCall { + tool_call_id: "call_1".to_string(), + tool_name: "get_weather".to_string(), + input: r#""hello""#.to_string(), + provider_executed: None, + dynamic: None, + thought_signature: None, + provider_metadata: None, + }, + Some(&[tool]), + None, + &[], + None, + ) + .await; + assert_eq!(parsed.invalid, Some(true)); + + let parts: Vec> = vec![ + Ok(StreamPart::ToolCall { + tool_call_id: parsed.tool_call_id, + tool_name: parsed.tool_name, + input: parsed.input, + provider_executed: parsed.provider_executed, + dynamic: parsed.dynamic, + thought_signature: parsed.thought_signature, + invalid: parsed.invalid, + error: parsed.error, + provider_metadata: parsed.provider_metadata, + }), + Ok(StreamPart::Finish { + finish_reason: FinishReason { + unified: FinishReasonUnified::ToolCalls, + raw: None, + }, + usage: Usage::default(), + provider_metadata: None, + }), + ]; + + let result = to_chat_completion_stream( + Box::pin(futures::stream::iter(parts)), + "gpt-4o", + OpenAiStreamOptions::default(), + ); + let chunks = collect_stream(result).await; + + let arguments = chunks + .iter() + .find_map(|c| { + c.choices + .first() + .and_then(|ch| ch.delta.tool_calls.as_ref()) + .and_then(|tcs| tcs.first()) + .and_then(|tc| tc.function.arguments.clone()) + }) + .expect("tool call arguments chunk not found"); + assert_eq!(arguments, r#""hello""#); + } + #[test] fn test_tool_call_null_content() { // Tool call with no text → content should be null. From c6da09c3e74d7cebbd6111fbce10690cadb57958 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Thu, 3 Sep 2026 23:34:05 +0800 Subject: [PATCH 09/19] fix(providers): stop Cohere streaming from parsing tool-call input itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cohere's tool-call-end handler parsed the accumulated arguments and treated a parse failure as a terminal stream Error, unlike every other streaming provider (which forwards the raw text unparsed) and unlike Cohere's own non-streaming path. A malformed streamed call never reached Core as a retained invalid: true tool call — it aborted the whole stream instead. Forward the trimmed accumulated text verbatim (empty still defaults to "{}" to match the TS provider), unparsed, and let Core's parse_tool_call own JSON parsing, schema validation, and repair, exactly as it already does for every other provider. Audited every other StreamPart::ToolCall construction site in aimux-providers (open_responses, xai, google, bedrock, anthropic, vertex, huggingface, mistral, openai) for the same anti-pattern; none of them parse provider-side. should_stream_tool_call_deltas no longer expects compact-reserialized arguments (interior whitespace from the deltas now survives, since nothing parses and re-serializes it), and a new test drives a malformed streamed call through both do_stream() directly (no Error, raw text forwarded) and stream_text() (Core keeps it as an invalid: true call with a typed InvalidToolInput error instead of erroring the stream). --- aimux-providers/src/cohere/model.rs | 49 ++++++------- aimux-providers/tests/cohere_model_test.rs | 82 +++++++++++++++++++++- 2 files changed, 101 insertions(+), 30 deletions(-) diff --git a/aimux-providers/src/cohere/model.rs b/aimux-providers/src/cohere/model.rs index 812cf661..dddedb19 100644 --- a/aimux-providers/src/cohere/model.rs +++ b/aimux-providers/src/cohere/model.rs @@ -486,35 +486,30 @@ impl LanguageModel for CohereModel { provider_metadata: None, }); - // TS trims the accumulated arguments, - // defaults empty to "{}", and re-serializes - // the parsed JSON compactly; a parse - // failure is terminal for the stream. + // Providers never parse tool input — Core + // owns JSON parsing, schema validation, + // and repair (aimux-core::parse_tool_call). + // Trim the accumulated text and default + // empty to "{}" to match the TS + // provider's convention; forward it + // verbatim otherwise, malformed JSON + // included, so a bad call surfaces as a + // retained `invalid: true` tool call + // instead of a terminal stream error. let trimmed = ptc.arguments.trim(); let text = if trimmed.is_empty() { "{}" } else { trimmed }; - match serde_json::from_str::(text) { - Ok(parsed_args) => { - // `Value` Display is compact JSON — the - // `JSON.stringify` equivalent, infallible. - let input = Value::String(parsed_args.to_string()); - yield Ok(StreamPart::ToolCall { - tool_call_id: ptc.id, - tool_name: ptc.name, - input, - provider_executed: None, - dynamic: None, - thought_signature: None, - invalid: None, - error: None, - provider_metadata: None, - }); - } - Err(e) => { - yield Ok(StreamPart::Error { error: e.into() }); - stream_errored = true; - break; - } - } + let input = Value::String(text.to_string()); + yield Ok(StreamPart::ToolCall { + tool_call_id: ptc.id, + tool_name: ptc.name, + input, + provider_executed: None, + dynamic: None, + thought_signature: None, + invalid: None, + error: None, + provider_metadata: None, + }); } } diff --git a/aimux-providers/tests/cohere_model_test.rs b/aimux-providers/tests/cohere_model_test.rs index 250fac63..c61f75f5 100644 --- a/aimux-providers/tests/cohere_model_test.rs +++ b/aimux-providers/tests/cohere_model_test.rs @@ -12,6 +12,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; +use aimux_core::generate::{GenerateTextOptions, stream_text}; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; @@ -551,11 +552,12 @@ async fn should_stream_tool_call_deltas() { let (id, name, input) = tool_call.expect("should have ToolCall"); assert_eq!(id, "weather_e8p4pn45zt0t"); assert_eq!(name, "weather"); - // The flush parses and re-serializes compactly, so interior whitespace - // from the deltas is dropped. + // Providers never parse tool input (Core owns that) — the flush forwards + // the accumulated deltas verbatim (only trimmed), so interior whitespace + // from the deltas survives. assert_eq!( input, - &Value::String(r#"{"location":"San Francisco"}"#.into()) + &Value::String(r#"{"location": "San Francisco"}"#.into()) ); // Verify the accumulated deltas. @@ -578,6 +580,80 @@ async fn should_stream_tool_call_deltas() { } } +/// Regression test for PR #165 review finding: Cohere used to parse the +/// accumulated tool-call arguments itself and treat a parse failure as a +/// terminal stream error, unlike every other provider (which forwards raw +/// text and lets Core own parsing/validation/repair) and unlike Cohere's own +/// non-streaming path. Malformed streamed arguments must surface as a +/// retained `invalid: true` tool call, not abort the stream. +#[tokio::test] +async fn malformed_streamed_tool_call_arguments_do_not_error_the_stream() { + let server = MockServer::start().await; + let sse = cohere_sse_body(&[ + r#"{"id":"malformed-1","type":"message-start","delta":{"message":{"role":"assistant","content":[],"tool_plan":"","tool_calls":[],"citations":[]}}}"#, + r#"{"type":"tool-call-start","index":0,"delta":{"message":{"tool_calls":{"id":"tc_malformed","type":"function","function":{"name":"test-tool","arguments":""}}}}}"#, + r#"{"type":"tool-call-delta","index":0,"delta":{"message":{"tool_calls":{"function":{"arguments":"{\"value\":"}}}}}"#, + r#"{"type":"tool-call-end","index":0}"#, + r#"{"type":"message-end","delta":{"finish_reason":"TOOL_CALL","usage":{"billed_units":{"input_tokens":10,"output_tokens":5},"tokens":{"input_tokens":10,"output_tokens":5}}}}"#, + ]); + mock_sse_response(&server, &sse).await; + + let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); + let provider = CohereProvider::new(config); + let model = provider.model("command-r-plus"); + + // First, confirm the raw provider stream itself never parses and never + // errors on the malformed arguments — it just forwards the text. + let raw_options = CallOptions { + tools: Some(vec![test_tool()]), + ..default_options(test_prompt()) + }; + let raw_parts = + collect_stream(model.do_stream(&raw_options).await.expect("should succeed")).await; + assert!( + !raw_parts + .iter() + .any(|part| matches!(part, StreamPart::Error { .. })), + "provider must not parse tool input itself: {raw_parts:?}" + ); + let raw_input = raw_parts.iter().find_map(|p| match p { + StreamPart::ToolCall { input, .. } => Some(input.clone()), + _ => None, + }); + assert_eq!( + raw_input, + Some(Value::String(r#"{"value":"#.to_string())), + "provider must forward the malformed text verbatim" + ); + + // Then, through Core's `stream_text` (parse/validate/repair boundary), + // the malformed call surfaces as a retained invalid tool call — the + // stream itself completes normally. + let result = stream_text( + &model, + "test", + GenerateTextOptions { + tools: Some(vec![test_tool()]), + ..GenerateTextOptions::default() + }, + ) + .await + .expect("stream_text should start") + .consume() + .await + .expect("Core parsing keeps an invalid call, not a stream error"); + + let call = result + .tool_calls + .first() + .expect("should have one tool call"); + assert_eq!(call.invalid, Some(true)); + assert!(matches!( + call.error, + Some(aimux_core::error::AiMuxError::InvalidToolInput { .. }) + )); +} + /// TS: "should stream reasoning deltas" #[tokio::test] async fn should_stream_reasoning_deltas() { From edcaa400146827e2fb60b278a3d0075fb873adde Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Thu, 3 Sep 2026 23:37:30 +0800 Subject: [PATCH 10/19] fix(core): accept the legacy object-shaped GenerateContent::ToolCall.input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GenerateContent::ToolCall.input changed from serde_json::Value to String (providers now hand Core raw text; Core owns parsing). The PR description called this a break only for invalid arguments, but it understated the blast radius: a GenerateResult persisted or replayed from before the refactor — back when tool input was already-parsed and object/array-shaped on this field — no longer deserializes at all, valid calls included. Add a deserialize_with that accepts both shapes: a JSON string loads unchanged (the current, and only, wire shape this version writes), and any other JSON value re-serializes to its compact JSON text. Serialization is untouched (always a plain string). Documents the wire shape and the compatibility behavior in docs/api/gaps.md §9. Regression tests cover both directions (a legacy object-shaped payload and the current string-shaped payload both load to the same value) and every legacy value shape (array/number/bool/null), plus that serialization never regresses to the legacy shape. --- aimux-core/src/result.rs | 114 +++++++++++++++++++++ bindings/node/src/types/GenerateContent.ts | 8 ++ docs/api/gaps.md | 40 ++++++++ 3 files changed, 162 insertions(+) diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index c2c0af4d..60ab5a40 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -14,6 +14,28 @@ use crate::types::{FinishReason, ProviderMetadata, ResponseMetadata, Usage, Warn use serde_json::Value; +/// Compatibility deserializer for `GenerateContent::ToolCall.input`. +/// +/// The field used to be a `serde_json::Value` (the already-parsed argument +/// object) and became a `String` (the provider's raw, unparsed text) in the +/// tool-input-parse-repair refactor. The wire format for a *new* result was +/// unaffected — providers had only ever put the raw text in a +/// `Value::String`, and `Value::String` / `String` serialize identically — +/// but a result persisted (recording/replay) before that refactor, back when +/// providers parsed their own input, can carry an object/array/number/bool/ +/// null here. Accept both: a JSON string passes through unchanged, and any +/// other JSON value is re-serialized to its compact JSON text so the field +/// keeps meaning "the raw text a schema-validating parse would run against". +fn deserialize_tool_call_input<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(match Value::deserialize(deserializer)? { + Value::String(raw) => raw, + legacy => legacy.to_string(), + }) +} + /// A content item in the generation result. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] @@ -31,6 +53,15 @@ pub enum GenerateContent { /// The model's raw argument text, exactly as the provider delivered /// it (possibly malformed). Providers never parse it — `generate_text` /// owns parsing, schema validation, and repair. + /// + /// Always serializes as a JSON string. Deserializes a JSON string + /// (the current wire shape) unchanged, and also accepts the + /// pre-refactor legacy shape — an already-parsed JSON value (object, + /// array, number, bool, or null) — by re-serializing it to its + /// compact JSON text, so `GenerateResult`s persisted or replayed from + /// before this field became a `String` keep loading. See + /// docs/api/gaps.md §9 for the wire-shape and migration note. + #[serde(deserialize_with = "deserialize_tool_call_input")] input: String, /// Whether the tool call will be executed by the provider. /// If false/unset, the tool call is executed by the client. @@ -222,3 +253,86 @@ impl std::fmt::Debug for StreamResult { .finish() } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression test for PR #165 review finding: `GenerateContent::ToolCall` + /// went from carrying `input: Value` to `input: String`. A *new* result's + /// wire shape is unaffected (providers only ever put raw text in a + /// `Value::String`, which serializes identically to a bare `String`), but + /// a result persisted before the refactor can carry an object — this must + /// still deserialize, not fail closed. + #[test] + fn tool_call_input_deserializes_both_the_legacy_object_shape_and_the_new_string_shape() { + let legacy = serde_json::json!({ + "ToolCall": { + "tool_call_id": "call_1", + "tool_name": "get_weather", + "input": { "city": "Tokyo" }, + } + }); + let from_legacy: GenerateContent = serde_json::from_value(legacy).unwrap(); + let GenerateContent::ToolCall { input, .. } = &from_legacy else { + panic!("expected ToolCall, got {from_legacy:?}"); + }; + assert_eq!(input, r#"{"city":"Tokyo"}"#); + + let current = serde_json::json!({ + "ToolCall": { + "tool_call_id": "call_1", + "tool_name": "get_weather", + "input": r#"{"city":"Tokyo"}"#, + } + }); + let from_current: GenerateContent = serde_json::from_value(current).unwrap(); + assert_eq!(from_current, from_legacy); + } + + /// A legacy array/number/bool/null-shaped `input` (any non-string JSON + /// value some historical provider integration may have written) also + /// loads, re-serialized to its compact JSON text. + #[test] + fn tool_call_input_deserializes_every_legacy_value_shape() { + for (legacy_input, expected) in [ + (serde_json::json!([1, 2, 3]), "[1,2,3]"), + (serde_json::json!(42), "42"), + (serde_json::json!(true), "true"), + (serde_json::json!(null), "null"), + ] { + let wire = serde_json::json!({ + "ToolCall": { + "tool_call_id": "call_1", + "tool_name": "get_weather", + "input": legacy_input, + } + }); + let parsed: GenerateContent = serde_json::from_value(wire).unwrap(); + let GenerateContent::ToolCall { input, .. } = &parsed else { + panic!("expected ToolCall, got {parsed:?}"); + }; + assert_eq!(input, expected); + } + } + + /// The field always serializes as a plain JSON string, never a nested + /// JSON value — the current, non-legacy wire shape. + #[test] + fn tool_call_input_serializes_as_a_string() { + let content = GenerateContent::ToolCall { + tool_call_id: "call_1".to_string(), + tool_name: "get_weather".to_string(), + input: r#"{"city":"Tokyo"}"#.to_string(), + provider_executed: None, + dynamic: None, + thought_signature: None, + provider_metadata: None, + }; + let wire = serde_json::to_value(&content).unwrap(); + assert_eq!( + wire["ToolCall"]["input"], + serde_json::json!(r#"{"city":"Tokyo"}"#) + ); + } +} diff --git a/bindings/node/src/types/GenerateContent.ts b/bindings/node/src/types/GenerateContent.ts index 78841445..31e55789 100644 --- a/bindings/node/src/types/GenerateContent.ts +++ b/bindings/node/src/types/GenerateContent.ts @@ -10,6 +10,14 @@ export type GenerateContent = { "Text": { text: string, provider_metadata?: Json * The model's raw argument text, exactly as the provider delivered * it (possibly malformed). Providers never parse it — `generate_text` * owns parsing, schema validation, and repair. + * + * Always serializes as a JSON string. Deserializes a JSON string + * (the current wire shape) unchanged, and also accepts the + * pre-refactor legacy shape — an already-parsed JSON value (object, + * array, number, bool, or null) — by re-serializing it to its + * compact JSON text, so `GenerateResult`s persisted or replayed from + * before this field became a `String` keep loading. See + * docs/api/gaps.md §9 for the wire-shape and migration note. */ input: string, /** diff --git a/docs/api/gaps.md b/docs/api/gaps.md index 1417b0a3..e4bec6eb 100644 --- a/docs/api/gaps.md +++ b/docs/api/gaps.md @@ -179,6 +179,46 @@ FFI 调用仅 8 个符号。[Types.swift](../../bindings/swift/Sources/Aimux/Typ --- +## 9. Wire-shape migration: `GenerateContent::ToolCall.input` + +The tool-input-parse-repair refactor (PR #165 and predecessors) moved JSON +parsing, schema validation, and repair from providers into Core +(`generate_text` / `stream_text`). Providers now hand Core the model's raw +argument text unparsed; `GenerateContent::ToolCall.input` — part of +`GenerateResult.content`, i.e. what `result.raw.content` carries for a +non-streaming call — changed type accordingly: + +| | Before | After | +|---|---|---| +| Rust type | `serde_json::Value` | `String` | +| What it held | The provider's raw text, but *always* wrapped in `Value::String(text)` — providers never put a structured value there either, by convention (not by the type system) | The same raw text, now typed as the wire carrier directly | +| Wire JSON | `"input": ""` (a JSON string, because `Value::String` and `String` serialize identically) | `"input": ""` (unchanged) | + +**For a `GenerateContent::ToolCall` produced by *this* version of aimux, the +wire format did not change** — `Value::String(text)` and `String` serialize to +the identical JSON string, so nothing downstream (recordings, replay +fixtures, cross-binding contract tests) written by this version or later +needs to move. + +**What did change**: before this refactor line existed, `input` briefly held +the *already-parsed* argument value (an object/array/etc., not a raw-text +string) on `main`, because parsing happened provider-side. A `GenerateResult` +recorded or persisted from that window carries an object-shaped `input`, +which the current `String`-typed field cannot deserialize as-is — deserializing +such a record with a plain `#[derive(Deserialize)]` `String` field fails +closed instead of loading. + +**Compatibility**: `GenerateContent::ToolCall.input` uses a custom +`deserialize_with` (`aimux-core/src/result.rs`) that accepts either shape — +a JSON string loads unchanged; any other JSON value (object, array, number, +bool, null) is re-serialized to its compact JSON text. Serialization always +emits a plain JSON string; there is no code path that writes the legacy +object shape going forward. Old recordings/replay fixtures and any +externally-persisted `GenerateResult` JSON keep loading without a migration +step. + +--- + ## 建议实施顺序 1. **C ABI + Go + Node/Python 小缺口**(半天):不涉及新架构,纯追加 From 6b5e6cf070346e7249dc79b4bc6e1b689671a0c4 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Thu, 3 Sep 2026 23:44:58 +0800 Subject: [PATCH 11/19] fix(core): recover raw tool-call text for NoSuchTool and failed-repair calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first tool-input-parse-repair fix on this branch only recovered the provider's verbatim raw text from AiMuxError::InvalidToolInput, missing two other invalid-call paths that hit the same Value::String ambiguity: - ToolCallRepair (a failed repair *callback*, not a re-validation failure) wraps the pre-repair error as `original_error` without being unwrapped, so parsed_tool_call_arguments fell through to re-deriving text from the ambiguous Value. - NoSuchTool carries no tool_input field at all — like the AI SDK, it fires off the tool name alone, before the arguments are looked at — so invalid_tool_call's best-effort `serde_json::from_str` still ran and a valid quoted string such as "hello" lost its quotes on the way back out. parse_tool_call::invalid_tool_call now skips the best-effort parse entirely for NoSuchTool (recursing through ToolCallRepair.original_error), storing the unparsed raw text in `input: Value::String` instead — the same contract InvalidToolInput's fallback already had for malformed text, just applied before any parse attempt. openai_output::parsed_tool_call_arguments gained a raw_tool_call_text helper that reads InvalidToolInput.tool_input, unwraps one level of ToolCallRepair, or reads the now-guaranteed-unparsed Value::String for NoSuchTool. AiMuxError's own shape and wire format are untouched — this only changes how ToolCall.input and the rendered OpenAI arguments text are derived from it. --- aimux-core/src/openai_output.rs | 228 ++++++++++++++++++++++++++++-- aimux-core/src/parse_tool_call.rs | 26 +++- 2 files changed, 241 insertions(+), 13 deletions(-) diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index 6035d6a5..d2fc8ae9 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -1093,6 +1093,29 @@ fn now_unix() -> u64 { .unwrap_or(0) } +/// Recover the provider's raw argument text from an invalid call's typed +/// error, when the error shape carries or implies it verbatim. +/// +/// `InvalidToolInput` always sets `tool_input` to `RawToolCall.input`, the +/// byte-for-byte provider text, whether the failure was a JSON parse error or +/// a schema mismatch on already-valid JSON. `NoSuchTool` carries no text of +/// its own — it fires purely off the tool name, before Core looks at the +/// arguments — but `parse_tool_call::invalid_tool_call` reflects that by +/// leaving `input` as the unparsed `Value::String(raw)` rather than a +/// best-effort parse, so it is recovered from there instead. `ToolCallRepair` +/// wraps whichever of those the repair callback was invoked over; unwrap one +/// level to reach it. +fn raw_tool_call_text(error: &AiMuxError, input: &Value) -> Option { + match error { + AiMuxError::InvalidToolInput { tool_input, .. } => Some(tool_input.clone()), + AiMuxError::NoSuchTool { .. } => input.as_str().map(str::to_string), + AiMuxError::ToolCallRepair { original_error, .. } => { + raw_tool_call_text(original_error, input) + } + _ => None, + } +} + /// Render a Core-parsed `StreamPart::ToolCall`'s arguments as OpenAI-compatible /// wire text: the provider's raw argument text verbatim for an invalid call, /// compact JSON of the parsed value otherwise. @@ -1101,24 +1124,21 @@ fn now_unix() -> u64 { /// JSON string like `"hello"` parses to `Value::String("hello")`, and so does /// malformed text (e.g. bare `hello`) that Core falls back to wrapping /// verbatim — both produce the identical `Value`, and `Value::Null` on a -/// *valid* call must stay `null`, not get rewritten to `{}`. `AiMuxError` -/// resolves the ambiguity: `InvalidToolInput` (the only invalid-call error -/// carrying argument text) always sets `tool_input` to `RawToolCall.input`, -/// the byte-for-byte provider text, whether the failure was a JSON parse -/// error or a schema mismatch on already-valid JSON — so it is used verbatim -/// instead of re-deriving anything from `input`. An invalid call without that -/// error shape (e.g. `NoSuchTool`, which never parsed the text at all) has no -/// recoverable raw text; compact-serializing `input` is the best available -/// fallback there. +/// *valid* call must stay `null`, not get rewritten to `{}`. See +/// `raw_tool_call_text` for how the ambiguity is resolved from the typed +/// error. A call without a recoverable raw text (which should not currently +/// happen — every invalid-call error variant is handled above) falls back to +/// compact-serializing `input`. pub(crate) fn parsed_tool_call_arguments( input: &Value, invalid: Option, error: Option<&AiMuxError>, ) -> String { if invalid == Some(true) - && let Some(AiMuxError::InvalidToolInput { tool_input, .. }) = error + && let Some(error) = error + && let Some(raw) = raw_tool_call_text(error, input) { - return tool_input.clone(); + return raw; } // `Value` Display is compact JSON, the `JSON.stringify` equivalent — // correct for every shape, `null` included. @@ -1309,6 +1329,47 @@ mod tests { assert_eq!(arguments, r#"{"city":"Tokyo"}"#); } + #[test] + fn parsed_tool_call_arguments_no_such_tool_round_trips_raw_text_with_quotes() { + // NoSuchTool fires off the tool name alone, before Core ever looks at + // the arguments — `parse_tool_call::invalid_tool_call` reflects that + // by leaving `input` unparsed (`Value::String(raw)`), quotes + // included, instead of a best-effort parse that strips them. + let error = AiMuxError::NoSuchTool { + tool_name: "get_weather".to_string(), + available_tools: Some(vec!["other_tool".to_string()]), + }; + let arguments = parsed_tool_call_arguments( + &Value::String(r#""hello""#.to_string()), + Some(true), + Some(&error), + ); + assert_eq!(arguments, r#""hello""#); + } + + #[test] + fn parsed_tool_call_arguments_failed_repair_unwraps_original_invalid_tool_input() { + // A failed repair *callback* (not a re-validation failure) wraps the + // pre-repair error as `ToolCallRepair.original_error` — unwrap one + // level to recover the raw text instead of falling through to a + // compact re-serialization of the ambiguous `Value`. + let original_error = AiMuxError::InvalidToolInput { + tool_name: "get_weather".to_string(), + tool_input: r#"{"a":"#.to_string(), + cause: "JSON parsing failed: ...".to_string(), + }; + let error = AiMuxError::ToolCallRepair { + original_error: Box::new(original_error), + cause: Box::new(AiMuxError::Other("repair callback failed".to_string())), + }; + let arguments = parsed_tool_call_arguments( + &Value::String(r#"{"a":"#.to_string()), + Some(true), + Some(&error), + ); + assert_eq!(arguments, r#"{"a":"#); + } + #[tokio::test] async fn test_stream_invalid_tool_call_round_trips_raw_text_not_double_encoded() { // End-to-end: `parse_tool_call` builds the invalid call the way Core @@ -1378,6 +1439,151 @@ mod tests { assert_eq!(arguments, r#""hello""#); } + #[tokio::test] + async fn test_stream_no_such_tool_round_trips_raw_text_with_quotes() { + // End-to-end: an unknown tool name never gets its arguments parsed at + // all (NoSuchTool fires off the name alone) — the raw text, quotes + // included, must still survive to the OpenAI-compat arguments field. + let known_tool = crate::tool::Tool::Function(crate::tool::FunctionTool::new( + "other_tool", + json!({"type": "object"}), + )); + let parsed = crate::parse_tool_call::parse_tool_call( + crate::parse_tool_call::RawToolCall { + tool_call_id: "call_1".to_string(), + tool_name: "get_weather".to_string(), + input: r#""hello""#.to_string(), + provider_executed: None, + dynamic: None, + thought_signature: None, + provider_metadata: None, + }, + Some(&[known_tool]), + None, + &[], + None, + ) + .await; + assert!(matches!(parsed.error, Some(AiMuxError::NoSuchTool { .. }))); + + let parts: Vec> = vec![ + Ok(StreamPart::ToolCall { + tool_call_id: parsed.tool_call_id, + tool_name: parsed.tool_name, + input: parsed.input, + provider_executed: parsed.provider_executed, + dynamic: parsed.dynamic, + thought_signature: parsed.thought_signature, + invalid: parsed.invalid, + error: parsed.error, + provider_metadata: parsed.provider_metadata, + }), + Ok(StreamPart::Finish { + finish_reason: FinishReason { + unified: FinishReasonUnified::ToolCalls, + raw: None, + }, + usage: Usage::default(), + provider_metadata: None, + }), + ]; + + let result = to_chat_completion_stream( + Box::pin(futures::stream::iter(parts)), + "gpt-4o", + OpenAiStreamOptions::default(), + ); + let chunks = collect_stream(result).await; + + let arguments = chunks + .iter() + .find_map(|c| { + c.choices + .first() + .and_then(|ch| ch.delta.tool_calls.as_ref()) + .and_then(|tcs| tcs.first()) + .and_then(|tc| tc.function.arguments.clone()) + }) + .expect("tool call arguments chunk not found"); + assert_eq!(arguments, r#""hello""#); + } + + #[tokio::test] + async fn test_stream_failed_repair_round_trips_malformed_json_verbatim() { + // End-to-end: the repair *callback* itself failing wraps the + // pre-repair InvalidToolInput as ToolCallRepair.original_error — the + // malformed raw text must still round-trip verbatim, not + // double-encoded or dropped. + let tool = crate::tool::Tool::Function(crate::tool::FunctionTool::new( + "get_weather", + json!({"type": "object", "properties": {}, "additionalProperties": false}), + )); + let repair = crate::parse_tool_call::ToolCallRepair::new(|_context| async { + Err(AiMuxError::Other("repair model failed".to_string())) + }); + let parsed = crate::parse_tool_call::parse_tool_call( + crate::parse_tool_call::RawToolCall { + tool_call_id: "call_1".to_string(), + tool_name: "get_weather".to_string(), + input: r#"{"a":"#.to_string(), + provider_executed: None, + dynamic: None, + thought_signature: None, + provider_metadata: None, + }, + Some(&[tool]), + Some(&repair), + &[], + None, + ) + .await; + assert!(matches!( + parsed.error, + Some(AiMuxError::ToolCallRepair { .. }) + )); + + let parts: Vec> = vec![ + Ok(StreamPart::ToolCall { + tool_call_id: parsed.tool_call_id, + tool_name: parsed.tool_name, + input: parsed.input, + provider_executed: parsed.provider_executed, + dynamic: parsed.dynamic, + thought_signature: parsed.thought_signature, + invalid: parsed.invalid, + error: parsed.error, + provider_metadata: parsed.provider_metadata, + }), + Ok(StreamPart::Finish { + finish_reason: FinishReason { + unified: FinishReasonUnified::ToolCalls, + raw: None, + }, + usage: Usage::default(), + provider_metadata: None, + }), + ]; + + let result = to_chat_completion_stream( + Box::pin(futures::stream::iter(parts)), + "gpt-4o", + OpenAiStreamOptions::default(), + ); + let chunks = collect_stream(result).await; + + let arguments = chunks + .iter() + .find_map(|c| { + c.choices + .first() + .and_then(|ch| ch.delta.tool_calls.as_ref()) + .and_then(|tcs| tcs.first()) + .and_then(|tc| tc.function.arguments.clone()) + }) + .expect("tool call arguments chunk not found"); + assert_eq!(arguments, r#"{"a":"#); + } + #[test] fn test_tool_call_null_content() { // Tool call with no text → content should be null. diff --git a/aimux-core/src/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs index fb4bed7a..08edfbd7 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -289,9 +289,31 @@ fn valid_tool_call(tool_call: RawToolCall, input: Value, dynamic: Option) } } +/// Whether `error` means the raw text was never even attempted to parse. +/// +/// Currently only `NoSuchTool`: like the AI SDK, aimux raises it purely from +/// the tool name, before looking at the arguments at all — so unlike +/// `InvalidToolInput` (a parse or schema failure), there is no "best effort" +/// parsed value to fall back to. Recurses through `ToolCallRepair` so a +/// `NoSuchTool` that survives a failed repair attempt is still recognized. +fn input_was_never_parsed(error: &AiMuxError) -> bool { + match error { + AiMuxError::NoSuchTool { .. } => true, + AiMuxError::ToolCallRepair { original_error, .. } => input_was_never_parsed(original_error), + _ => false, + } +} + fn invalid_tool_call(tool_call: RawToolCall, error: AiMuxError) -> ToolCall { - let input = serde_json::from_str(&tool_call.input) - .unwrap_or_else(|_| Value::String(tool_call.input.clone())); + let input = if input_was_never_parsed(&error) { + // Keep the raw text verbatim and unparsed — a valid-JSON-but-quoted + // call (`"hello"`) must not lose its quotes to a "helpful" parse that + // was never actually attempted for this error. + Value::String(tool_call.input.clone()) + } else { + serde_json::from_str(&tool_call.input) + .unwrap_or_else(|_| Value::String(tool_call.input.clone())) + }; ToolCall { tool_call_id: tool_call.tool_call_id, tool_name: tool_call.tool_name, From f22be33b5b45715d7640418763eb2a10e268cd17 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Fri, 4 Sep 2026 00:18:31 +0800 Subject: [PATCH 12/19] fix(core): parse an unknown tool's arguments back into the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit stopped invalid_tool_call from parsing the raw text for NoSuchTool, to keep a bare JSON string (`"hello"`) from losing its quotes on the OpenAI-compat arguments wire. That traded a rare cosmetic glitch for a common data loss: response_messages only carries a structured input into the next turn's transcript (matching to-response-messages.ts, which replaces a non-object invalid input with `{}`), so a model calling a misspelled tool with perfectly good `{"city":"Tokyo"}` arguments replayed as `{}` — the arguments were dropped. Restore the AI SDK's unconditional best-effort parse (parse-tool-call.ts catch-all: parsed value when the text is valid JSON, verbatim text otherwise) for every invalid-call error, NoSuchTool included, and drop input_was_never_parsed. raw_tool_call_text keeps its NoSuchTool arm: a string left on `input` is either malformed text kept verbatim or a genuine JSON string, indistinguishable without a raw-text field on the error, and only the malformed case occurs in practice — now documented as such. --- aimux-core/src/openai_output.rs | 36 ++++++++++++++--------------- aimux-core/src/parse_tool_call.rs | 32 +++++++------------------ aimux-core/tests/tool_input_test.rs | 6 ++++- 3 files changed, 31 insertions(+), 43 deletions(-) diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index d2fc8ae9..e1021989 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -1100,11 +1100,12 @@ fn now_unix() -> u64 { /// byte-for-byte provider text, whether the failure was a JSON parse error or /// a schema mismatch on already-valid JSON. `NoSuchTool` carries no text of /// its own — it fires purely off the tool name, before Core looks at the -/// arguments — but `parse_tool_call::invalid_tool_call` reflects that by -/// leaving `input` as the unparsed `Value::String(raw)` rather than a -/// best-effort parse, so it is recovered from there instead. `ToolCallRepair` -/// wraps whichever of those the repair callback was invoked over; unwrap one -/// level to reach it. +/// arguments — so it falls back to `input`, which holds the best-effort parse +/// of that text: a string there is either malformed text kept verbatim (emit +/// it as-is) or a genuine JSON string (whose quotes are then lost). The two +/// are indistinguishable without a raw-text field on the error, and the +/// malformed case is the one that actually occurs. `ToolCallRepair` wraps +/// whichever of those the repair callback was invoked over; unwrap to reach it. fn raw_tool_call_text(error: &AiMuxError, input: &Value) -> Option { match error { AiMuxError::InvalidToolInput { tool_input, .. } => Some(tool_input.clone()), @@ -1330,21 +1331,20 @@ mod tests { } #[test] - fn parsed_tool_call_arguments_no_such_tool_round_trips_raw_text_with_quotes() { - // NoSuchTool fires off the tool name alone, before Core ever looks at - // the arguments — `parse_tool_call::invalid_tool_call` reflects that - // by leaving `input` unparsed (`Value::String(raw)`), quotes - // included, instead of a best-effort parse that strips them. + fn parsed_tool_call_arguments_no_such_tool_round_trips_malformed_text_verbatim() { + // NoSuchTool carries no raw text of its own (it fires off the tool + // name alone), so the malformed text is recovered from `input`, where + // the best-effort parse left it wrapped verbatim. let error = AiMuxError::NoSuchTool { tool_name: "get_weather".to_string(), available_tools: Some(vec!["other_tool".to_string()]), }; let arguments = parsed_tool_call_arguments( - &Value::String(r#""hello""#.to_string()), + &Value::String(r#"{"a":"#.to_string()), Some(true), Some(&error), ); - assert_eq!(arguments, r#""hello""#); + assert_eq!(arguments, r#"{"a":"#); } #[test] @@ -1440,10 +1440,10 @@ mod tests { } #[tokio::test] - async fn test_stream_no_such_tool_round_trips_raw_text_with_quotes() { - // End-to-end: an unknown tool name never gets its arguments parsed at - // all (NoSuchTool fires off the name alone) — the raw text, quotes - // included, must still survive to the OpenAI-compat arguments field. + async fn test_stream_no_such_tool_round_trips_malformed_text_verbatim() { + // End-to-end: an unknown tool name whose arguments are also malformed + // must still reach the OpenAI-compat arguments field verbatim, not + // double-encoded. let known_tool = crate::tool::Tool::Function(crate::tool::FunctionTool::new( "other_tool", json!({"type": "object"}), @@ -1452,7 +1452,7 @@ mod tests { crate::parse_tool_call::RawToolCall { tool_call_id: "call_1".to_string(), tool_name: "get_weather".to_string(), - input: r#""hello""#.to_string(), + input: r#"{"a":"#.to_string(), provider_executed: None, dynamic: None, thought_signature: None, @@ -1505,7 +1505,7 @@ mod tests { .and_then(|tc| tc.function.arguments.clone()) }) .expect("tool call arguments chunk not found"); - assert_eq!(arguments, r#""hello""#); + assert_eq!(arguments, r#"{"a":"#); } #[tokio::test] diff --git a/aimux-core/src/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs index 08edfbd7..7b2148ab 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -289,31 +289,15 @@ fn valid_tool_call(tool_call: RawToolCall, input: Value, dynamic: Option) } } -/// Whether `error` means the raw text was never even attempted to parse. -/// -/// Currently only `NoSuchTool`: like the AI SDK, aimux raises it purely from -/// the tool name, before looking at the arguments at all — so unlike -/// `InvalidToolInput` (a parse or schema failure), there is no "best effort" -/// parsed value to fall back to. Recurses through `ToolCallRepair` so a -/// `NoSuchTool` that survives a failed repair attempt is still recognized. -fn input_was_never_parsed(error: &AiMuxError) -> bool { - match error { - AiMuxError::NoSuchTool { .. } => true, - AiMuxError::ToolCallRepair { original_error, .. } => input_was_never_parsed(original_error), - _ => false, - } -} - fn invalid_tool_call(tool_call: RawToolCall, error: AiMuxError) -> ToolCall { - let input = if input_was_never_parsed(&error) { - // Keep the raw text verbatim and unparsed — a valid-JSON-but-quoted - // call (`"hello"`) must not lose its quotes to a "helpful" parse that - // was never actually attempted for this error. - Value::String(tool_call.input.clone()) - } else { - serde_json::from_str(&tool_call.input) - .unwrap_or_else(|_| Value::String(tool_call.input.clone())) - }; + // Best effort, for every failure including `NoSuchTool`, exactly as the + // AI SDK's `parseToolCall` catch-all does: the parsed value when the text + // is valid JSON, the verbatim text otherwise. Callers replaying an + // invalid call rely on this — `response_messages` only carries a + // structured input into the next turn's transcript, so an unknown tool + // called with perfectly good arguments must still parse here. + let input = serde_json::from_str(&tool_call.input) + .unwrap_or_else(|_| Value::String(tool_call.input.clone())); ToolCall { tool_call_id: tool_call.tool_call_id, tool_name: tool_call.tool_name, diff --git a/aimux-core/tests/tool_input_test.rs b/aimux-core/tests/tool_input_test.rs index 3ec019f3..a0f67aae 100644 --- a/aimux-core/tests/tool_input_test.rs +++ b/aimux-core/tests/tool_input_test.rs @@ -128,7 +128,7 @@ async fn preserves_parsed_input_when_schema_validation_fails() { #[tokio::test] async fn unknown_tool_is_an_invalid_dynamic_call_with_available_tools() { let call = parse_tool_call( - raw("forecast", "{}"), + raw("forecast", r#"{"city":"Tokyo"}"#), Some(&[weather_tool()]), None, &[], @@ -136,6 +136,10 @@ async fn unknown_tool_is_an_invalid_dynamic_call_with_available_tools() { ) .await; + // The arguments still parse: `response_messages` drops a non-structured + // input from the next turn's transcript, so an unknown tool called with + // perfectly good arguments must not arrive here as raw text. + assert_eq!(call.input, json!({ "city": "Tokyo" })); assert_eq!(call.dynamic, Some(true)); assert_eq!(call.invalid, Some(true)); assert!(matches!( From 41591d3338fca004ae8b300be55d1ec95cff6f64 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Fri, 4 Sep 2026 00:19:12 +0800 Subject: [PATCH 13/19] fix(core): render blank invalid tool arguments as an empty object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blank argument text parses as `{}` and can still fail schema validation against required properties. On that path parsed_tool_call_arguments returned the recovered raw text verbatim — the empty string — which is not valid JSON in an OpenAI `tool_calls[].function.arguments` field. to_chat_completion already normalized empty text to `{}` for the unparsed non-streaming path; apply the same rule to the parsed renderer, and make to_chat_completion's check whitespace-tolerant so the two agree. --- aimux-core/src/openai_output.rs | 45 +++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index e1021989..483b4c7b 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -248,7 +248,7 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio // The provider's raw argument text passes through verbatim; // OpenAI's wire format requires a JSON object even when the // model emitted no arguments at all. - let arguments = if input.is_empty() { + let arguments = if input.trim().is_empty() { "{}".to_string() } else { input.clone() @@ -1139,7 +1139,15 @@ pub(crate) fn parsed_tool_call_arguments( && let Some(error) = error && let Some(raw) = raw_tool_call_text(error, input) { - return raw; + // Blank text still fails validation against a schema with required + // properties, but OpenAI's wire format has no representation for + // "no arguments" other than an empty object — same rule as + // `to_chat_completion` applies to the unparsed non-streaming path. + return if raw.trim().is_empty() { + "{}".to_string() + } else { + raw + }; } // `Value` Display is compact JSON, the `JSON.stringify` equivalent — // correct for every shape, `null` included. @@ -1316,6 +1324,39 @@ mod tests { assert_eq!(arguments, r#"{"a":"#); } + #[test] + fn blank_invalid_tool_arguments_render_as_an_empty_object() { + // Both OpenAI renderers: blank raw text has no wire representation + // other than `{}`. + let error = AiMuxError::InvalidToolInput { + tool_name: "get_weather".to_string(), + tool_input: String::new(), + cause: "Type validation failed: missing 'city'".to_string(), + }; + let arguments = parsed_tool_call_arguments(&json!({}), Some(true), Some(&error)); + assert_eq!(arguments, "{}"); + + let result = make_result(vec![GenerateContent::ToolCall { + tool_call_id: "call_blank".to_string(), + tool_name: "get_weather".to_string(), + input: " ".to_string(), + provider_executed: None, + dynamic: None, + thought_signature: None, + provider_metadata: None, + }]); + assert_eq!( + to_chat_completion(&result, "gpt-4o").choices[0] + .message + .tool_calls + .as_ref() + .unwrap()[0] + .function + .arguments, + "{}" + ); + } + #[test] fn parsed_tool_call_arguments_null_is_not_rewritten_to_empty_object() { // A valid call whose parsed input happens to be `null` (no schema From 1cad1181dab0c684b1d94a43a4fb529a1a5e98df Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Fri, 4 Sep 2026 00:19:58 +0800 Subject: [PATCH 14/19] docs(api): correct and trim the tool-call input wire-shape note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gaps.md §9 and the deserializer's own doc comment both claimed the wire format was unchanged for results written by this version, because providers had "only ever put the raw text in a Value::String". Neither is true: on master the providers parsed their own arguments into that field (e.g. openai/model.rs ran serde_json::from_str, google/model.rs stored the response object directly), so `input` really did go from `{"city":"Paris"}` to `"{\"city\":\"Paris\"}"` — as this PR's own contract-tests/fixtures/wire-format.json change shows. State the before/after wire shape, that deserialization accepts both, and that the parsed value still reaches callers on GenerateTextResult.tool_calls[].input; drop the rest. --- aimux-core/src/result.rs | 28 +++++------- bindings/node/src/types/GenerateContent.ts | 10 ++--- docs/api/gaps.md | 51 ++++++++-------------- 3 files changed, 35 insertions(+), 54 deletions(-) diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index 60ab5a40..67b6983a 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -16,16 +16,14 @@ use serde_json::Value; /// Compatibility deserializer for `GenerateContent::ToolCall.input`. /// -/// The field used to be a `serde_json::Value` (the already-parsed argument -/// object) and became a `String` (the provider's raw, unparsed text) in the -/// tool-input-parse-repair refactor. The wire format for a *new* result was -/// unaffected — providers had only ever put the raw text in a -/// `Value::String`, and `Value::String` / `String` serialize identically — -/// but a result persisted (recording/replay) before that refactor, back when -/// providers parsed their own input, can carry an object/array/number/bool/ -/// null here. Accept both: a JSON string passes through unchanged, and any -/// other JSON value is re-serialized to its compact JSON text so the field -/// keeps meaning "the raw text a schema-validating parse would run against". +/// The field used to be a `serde_json::Value` (the already-parsed arguments, +/// since providers parsed their own input) and became a `String` (the raw, +/// unparsed provider text) in the tool-input-parse-repair refactor, so a +/// `GenerateResult` persisted or recorded before it carries an +/// object/array/number/bool/null here. Accept both: a JSON string passes +/// through unchanged, and any other JSON value is re-serialized to its +/// compact JSON text so the field keeps meaning "the raw text a +/// schema-validating parse would run against". fn deserialize_tool_call_input<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -55,12 +53,10 @@ pub enum GenerateContent { /// owns parsing, schema validation, and repair. /// /// Always serializes as a JSON string. Deserializes a JSON string - /// (the current wire shape) unchanged, and also accepts the - /// pre-refactor legacy shape — an already-parsed JSON value (object, - /// array, number, bool, or null) — by re-serializing it to its - /// compact JSON text, so `GenerateResult`s persisted or replayed from - /// before this field became a `String` keep loading. See - /// docs/api/gaps.md §9 for the wire-shape and migration note. + /// (the current wire shape) unchanged, and also re-serializes the + /// pre-refactor shape — an already-parsed JSON value — to its compact + /// JSON text, so a `GenerateResult` persisted before this field + /// became a `String` keeps loading. See docs/api/gaps.md §9. #[serde(deserialize_with = "deserialize_tool_call_input")] input: String, /// Whether the tool call will be executed by the provider. diff --git a/bindings/node/src/types/GenerateContent.ts b/bindings/node/src/types/GenerateContent.ts index 31e55789..f6da7630 100644 --- a/bindings/node/src/types/GenerateContent.ts +++ b/bindings/node/src/types/GenerateContent.ts @@ -12,12 +12,10 @@ export type GenerateContent = { "Text": { text: string, provider_metadata?: Json * owns parsing, schema validation, and repair. * * Always serializes as a JSON string. Deserializes a JSON string - * (the current wire shape) unchanged, and also accepts the - * pre-refactor legacy shape — an already-parsed JSON value (object, - * array, number, bool, or null) — by re-serializing it to its - * compact JSON text, so `GenerateResult`s persisted or replayed from - * before this field became a `String` keep loading. See - * docs/api/gaps.md §9 for the wire-shape and migration note. + * (the current wire shape) unchanged, and also re-serializes the + * pre-refactor shape — an already-parsed JSON value — to its compact + * JSON text, so a `GenerateResult` persisted before this field + * became a `String` keeps loading. See docs/api/gaps.md §9. */ input: string, /** diff --git a/docs/api/gaps.md b/docs/api/gaps.md index e4bec6eb..c532730b 100644 --- a/docs/api/gaps.md +++ b/docs/api/gaps.md @@ -181,41 +181,28 @@ FFI 调用仅 8 个符号。[Types.swift](../../bindings/swift/Sources/Aimux/Typ ## 9. Wire-shape migration: `GenerateContent::ToolCall.input` -The tool-input-parse-repair refactor (PR #165 and predecessors) moved JSON -parsing, schema validation, and repair from providers into Core -(`generate_text` / `stream_text`). Providers now hand Core the model's raw -argument text unparsed; `GenerateContent::ToolCall.input` — part of -`GenerateResult.content`, i.e. what `result.raw.content` carries for a -non-streaming call — changed type accordingly: +The tool-input-parse-repair refactor (PR #165) moved JSON parsing, schema +validation, and repair from the providers into Core. Providers now hand Core +the model's raw argument text unparsed, and +`GenerateContent::ToolCall.input` — the per-provider content on +`GenerateResult` (`result.raw.content`) — changed from `serde_json::Value` +(the already-parsed arguments) to `String` (that raw text). + +The wire shape changed with it: | | Before | After | |---|---|---| -| Rust type | `serde_json::Value` | `String` | -| What it held | The provider's raw text, but *always* wrapped in `Value::String(text)` — providers never put a structured value there either, by convention (not by the type system) | The same raw text, now typed as the wire carrier directly | -| Wire JSON | `"input": ""` (a JSON string, because `Value::String` and `String` serialize identically) | `"input": ""` (unchanged) | - -**For a `GenerateContent::ToolCall` produced by *this* version of aimux, the -wire format did not change** — `Value::String(text)` and `String` serialize to -the identical JSON string, so nothing downstream (recordings, replay -fixtures, cross-binding contract tests) written by this version or later -needs to move. - -**What did change**: before this refactor line existed, `input` briefly held -the *already-parsed* argument value (an object/array/etc., not a raw-text -string) on `main`, because parsing happened provider-side. A `GenerateResult` -recorded or persisted from that window carries an object-shaped `input`, -which the current `String`-typed field cannot deserialize as-is — deserializing -such a record with a plain `#[derive(Deserialize)]` `String` field fails -closed instead of loading. - -**Compatibility**: `GenerateContent::ToolCall.input` uses a custom -`deserialize_with` (`aimux-core/src/result.rs`) that accepts either shape — -a JSON string loads unchanged; any other JSON value (object, array, number, -bool, null) is re-serialized to its compact JSON text. Serialization always -emits a plain JSON string; there is no code path that writes the legacy -object shape going forward. Old recordings/replay fixtures and any -externally-persisted `GenerateResult` JSON keep loading without a migration -step. +| Wire JSON | `"input": {"city":"Paris"}` | `"input": "{\"city\":\"Paris\"}"` | + +Serialization always writes the JSON string; nothing writes the old shape any +more. Deserialization accepts both — a JSON string loads unchanged, and any +other JSON value (object, array, number, bool, null) is re-serialized to its +compact JSON text (`deserialize_tool_call_input` in +`aimux-core/src/result.rs`). A `GenerateResult` persisted or recorded before +the refactor therefore keeps loading, with no migration step. + +The parsed arguments still reach callers as a `Value`, on +`GenerateTextResult.tool_calls[].input` — that field is unchanged. --- From 65b3eb7891477bf42ae746988ed2504bdfba4ba3 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Fri, 4 Sep 2026 00:21:24 +0800 Subject: [PATCH 15/19] test(core): collapse the invalid-tool-argument renderer tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six parsed_tool_call_arguments unit tests and three near-identical end-to-end stream tests covered one contract with different data: the raw argument text of an invalid call must reach an OpenAI-compatible client verbatim. Fold them into one table-driven end-to-end test over the three distinct error shapes (schema-rejected JSON string, NoSuchTool, failed repair callback), which also pins the error/`input` pairing parse_tool_call produces — something the hand-built unit tests could not. Keeps the two tests pinning separate contracts (a valid `null` input is not rewritten to `{}`; blank text is). Behaviour unchanged: 286 deleted, 66 added. --- aimux-core/src/openai_output.rs | 352 ++++++-------------------------- 1 file changed, 66 insertions(+), 286 deletions(-) diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index 483b4c7b..0b430deb 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -1283,47 +1283,6 @@ mod tests { ); } - // `parsed_tool_call_arguments` renders the Core-parsed `StreamPart::ToolCall` - // surface for OpenAI-compatible output. Regression coverage for the P1 - // finding on PR #165: `Value::String` alone can't distinguish a validly - // parsed JSON string from malformed text wrapped as a fallback string, and - // `Value::Null` on a valid call must not be rewritten to `{}`. - - #[test] - fn parsed_tool_call_arguments_invalid_json_string_round_trips_with_quotes() { - // Raw text `"hello"` (a syntactically valid JSON string) fails a - // schema that expects an object. `input` is the parsed value - // (`Value::String("hello")`, quotes stripped by JSON parsing); the - // typed error carries the original raw text with quotes intact. - let error = AiMuxError::InvalidToolInput { - tool_name: "get_weather".to_string(), - tool_input: r#""hello""#.to_string(), - cause: "Type validation failed: Value: \"hello\".\nError message: ...".to_string(), - }; - let arguments = parsed_tool_call_arguments(&json!("hello"), Some(true), Some(&error)); - assert_eq!(arguments, r#""hello""#); - // The bug reproduced here: emitting `input` (the parsed string's - // content) verbatim would yield bare `hello`, which is not valid JSON. - assert_ne!(arguments, "hello"); - } - - #[test] - fn parsed_tool_call_arguments_invalid_malformed_json_round_trips_verbatim() { - // Raw text `{"a":` never parses as JSON at all; Core's fallback wraps - // it verbatim as `Value::String("{\"a\":")`. - let error = AiMuxError::InvalidToolInput { - tool_name: "get_weather".to_string(), - tool_input: r#"{"a":"#.to_string(), - cause: "JSON parsing failed: Text: {\"a\":.\nError message: ...".to_string(), - }; - let arguments = parsed_tool_call_arguments( - &Value::String(r#"{"a":"#.to_string()), - Some(true), - Some(&error), - ); - assert_eq!(arguments, r#"{"a":"#); - } - #[test] fn blank_invalid_tool_arguments_render_as_an_empty_object() { // Both OpenAI renderers: blank raw text has no wire representation @@ -1365,226 +1324,52 @@ mod tests { assert_eq!(arguments, "null"); } - #[test] - fn parsed_tool_call_arguments_valid_call_uses_compact_parsed_json() { - let arguments = parsed_tool_call_arguments(&json!({"city": "Tokyo"}), None, None); - assert_eq!(arguments, r#"{"city":"Tokyo"}"#); - } - - #[test] - fn parsed_tool_call_arguments_no_such_tool_round_trips_malformed_text_verbatim() { - // NoSuchTool carries no raw text of its own (it fires off the tool - // name alone), so the malformed text is recovered from `input`, where - // the best-effort parse left it wrapped verbatim. - let error = AiMuxError::NoSuchTool { - tool_name: "get_weather".to_string(), - available_tools: Some(vec!["other_tool".to_string()]), - }; - let arguments = parsed_tool_call_arguments( - &Value::String(r#"{"a":"#.to_string()), - Some(true), - Some(&error), - ); - assert_eq!(arguments, r#"{"a":"#); - } - - #[test] - fn parsed_tool_call_arguments_failed_repair_unwraps_original_invalid_tool_input() { - // A failed repair *callback* (not a re-validation failure) wraps the - // pre-repair error as `ToolCallRepair.original_error` — unwrap one - // level to recover the raw text instead of falling through to a - // compact re-serialization of the ambiguous `Value`. - let original_error = AiMuxError::InvalidToolInput { - tool_name: "get_weather".to_string(), - tool_input: r#"{"a":"#.to_string(), - cause: "JSON parsing failed: ...".to_string(), - }; - let error = AiMuxError::ToolCallRepair { - original_error: Box::new(original_error), - cause: Box::new(AiMuxError::Other("repair callback failed".to_string())), - }; - let arguments = parsed_tool_call_arguments( - &Value::String(r#"{"a":"#.to_string()), - Some(true), - Some(&error), - ); - assert_eq!(arguments, r#"{"a":"#); - } - - #[tokio::test] - async fn test_stream_invalid_tool_call_round_trips_raw_text_not_double_encoded() { - // End-to-end: `parse_tool_call` builds the invalid call the way Core - // actually does, then the OpenAI-compat stream conversion renders its - // arguments. Guards the whole pipeline, not just the unit above. - let tool = crate::tool::Tool::Function(crate::tool::FunctionTool::new( - "get_weather", - json!({"type": "object", "properties": {}, "additionalProperties": false}), - )); - let parsed = crate::parse_tool_call::parse_tool_call( - crate::parse_tool_call::RawToolCall { - tool_call_id: "call_1".to_string(), - tool_name: "get_weather".to_string(), - input: r#""hello""#.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - }, - Some(&[tool]), - None, - &[], - None, - ) - .await; - assert_eq!(parsed.invalid, Some(true)); - - let parts: Vec> = vec![ - Ok(StreamPart::ToolCall { - tool_call_id: parsed.tool_call_id, - tool_name: parsed.tool_name, - input: parsed.input, - provider_executed: parsed.provider_executed, - dynamic: parsed.dynamic, - thought_signature: parsed.thought_signature, - invalid: parsed.invalid, - error: parsed.error, - provider_metadata: parsed.provider_metadata, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let result = to_chat_completion_stream( - Box::pin(futures::stream::iter(parts)), - "gpt-4o", - OpenAiStreamOptions::default(), - ); - let chunks = collect_stream(result).await; - - let arguments = chunks - .iter() - .find_map(|c| { - c.choices - .first() - .and_then(|ch| ch.delta.tool_calls.as_ref()) - .and_then(|tcs| tcs.first()) - .and_then(|tc| tc.function.arguments.clone()) - }) - .expect("tool call arguments chunk not found"); - assert_eq!(arguments, r#""hello""#); - } - + /// Regression coverage for the P1 finding on PR #165: `input: Value` + /// alone cannot distinguish a validly parsed JSON string from malformed + /// text wrapped as a fallback string, so an invalid call's arguments are + /// recovered from its typed error instead. Driven end to end so the + /// error/`input` pairing `parse_tool_call` actually produces is pinned + /// too, not just the renderer. #[tokio::test] - async fn test_stream_no_such_tool_round_trips_malformed_text_verbatim() { - // End-to-end: an unknown tool name whose arguments are also malformed - // must still reach the OpenAI-compat arguments field verbatim, not - // double-encoded. - let known_tool = crate::tool::Tool::Function(crate::tool::FunctionTool::new( - "other_tool", - json!({"type": "object"}), - )); - let parsed = crate::parse_tool_call::parse_tool_call( - crate::parse_tool_call::RawToolCall { - tool_call_id: "call_1".to_string(), - tool_name: "get_weather".to_string(), - input: r#"{"a":"#.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - }, - Some(&[known_tool]), - None, - &[], - None, - ) - .await; - assert!(matches!(parsed.error, Some(AiMuxError::NoSuchTool { .. }))); - - let parts: Vec> = vec![ - Ok(StreamPart::ToolCall { - tool_call_id: parsed.tool_call_id, - tool_name: parsed.tool_name, - input: parsed.input, - provider_executed: parsed.provider_executed, - dynamic: parsed.dynamic, - thought_signature: parsed.thought_signature, - invalid: parsed.invalid, - error: parsed.error, - provider_metadata: parsed.provider_metadata, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), + async fn invalid_tool_calls_stream_their_raw_arguments_verbatim() { + let failing_repair = crate::parse_tool_call::ToolCallRepair::new(|_context| async { + Err(AiMuxError::Other("repair model failed".to_string())) + }); + let cases: [(&str, &str, Option<&crate::parse_tool_call::ToolCallRepair>); 3] = [ + // Valid JSON string rejected by the schema: the quotes must + // survive, or the arguments field stops being JSON at all. + ("get_weather", r#""hello""#, None), + // Unknown tool *and* malformed text: `NoSuchTool` carries no raw + // text of its own, so it comes off the unparsable `input`. + ("unknown_tool", r#"{"a":"#, None), + // A failed repair callback wraps the pre-repair error. + ("get_weather", r#"{"a":"#, Some(&failing_repair)), ]; - - let result = to_chat_completion_stream( - Box::pin(futures::stream::iter(parts)), - "gpt-4o", - OpenAiStreamOptions::default(), - ); - let chunks = collect_stream(result).await; - - let arguments = chunks - .iter() - .find_map(|c| { - c.choices - .first() - .and_then(|ch| ch.delta.tool_calls.as_ref()) - .and_then(|tcs| tcs.first()) - .and_then(|tc| tc.function.arguments.clone()) - }) - .expect("tool call arguments chunk not found"); - assert_eq!(arguments, r#"{"a":"#); - } - - #[tokio::test] - async fn test_stream_failed_repair_round_trips_malformed_json_verbatim() { - // End-to-end: the repair *callback* itself failing wraps the - // pre-repair InvalidToolInput as ToolCallRepair.original_error — the - // malformed raw text must still round-trip verbatim, not - // double-encoded or dropped. - let tool = crate::tool::Tool::Function(crate::tool::FunctionTool::new( + let tools = [crate::tool::Tool::Function(crate::tool::FunctionTool::new( "get_weather", json!({"type": "object", "properties": {}, "additionalProperties": false}), - )); - let repair = crate::parse_tool_call::ToolCallRepair::new(|_context| async { - Err(AiMuxError::Other("repair model failed".to_string())) - }); - let parsed = crate::parse_tool_call::parse_tool_call( - crate::parse_tool_call::RawToolCall { - tool_call_id: "call_1".to_string(), - tool_name: "get_weather".to_string(), - input: r#"{"a":"#.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - }, - Some(&[tool]), - Some(&repair), - &[], - None, - ) - .await; - assert!(matches!( - parsed.error, - Some(AiMuxError::ToolCallRepair { .. }) - )); - - let parts: Vec> = vec![ - Ok(StreamPart::ToolCall { + ))]; + + for (tool_name, raw_input, repair) in cases { + let parsed = crate::parse_tool_call::parse_tool_call( + crate::parse_tool_call::RawToolCall { + tool_call_id: "call_1".to_string(), + tool_name: tool_name.to_string(), + input: raw_input.to_string(), + provider_executed: None, + dynamic: None, + thought_signature: None, + provider_metadata: None, + }, + Some(&tools), + repair, + &[], + None, + ) + .await; + assert_eq!(parsed.invalid, Some(true), "{raw_input}"); + + let parts: Vec> = vec![Ok(StreamPart::ToolCall { tool_call_id: parsed.tool_call_id, tool_name: parsed.tool_name, input: parsed.input, @@ -1594,35 +1379,30 @@ mod tests { invalid: parsed.invalid, error: parsed.error, provider_metadata: parsed.provider_metadata, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let result = to_chat_completion_stream( - Box::pin(futures::stream::iter(parts)), - "gpt-4o", - OpenAiStreamOptions::default(), - ); - let chunks = collect_stream(result).await; - - let arguments = chunks - .iter() - .find_map(|c| { - c.choices - .first() - .and_then(|ch| ch.delta.tool_calls.as_ref()) - .and_then(|tcs| tcs.first()) - .and_then(|tc| tc.function.arguments.clone()) - }) - .expect("tool call arguments chunk not found"); - assert_eq!(arguments, r#"{"a":"#); + })]; + let chunks = collect_stream(to_chat_completion_stream( + Box::pin(futures::stream::iter(parts)), + "gpt-4o", + OpenAiStreamOptions::default(), + )) + .await; + + let arguments = chunks + .iter() + .find_map(|c| { + c.choices + .first()? + .delta + .tool_calls + .as_ref()? + .first()? + .function + .arguments + .clone() + }) + .expect("tool call arguments chunk not found"); + assert_eq!(arguments, raw_input, "tool {tool_name}"); + } } #[test] From 676e658c9c1f846b059ac7fccb9d4946c214f747 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Fri, 4 Sep 2026 00:21:40 +0800 Subject: [PATCH 16/19] test(core): keep one legacy-shape tool-call input test Three tests covered the compatibility deserializer: one loading both wire shapes, one repeating that over every non-string JSON value, and one asserting serialization stays a string. The second only varied the data; the third is two lines inside the first. Keep one test that loads both shapes and checks the round-trip. --- aimux-core/src/result.rs | 86 ++++++++++------------------------------ 1 file changed, 20 insertions(+), 66 deletions(-) diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index 67b6983a..c9d451aa 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -255,80 +255,34 @@ mod tests { use super::*; /// Regression test for PR #165 review finding: `GenerateContent::ToolCall` - /// went from carrying `input: Value` to `input: String`. A *new* result's - /// wire shape is unaffected (providers only ever put raw text in a - /// `Value::String`, which serializes identically to a bare `String`), but - /// a result persisted before the refactor can carry an object — this must - /// still deserialize, not fail closed. + /// went from carrying `input: Value` (the parsed arguments) to + /// `input: String` (the raw text), so a `GenerateResult` persisted before + /// the refactor carries an object here and must still load rather than + /// fail closed. Serialization stays one-way — always a plain string. #[test] - fn tool_call_input_deserializes_both_the_legacy_object_shape_and_the_new_string_shape() { - let legacy = serde_json::json!({ - "ToolCall": { - "tool_call_id": "call_1", - "tool_name": "get_weather", - "input": { "city": "Tokyo" }, - } - }); - let from_legacy: GenerateContent = serde_json::from_value(legacy).unwrap(); - let GenerateContent::ToolCall { input, .. } = &from_legacy else { - panic!("expected ToolCall, got {from_legacy:?}"); - }; - assert_eq!(input, r#"{"city":"Tokyo"}"#); - - let current = serde_json::json!({ - "ToolCall": { - "tool_call_id": "call_1", - "tool_name": "get_weather", - "input": r#"{"city":"Tokyo"}"#, - } - }); - let from_current: GenerateContent = serde_json::from_value(current).unwrap(); - assert_eq!(from_current, from_legacy); - } - - /// A legacy array/number/bool/null-shaped `input` (any non-string JSON - /// value some historical provider integration may have written) also - /// loads, re-serialized to its compact JSON text. - #[test] - fn tool_call_input_deserializes_every_legacy_value_shape() { - for (legacy_input, expected) in [ - (serde_json::json!([1, 2, 3]), "[1,2,3]"), - (serde_json::json!(42), "42"), - (serde_json::json!(true), "true"), - (serde_json::json!(null), "null"), - ] { - let wire = serde_json::json!({ + fn tool_call_input_loads_the_legacy_object_shape_and_the_current_string_shape() { + let wire = |input: serde_json::Value| { + serde_json::json!({ "ToolCall": { "tool_call_id": "call_1", "tool_name": "get_weather", - "input": legacy_input, + "input": input, } - }); - let parsed: GenerateContent = serde_json::from_value(wire).unwrap(); - let GenerateContent::ToolCall { input, .. } = &parsed else { - panic!("expected ToolCall, got {parsed:?}"); - }; - assert_eq!(input, expected); - } - } + }) + }; + let from_legacy: GenerateContent = + serde_json::from_value(wire(serde_json::json!({ "city": "Tokyo" }))).unwrap(); + let from_current: GenerateContent = + serde_json::from_value(wire(serde_json::json!(r#"{"city":"Tokyo"}"#))).unwrap(); + assert_eq!(from_legacy, from_current); - /// The field always serializes as a plain JSON string, never a nested - /// JSON value — the current, non-legacy wire shape. - #[test] - fn tool_call_input_serializes_as_a_string() { - let content = GenerateContent::ToolCall { - tool_call_id: "call_1".to_string(), - tool_name: "get_weather".to_string(), - input: r#"{"city":"Tokyo"}"#.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, + let GenerateContent::ToolCall { input, .. } = &from_legacy else { + panic!("expected ToolCall, got {from_legacy:?}"); }; - let wire = serde_json::to_value(&content).unwrap(); + assert_eq!(input, r#"{"city":"Tokyo"}"#); assert_eq!( - wire["ToolCall"]["input"], - serde_json::json!(r#"{"city":"Tokyo"}"#) + serde_json::to_value(&from_legacy).unwrap(), + wire(serde_json::json!(r#"{"city":"Tokyo"}"#)) ); } } From d4e8454bc8b67b2b1e3e73497c15e1ac12f34c15 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Fri, 4 Sep 2026 00:22:11 +0800 Subject: [PATCH 17/19] test: drop duplicate tool-error test cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The golden error snapshots pinned NoSuchTool-with-available-tools, InvalidToolInput, and ToolCallRepair twice each, the second copy differing only in its data — the wire shape is what the snapshot pins. Keep one case per shape (NoSuchTool stays twice: `skip_serializing_if` makes its payload vary with `available_tools`). The provider-utils test asserting three error_variant log reasons only restated three string constants from a match a compile error already guards. --- aimux-core/tests/error_value_golden_test.rs | 29 --------------------- 1 file changed, 29 deletions(-) diff --git a/aimux-core/tests/error_value_golden_test.rs b/aimux-core/tests/error_value_golden_test.rs index 89dfe41f..6d8f9164 100644 --- a/aimux-core/tests/error_value_golden_test.rs +++ b/aimux-core/tests/error_value_golden_test.rs @@ -139,35 +139,6 @@ fn error_value_snapshots_plain_variants() { }, r#"{"ToolCallRepair":{"original_error":{"NoSuchTool":{"tool_name":"weathr"}},"cause":{"Other":"repair model failed"}}}"#, ), - ( - AiMuxError::NoSuchTool { - tool_name: "forecast".into(), - available_tools: Some(vec!["weather".into(), "search".into()]), - }, - r#"{"NoSuchTool":{"tool_name":"forecast","available_tools":["weather","search"]}}"#, - ), - ( - AiMuxError::InvalidToolInput { - tool_name: "weather".into(), - tool_input: r#"{"city":7}"#.into(), - cause: "input does not match the schema".into(), - }, - r#"{"InvalidToolInput":{"tool_name":"weather","tool_input":"{\"city\":7}","cause":"input does not match the schema"}}"#, - ), - ( - AiMuxError::ToolCallRepair { - original_error: Box::new(AiMuxError::NoSuchTool { - tool_name: "forecast".into(), - available_tools: None, - }), - cause: Box::new(AiMuxError::InvalidToolInput { - tool_name: "weather".into(), - tool_input: "{".into(), - cause: "input is not valid JSON".into(), - }), - }, - r#"{"ToolCallRepair":{"original_error":{"NoSuchTool":{"tool_name":"forecast"}},"cause":{"InvalidToolInput":{"tool_name":"weather","tool_input":"{","cause":"input is not valid JSON"}}}}"#, - ), ( AiMuxError::InvalidArgument("bad arg".into()), r#"{"InvalidArgument":"bad arg"}"#, From a667bb47dea53da80669c4de7cc19a635beefefc Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Mon, 7 Sep 2026 17:03:20 +0800 Subject: [PATCH 18/19] fix(core): preserve original arguments when tool calls remain invalid --- aimux-core/src/error.rs | 6 +- aimux-core/src/openai_output.rs | 36 ++------- aimux-core/src/parse_tool_call.rs | 10 ++- aimux-core/tests/error_value_golden_test.rs | 7 +- aimux-core/tests/tool_input_test.rs | 89 ++++++++++++++++++++- aimux-ffi/aimux-error.h | 2 +- aimux-ffi/src/lib.rs | 22 ++++- bindings/node/src/error.rs | 4 + bindings/node/src/error.ts | 4 +- bindings/node/src/types/AiMuxError.ts | 7 +- bindings/python/src/error.rs | 2 + docs/api/gaps.md | 6 ++ tools/aimux-web/web/src/types/AiMuxError.ts | 7 +- 13 files changed, 164 insertions(+), 38 deletions(-) diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs index 3fdc23ce..e5796b5a 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -188,6 +188,10 @@ pub enum AiMuxError { tool_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] available_tools: Option>, + /// Original argument text, when supplied by the provider. Absent in + /// older serialized errors and errors constructed without a call. + #[serde(default, skip_serializing_if = "Option::is_none")] + tool_input: Option, }, /// A tool call could not be parsed or did not satisfy its input schema. @@ -198,7 +202,7 @@ pub enum AiMuxError { cause: String, }, - /// The optional repair callback failed while handling an invalid call. + /// The repair callback failed or returned a call that was still invalid. #[error("Error repairing tool call: {cause}")] ToolCallRepair { original_error: Box, diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index 0b430deb..6e8fdfb6 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -1093,26 +1093,13 @@ fn now_unix() -> u64 { .unwrap_or(0) } -/// Recover the provider's raw argument text from an invalid call's typed -/// error, when the error shape carries or implies it verbatim. -/// -/// `InvalidToolInput` always sets `tool_input` to `RawToolCall.input`, the -/// byte-for-byte provider text, whether the failure was a JSON parse error or -/// a schema mismatch on already-valid JSON. `NoSuchTool` carries no text of -/// its own — it fires purely off the tool name, before Core looks at the -/// arguments — so it falls back to `input`, which holds the best-effort parse -/// of that text: a string there is either malformed text kept verbatim (emit -/// it as-is) or a genuine JSON string (whose quotes are then lost). The two -/// are indistinguishable without a raw-text field on the error, and the -/// malformed case is the one that actually occurs. `ToolCallRepair` wraps -/// whichever of those the repair callback was invoked over; unwrap to reach it. -fn raw_tool_call_text(error: &AiMuxError, input: &Value) -> Option { +// Rejected repairs retain the original call. Recover its text from the +// original error, never from the replacement's validation failure. +fn raw_tool_call_text(error: &AiMuxError) -> Option { match error { AiMuxError::InvalidToolInput { tool_input, .. } => Some(tool_input.clone()), - AiMuxError::NoSuchTool { .. } => input.as_str().map(str::to_string), - AiMuxError::ToolCallRepair { original_error, .. } => { - raw_tool_call_text(original_error, input) - } + AiMuxError::NoSuchTool { tool_input, .. } => tool_input.clone(), + AiMuxError::ToolCallRepair { original_error, .. } => raw_tool_call_text(original_error), _ => None, } } @@ -1121,15 +1108,8 @@ fn raw_tool_call_text(error: &AiMuxError, input: &Value) -> Option { /// wire text: the provider's raw argument text verbatim for an invalid call, /// compact JSON of the parsed value otherwise. /// -/// `input: Value` alone cannot carry this distinction: a syntactically valid -/// JSON string like `"hello"` parses to `Value::String("hello")`, and so does -/// malformed text (e.g. bare `hello`) that Core falls back to wrapping -/// verbatim — both produce the identical `Value`, and `Value::Null` on a -/// *valid* call must stay `null`, not get rewritten to `{}`. See -/// `raw_tool_call_text` for how the ambiguity is resolved from the typed -/// error. A call without a recoverable raw text (which should not currently -/// happen — every invalid-call error variant is handled above) falls back to -/// compact-serializing `input`. +/// Older errors without raw argument text fall back to serializing the +/// parsed input as JSON. pub(crate) fn parsed_tool_call_arguments( input: &Value, invalid: Option, @@ -1137,7 +1117,7 @@ pub(crate) fn parsed_tool_call_arguments( ) -> String { if invalid == Some(true) && let Some(error) = error - && let Some(raw) = raw_tool_call_text(error, input) + && let Some(raw) = raw_tool_call_text(error) { // Blank text still fails validation against a schema with required // properties, but OpenAI's wire format has no representation for diff --git a/aimux-core/src/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs index 7b2148ab..89890d87 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -120,6 +120,7 @@ pub async fn parse_tool_call( Err(AiMuxError::NoSuchTool { tool_name: tool_call.tool_name.clone(), available_tools: None, + tool_input: Some(tool_call.input.clone()), }) }; return match parsed { @@ -144,7 +145,13 @@ pub async fn parse_tool_call( Ok(Some(repaired)) => match parse_and_validate_tool_call(&repaired, tools) { Ok((input, dynamic)) => return valid_tool_call(repaired, input, dynamic), Err(repaired_error) => { - return invalid_tool_call(tool_call, repaired_error); + return invalid_tool_call( + tool_call, + AiMuxError::ToolCallRepair { + original_error: Box::new(original_error), + cause: Box::new(repaired_error), + }, + ); } }, Ok(None) => {} @@ -181,6 +188,7 @@ fn parse_and_validate_tool_call( } return Err(AiMuxError::NoSuchTool { tool_name: tool_call.tool_name.clone(), + tool_input: Some(tool_call.input.clone()), available_tools: Some( tools .iter() diff --git a/aimux-core/tests/error_value_golden_test.rs b/aimux-core/tests/error_value_golden_test.rs index 6d8f9164..8df2b9aa 100644 --- a/aimux-core/tests/error_value_golden_test.rs +++ b/aimux-core/tests/error_value_golden_test.rs @@ -111,13 +111,15 @@ fn error_value_snapshots_plain_variants() { AiMuxError::NoSuchTool { tool_name: "weathr".into(), available_tools: Some(vec!["weather".into(), "search".into()]), + tool_input: Some(r#""hello""#.into()), }, - r#"{"NoSuchTool":{"tool_name":"weathr","available_tools":["weather","search"]}}"#, + r#"{"NoSuchTool":{"tool_name":"weathr","available_tools":["weather","search"],"tool_input":"\"hello\""}}"#, ), ( AiMuxError::NoSuchTool { tool_name: "weathr".into(), available_tools: None, + tool_input: None, }, r#"{"NoSuchTool":{"tool_name":"weathr"}}"#, ), @@ -134,6 +136,7 @@ fn error_value_snapshots_plain_variants() { original_error: Box::new(AiMuxError::NoSuchTool { tool_name: "weathr".into(), available_tools: None, + tool_input: None, }), cause: Box::new(AiMuxError::Other("repair model failed".into())), }, @@ -227,6 +230,7 @@ fn variant_set_is_exactly_sixteen() { AiMuxError::NoSuchTool { tool_name: "x".into(), available_tools: None, + tool_input: None, }, AiMuxError::InvalidToolInput { tool_name: "x".into(), @@ -237,6 +241,7 @@ fn variant_set_is_exactly_sixteen() { original_error: Box::new(AiMuxError::NoSuchTool { tool_name: "x".into(), available_tools: None, + tool_input: None, }), cause: Box::new(AiMuxError::Other("x".into())), }, diff --git a/aimux-core/tests/tool_input_test.rs b/aimux-core/tests/tool_input_test.rs index a0f67aae..0077989e 100644 --- a/aimux-core/tests/tool_input_test.rs +++ b/aimux-core/tests/tool_input_test.rs @@ -209,8 +209,9 @@ async fn an_invalid_repair_is_not_repaired_again() { assert_eq!(call.invalid, Some(true)); assert!(matches!( call.error, - Some(AiMuxError::InvalidToolInput { tool_input, .. }) - if tool_input == r#"{"city":7}"# + Some(AiMuxError::ToolCallRepair { original_error, cause }) + if matches!(*original_error, AiMuxError::InvalidToolInput { ref tool_input, .. } if tool_input == "{") + && matches!(*cause, AiMuxError::InvalidToolInput { ref tool_input, .. } if tool_input == r#"{"city":7}"#) )); } @@ -631,3 +632,87 @@ async fn openai_stream_without_repair_preserves_provider_tool_input_deltas() { Some(r#"{"city":"Singapore"}"#) ); } + +async fn assert_openai_arguments( + model: &RawToolModel, + options: GenerateTextOptions, + expected: &str, +) { + let completion = generate_text_as_openai(model, "weather", options.clone()) + .await + .unwrap(); + let call = &completion.choices[0].message.tool_calls.as_ref().unwrap()[0]; + assert_eq!(call.function.name, model.tool_name); + assert_eq!(call.function.arguments, expected); + + let result = stream_text_as_openai(model, "weather", options, OpenAiStreamOptions::default()) + .await + .unwrap(); + let chunks = result + .stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .unwrap(); + let calls: Vec<_> = chunks + .iter() + .flat_map(|chunk| &chunk.choices) + .filter_map(|choice| choice.delta.tool_calls.as_ref()) + .flatten() + .collect(); + assert_eq!( + calls.iter().find_map(|call| call.function.name.as_deref()), + Some(model.tool_name) + ); + let arguments: String = calls + .iter() + .filter_map(|call| call.function.arguments.as_deref()) + .collect(); + assert_eq!(arguments, expected); +} + +#[tokio::test] +async fn invalid_repair_keeps_original_arguments_with_original_name() { + for name in ["forecast", "weather"] { + let repair = ToolCallRepair::new(|context| async move { + Ok(Some(RawToolCall { + tool_name: "weather".into(), + input: r#"{"city":7}"#.into(), + ..context.tool_call + })) + }); + let model = RawToolModel::new(r#"{"place":"Tokyo"}"#) + .named(name) + .with_streamed_input(); + assert_openai_arguments( + &model, + GenerateTextOptions { + tools: Some(vec![weather_tool()]), + repair_tool_call: Some(repair), + ..Default::default() + }, + r#"{"place":"Tokyo"}"#, + ) + .await; + } +} + +#[tokio::test] +async fn unknown_tool_preserves_raw_arguments_with_and_without_input_deltas() { + for input in [r#""hello""#, "hello", "null", " { \"city\": \"Tokyo\" } "] { + for deltas in [false, true] { + let mut model = RawToolModel::new(input).named("unknown"); + model.stream_input = deltas; + assert_openai_arguments( + &model, + GenerateTextOptions { + tools: Some(vec![weather_tool()]), + ..Default::default() + }, + input, + ) + .await; + } + } +} diff --git a/aimux-ffi/aimux-error.h b/aimux-ffi/aimux-error.h index 06396e67..a86aa926 100644 --- a/aimux-ffi/aimux-error.h +++ b/aimux-ffi/aimux-error.h @@ -158,7 +158,7 @@ char *aimux_error_tool_name(const aimux_error_t *error); * or NULL when no tool set was supplied. */ char *aimux_error_available_tools(const aimux_error_t *error); -/** AIMUX_E_INVALID_TOOL_INPUT: the raw argument text the model produced. */ +/** AIMUX_E_INVALID_TOOL_INPUT / AIMUX_E_NO_SUCH_TOOL: raw argument text, or NULL if unavailable. */ char *aimux_error_tool_input(const aimux_error_t *error); /** * AIMUX_E_TOOL_CALL_REPAIR: the original lookup/parse/validation error as diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index d2ab0497..d3edc777 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -774,12 +774,14 @@ pub extern "C" fn aimux_error_available_tools(err: *const aimux_error_t) -> *mut ) } -/// `AIMUX_E_INVALID_TOOL_INPUT`: the raw argument text the model produced. +/// `AIMUX_E_INVALID_TOOL_INPUT` / `AIMUX_E_NO_SUCH_TOOL`: the raw argument +/// text the model produced, or NULL when unavailable. #[unsafe(no_mangle)] pub extern "C" fn aimux_error_tool_input(err: *const aimux_error_t) -> *mut c_char { opt_cstring( map_aimux_error(err, |e| match e { AiMuxError::InvalidToolInput { tool_input, .. } => Some(tool_input.clone()), + AiMuxError::NoSuchTool { tool_input, .. } => tool_input.clone(), _ => None, }) .flatten(), @@ -3713,6 +3715,7 @@ mod tests { AiMuxError::NoSuchTool { tool_name: s("t"), available_tools: None, + tool_input: None, }, AIMUX_E_NO_SUCH_TOOL, ), @@ -3729,6 +3732,7 @@ mod tests { original_error: Box::new(AiMuxError::NoSuchTool { tool_name: s("t"), available_tools: None, + tool_input: None, }), cause: Box::new(AiMuxError::Other(s("x"))), }, @@ -3771,6 +3775,22 @@ mod tests { } } + #[test] + fn unknown_tool_error_exposes_original_argument_text() { + let raw = r#""hello""#; + let error = boxed(AiMuxError::NoSuchTool { + tool_name: "unknown".into(), + available_tools: None, + tool_input: Some(raw.into()), + }); + let text = aimux_error_tool_input(error); + assert!(!text.is_null()); + // The accessor returns an owned C string, released with the public API. + assert_eq!(unsafe { CStr::from_ptr(text) }.to_str().unwrap(), raw); + unsafe { aimux_free_string(text) }; + aimux_error_free(error); + } + /// Interior NUL bytes must not corrupt or truncate the message. #[test] fn error_message_sanitizes_interior_nul() { diff --git a/bindings/node/src/error.rs b/bindings/node/src/error.rs index 0d6f5a56..bea1e27b 100644 --- a/bindings/node/src/error.rs +++ b/bindings/node/src/error.rs @@ -295,8 +295,12 @@ fn aimux_error_object<'env>(env: &'env Env, error: &AiMuxError) -> NapiResult { obj.set("toolName", tool_name.as_str())?; + if let Some(input) = tool_input { + obj.set("toolInput", input.as_str())?; + } if let Some(tools) = available_tools { obj.set("availableTools", tools.clone())?; } diff --git a/bindings/node/src/error.ts b/bindings/node/src/error.ts index c9f1f8fb..9fbf1105 100644 --- a/bindings/node/src/error.ts +++ b/bindings/node/src/error.ts @@ -84,6 +84,8 @@ export class NoSuchToolError extends AimuxError { declare readonly toolName: string /** Tools that were available; absent if none were. */ declare readonly availableTools?: string[] + /** Original argument text, when available. */ + declare readonly toolInput?: string } /** A tool call's input failed to parse or violated the tool's schema (AI SDK `InvalidToolInputError`). */ export class InvalidToolInputError extends AimuxError { @@ -92,7 +94,7 @@ export class InvalidToolInputError extends AimuxError { /** The raw input text the model produced. */ declare readonly toolInput: string } -/** The repair callback failed while handling an invalid tool call (AI SDK `ToolCallRepairError`). */ +/** The repair callback failed or returned a call that was still invalid. */ export class ToolCallRepairError extends AimuxError { /** The failure that triggered repair (a {@link NoSuchToolError} or {@link InvalidToolInputError}). */ declare readonly originalError: NoSuchToolError | InvalidToolInputError diff --git a/bindings/node/src/types/AiMuxError.ts b/bindings/node/src/types/AiMuxError.ts index 2a8eefbf..8627c19b 100644 --- a/bindings/node/src/types/AiMuxError.ts +++ b/bindings/node/src/types/AiMuxError.ts @@ -9,7 +9,12 @@ import type { RetryError } from "./RetryError"; * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. */ -export type AiMuxError = { "ApiCall": ApiCallError } | { "Retry": RetryError } | { "JsonParse": string } | { "InvalidResponseData": string } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, +export type AiMuxError = { "ApiCall": ApiCallError } | { "Retry": RetryError } | { "JsonParse": string } | { "InvalidResponseData": string } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, +/** + * Original argument text, when supplied by the provider. Absent in + * older serialized errors and errors constructed without a call. + */ +tool_input?: string | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, /** * What kind of model was requested (`"languageModel"`, * `"imageModel"`, …), the AI SDK's `modelType`. diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs index ea25e8cd..be2165d5 100644 --- a/bindings/python/src/error.rs +++ b/bindings/python/src/error.rs @@ -330,9 +330,11 @@ fn variant_instance<'py>(py: Python<'py>, e: &AiMuxError) -> PyResult { inst.setattr("tool_name", tool_name.as_str())?; inst.setattr("available_tools", available_tools.clone())?; + inst.setattr("tool_input", tool_input.as_deref())?; } AiMuxError::InvalidToolInput { tool_name, diff --git a/docs/api/gaps.md b/docs/api/gaps.md index c532730b..f11d07b2 100644 --- a/docs/api/gaps.md +++ b/docs/api/gaps.md @@ -204,6 +204,12 @@ the refactor therefore keeps loading, with no migration step. The parsed arguments still reach callers as a `Value`, on `GenerateTextResult.tool_calls[].input` — that field is unchanged. +`NoSuchTool` now includes optional `tool_input` with the original argument text; +older errors without this field still deserialize. If a repair callback returns +an invalid replacement, `ToolCallRepair` retains the original lookup/validation +error and the replacement failure as `cause`. The returned call and its OpenAI +output keep the original name and arguments; only a validated repair replaces them. + --- ## 建议实施顺序 diff --git a/tools/aimux-web/web/src/types/AiMuxError.ts b/tools/aimux-web/web/src/types/AiMuxError.ts index a5377515..72b4c339 100644 --- a/tools/aimux-web/web/src/types/AiMuxError.ts +++ b/tools/aimux-web/web/src/types/AiMuxError.ts @@ -14,7 +14,12 @@ import type { ApiCallError } from "./ApiCallError"; * (`ApiCallError { status_code: .., ..Default::default() }`), the same * shape as the AI SDK's named-options constructor. */ -export type AiMuxError = { "ApiCall": ApiCallError } | { "JsonParse": string } | { "InvalidResponseData": string } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, +export type AiMuxError = { "ApiCall": ApiCallError } | { "JsonParse": string } | { "InvalidResponseData": string } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, +/** + * Original argument text, when supplied by the provider. Absent in + * older serialized errors and errors constructed without a call. + */ +tool_input?: string | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, /** * What kind of model was requested (`"languageModel"`, * `"imageModel"`, …), the AI SDK's `modelType`. From eb0a93fd9a9bae70d10147359642ece1ab0df9c7 Mon Sep 17 00:00:00 2001 From: Card Cunningham Date: Mon, 14 Sep 2026 09:20:30 +0800 Subject: [PATCH 19/19] fix: reconcile the rebase onto #164 with the Retry error code The #164 squash added AiMuxError::Retry at C code 14, the slot this branch had documented as reserved. Resolving the overlap left both versions of the affected comments and docs in place, two binding tests still asserting the pre-#164 layout, and a Kotlin source file that no longer compiled. - Kotlin Errors.kt: redo the three-way merge. The earlier resolution kept both sides of the imports, the createByCode parameter list and the helper block, leaving an unbalanced brace. - Flutter and Kotlin tests: the first unassigned code is 18, not 14 or 15. - Drop the duplicated master/branch comment pairs in the Go, Java, Kotlin and Swift bindings and in docs/api; describe the code space as 1..17 with 4 retired and 14 = Retry, and the variant count as 16. --- aimux-ffi/aimux-error.h | 2 +- aimux-ffi/aimux-ffi.h | 2 +- aimux-ffi/src/lib.rs | 4 ++-- bindings/flutter/lib/errors.dart | 9 ++++---- bindings/flutter/test/errors_test.dart | 9 +++----- bindings/go/aimux.go | 5 ++-- bindings/go/error.go | 2 +- .../ai/arcships/aimux/AimuxException.java | 20 ++++++---------- .../java/ai/arcships/aimux/AimuxResult.java | 7 ++---- .../main/kotlin/ai/arcships/aimux/Errors.kt | 23 +++++++++---------- .../main/kotlin/ai/arcships/aimux/Model.kt | 6 ++--- .../kotlin/ai/arcships/aimux/Multimodal.kt | 3 +-- .../kotlin/ai/arcships/aimux/ErrorsTest.kt | 5 ++-- bindings/swift/Sources/Aimux/Aimux.swift | 9 +++----- docs/api/c.md | 17 ++++---------- docs/api/flutter.md | 6 ++--- docs/api/go.md | 14 ++++------- docs/api/java.md | 6 ++--- docs/api/kotlin.md | 6 ++--- docs/api/swift.md | 3 +-- docs/error-model.md | 2 +- 21 files changed, 58 insertions(+), 102 deletions(-) diff --git a/aimux-ffi/aimux-error.h b/aimux-ffi/aimux-error.h index a86aa926..81ef6928 100644 --- a/aimux-ffi/aimux-error.h +++ b/aimux-ffi/aimux-error.h @@ -6,7 +6,7 @@ * trailing out-parameter, which remains at its documented sentinel on failure. * * Every non-NULL error has one non-zero `aimux_error_code_t` and one message. - * Codes 1..14 come from `AiMuxError`, 100..105 from + * Codes 1..17 come from `AiMuxError`, 100..105 from * `RecordingError`, and 200..206 identify failures detected while crossing * the C ABI. Higher-level bindings reconstruct their native error types from * that code; they map all 200..206 codes to the language's existing diff --git a/aimux-ffi/aimux-ffi.h b/aimux-ffi/aimux-ffi.h index eaa2692e..9705ec30 100644 --- a/aimux-ffi/aimux-ffi.h +++ b/aimux-ffi/aimux-ffi.h @@ -15,7 +15,7 @@ * Getter strings are caller-owned (`aimux_free_string`). See aimux-error.h. * * Each prototype below identifies its expected high-level error: - * [AiMuxError] codes 1..14 + * [AiMuxError] codes 1..17 * [RecordingError] codes 100..105 * [C ABI] no expected high-level code * Every fallible call can additionally return a C ABI failure (200..206). diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index d3edc777..76e4c939 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -12,7 +12,7 @@ //! value on failure (the out-parameter is left at its sentinel: handle 0, //! pointer NULL). Every non-NULL error has one code from [`aimux_error_code`] //! and one message from [`aimux_error_message`], and is released exactly once -//! with [`aimux_error_free`]. Codes 1..14 come from `AiMuxError`, 100..105 +//! with [`aimux_error_free`]. Codes 1..17 come from `AiMuxError`, 100..105 //! from `RecordingError`, and 200..206 identify failures detected while //! crossing the C ABI. //! @@ -3578,7 +3578,7 @@ mod tests { assert_eq!(aimux_error_code(e), AIMUX_E_ABORTED); assert_eq!(msg(e), "request aborted"); - // Retry extends the contiguous run to 1..14. + // Retry fills slot 14 between Aborted and the tool-call codes. let e = boxed(AiMuxError::Retry(RetryError { reason: RetryErrorReason::ErrorNotRetryable, errors: vec![AiMuxError::Other("bad".into())], diff --git a/bindings/flutter/lib/errors.dart b/bindings/flutter/lib/errors.dart index a69744a8..75b9d9e3 100644 --- a/bindings/flutter/lib/errors.dart +++ b/bindings/flutter/lib/errors.dart @@ -4,7 +4,7 @@ // Transport (aimux-error.h): every fallible C call returns // `aimux_error_t *` — NULL on success (the result is in the trailing // out-param), non-NULL on failure (out-param at its sentinel: 0 / NULL). The -// unified code selects AiMuxError (1..13, 15..17), RecordingError (100..105), or a +// unified code selects AiMuxError (1..17), RecordingError (100..105), or a // C ABI failure (200..206). The last range maps to // StateError('aimux ffi: …'); Dart does not expose seven additional classes. // Every field is copied before the error is released with `aimux_error_free` @@ -22,8 +22,7 @@ import 'package:ffi/ffi.dart'; // ───────────────────────────────────────────────────────────────────────────── /// Machine-readable codes. Values match C `aimux_error_code_t` / Go `Code`. -/// 15 variant codes: 1–13 plus 15–17 (1 is the catch-all; 4 is retired, 14 is -/// reserved). A code outside +/// 16 variant codes: 1–17 (1 is the catch-all; 4 is retired). A code outside /// that set is a header/library mismatch and fails with [StateError], not an /// error type. Every HTTP-shaped failure /// arrives as [apiCall], classified @@ -79,7 +78,7 @@ abstract final class AimuxErrorCode { /// Decode the `aimux_error_t *` [e] returned by a call that can fail in /// `AiMuxError` (`[AiMuxError]` in aimux-ffi.h). NULL → returns (success). -/// Codes 1..13 / 15..17 become [AimuxException]; 200..206 become [StateError]. +/// Codes 1..17 become [AimuxException]; 200..206 become [StateError]. /// The returned error is always freed. void expectAimuxError(Pointer e, String context) { if (e == nullptr) return; @@ -514,7 +513,7 @@ class AimuxException implements Exception { /// Build the typed subclass from a returned `const aimux_error_t *` [error] /// via the `aimux_error_*` getters (payload getters only under the owning /// code; getter strings freed here). The caller ([expectAimuxError]) - /// frees it. A code outside 1..13 / 15..17 is a + /// frees it. A code outside 1..17 is a /// contract violation and throws [StateError]. factory AimuxException._decode(Pointer error, String context) { final code = _errorCode(error); diff --git a/bindings/flutter/test/errors_test.dart b/bindings/flutter/test/errors_test.dart index 66a989d7..07fe3833 100644 --- a/bindings/flutter/test/errors_test.dart +++ b/bindings/flutter/test/errors_test.dart @@ -88,10 +88,10 @@ void main() { test('unknown code is rejected with StateError', () { // A code outside the published table is an ABI mismatch, not an error // kind. 1 is AIMUX_E_OTHER now because Other inherited the old UNKNOWN - // slot, so it resolves; 4 is retired and 14 is reserved. + // slot, so it resolves; 4 is retired and 18 is the first unassigned value. expect(() => AimuxException.fromCode(999, 'future'), throwsStateError); expect(() => AimuxException.fromCode(4, 'retired'), throwsStateError); - expect(() => AimuxException.fromCode(14, 'reserved'), throwsStateError); + expect(() => AimuxException.fromCode(18, 'unassigned'), throwsStateError); }); test('bare retry code synthesizes a single-attempt RetryError', () { @@ -178,13 +178,10 @@ void main() { expect(AimuxErrorCode.noSuchProvider, 10); expect(AimuxErrorCode.name(AimuxErrorCode.noSuchProvider), 'NoSuchProvider'); - // Engine codes are contiguous 1–14. - expect(AimuxErrorCode.other, 1); - expect(AimuxErrorCode.aborted, 13); expect(AimuxErrorCode.retry, 14); expect(AimuxErrorCode.name(AimuxErrorCode.retry), 'Retry'); // The AIMUX_E_UNKNOWN catch-all is gone and Other took its slot; the - // engine codes are 1–13 (4 retired) plus 15–17 (14 reserved). + // engine codes are 1–17 (4 retired). expect(AimuxErrorCode.other, 1); expect(AimuxErrorCode.aborted, 13); expect(AimuxErrorCode.noSuchTool, 15); diff --git a/bindings/go/aimux.go b/bindings/go/aimux.go index 9c40baa7..9f6b3ac3 100644 --- a/bindings/go/aimux.go +++ b/bindings/go/aimux.go @@ -1125,7 +1125,7 @@ func cstr(p *C.char) string { // ── Error decoding ─────────────────────────────────────────────────────────── // // Every fallible C call returns *C.aimux_error_t: nil = success, non-nil -// = failure. The unified code space distinguishes AiMuxError (1..14), +// = failure. The unified code space distinguishes AiMuxError (1..17), // RecordingError (100..105), and failures detected by the C ABI (200..206). // The latter collapse to a plain error in Go; no public Go error type is added // for those implementation failures. Every helper frees the pointer once. @@ -1150,8 +1150,7 @@ func expectFfiError(e *C.aimux_error_t) error { return ffiError(e) } -// expectAimuxError decodes an [AiMuxError] call: nil → nil; 1..14 → -// expectAimuxError decodes an [AiMuxError] call: nil → nil; 1..13, 15..17 → +// expectAimuxError decodes an [AiMuxError] call: nil → nil; 1..17 → // *Error; 200..206 → plain C ABI error. Any other code is an ABI contract // violation. func expectAimuxError(e *C.aimux_error_t) error { diff --git a/bindings/go/error.go b/bindings/go/error.go index 8c374430..ac858cbb 100644 --- a/bindings/go/error.go +++ b/bindings/go/error.go @@ -27,7 +27,7 @@ const ( CodeOther Code = 1 CodeJSONParse Code = 2 CodeInvalidResponseData Code = 3 - // 4 is retired (the legacy Tool variant); 14 is reserved. + // 4 is retired (the legacy Tool variant). CodeInvalidArgument Code = 5 CodeInvalidPrompt Code = 6 CodeTokenExpired Code = 7 diff --git a/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java index 27c2ce28..727fecbe 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java @@ -11,7 +11,7 @@ /** * AiMuxError hierarchy (OpenAI Java / Vercel AI SDK style). * - *

Raised when a fallible C ABI call returns an AiMuxError code (1–13, 15–17). + *

Raised when a fallible C ABI call returns an AiMuxError code (1–17). * Recording failures use the * independent {@link RecordingException} type; C ABI failures (bad raw * wire JSON, use-after-close, re-entrant call) surface as plain @@ -29,7 +29,7 @@ * } * } * - *

Every instance carries {@link #getCode()} (C {@code aimux_error_code_t} 1–13, 15–17), + *

Every instance carries {@link #getCode()} (C {@code aimux_error_code_t} 1–17), * {@link #getStatusCode()} (HTTP or {@code -1}), {@link #getRetryMs()} (hint * or {@code -1}; {@code 0} = retry now) and {@link #isRetryable()}. Message * text comes from the C layer. @@ -42,10 +42,8 @@ public class AimuxException extends RuntimeException { private static final long serialVersionUID = 1L; // ── aimux_error_code_t (aimux-error.h) ────────────────────────────────── - // 14 variant codes (1–14; 1 is the catch-all OTHER, 14 = RETRY reclaiming - // the slot the pre-unification Other vacated); every HTTP-shaped failure - // 15 variant codes (1–13 and 15–17; 1 is the catch-all OTHER; 4 is retired — - // the legacy Tool variant — and 14 is reserved); every HTTP-shaped failure + // 16 variant codes (1–17; 1 is the catch-all OTHER; 4 is retired — the + // legacy Tool variant; 14 = RETRY); every HTTP-shaped failure // arrives as AIMUX_E_API_CALL. A code outside that set is a header/library // mismatch and fails with IllegalStateException, never an AimuxException. // Recording failures are a different type: see RecordingException. @@ -73,8 +71,7 @@ public class AimuxException extends RuntimeException { private final long retryMs; // Set once by the fromC construction path; false for local / synthesized - // failures. Not a constructor param so the subclass constructors keep - // failures. Not a constructor param so the 15 subclass constructors keep + // failures. Not a constructor param so the 16 subclass constructors keep // their public signatures. private boolean retryable; @@ -106,8 +103,7 @@ public AimuxException(String message, int code, int status, long retryMs, Throwa // ── Accessors ─────────────────────────────────────────────────────────── - /** C {@code aimux_error_code_t} value (1–14). */ - /** C {@code aimux_error_code_t} value (1–13, 15–17). */ + /** C {@code aimux_error_code_t} value (1–17). */ public int getCode() { return code; } @@ -144,9 +140,7 @@ public boolean isRetryable() { * every returned string. Does not own the pointer: the caller * ({@link AimuxResult#expectAimuxError}) frees the returned error afterwards * (retry attempt errors are new owned copies and are freed here). - * A code outside 1–14 is a header/library mismatch → - * ({@link AimuxResult#expectAimuxError}) frees the returned error afterwards. - * A code outside 1–13 / 15–17 is a header/library mismatch → + * A code outside 1–17 is a header/library mismatch → * {@link IllegalStateException}. */ static AimuxException fromC(Pointer error, String prefix) { diff --git a/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java index 90bf8470..2c2b545b 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java @@ -9,8 +9,7 @@ * *

Every fallible C call returns an {@code aimux_error_t *} ({@code null} * = success, result in the out-parameter). Its code identifies an AiMuxError - * (1–14), RecordingError (100–105), or a failure detected by the C ABI - * (1–13, 15–17), RecordingError (100–105), or a failure detected by the C ABI + * (1–17), RecordingError (100–105), or a failure detected by the C ABI * (200–206). The last range collapses to {@link IllegalStateException} * ({@code "aimux ffi: "} + message); Java does not expose seven additional * exception types. Each helper frees the pointer exactly once. User-triggerable @@ -65,10 +64,8 @@ private static String prefix(String context) { } /** - * Decode an error from a call that may return {@code AiMuxError}: 1–14 → + * Decode an error from a call that may return {@code AiMuxError}: 1–17 → * {@link AimuxException}; 200–206 → {@link IllegalStateException}. - * Decode an error from a call that may return {@code AiMuxError}: 1–13 / - * 15–17 → {@link AimuxException}; 200–206 → {@link IllegalStateException}. * Frees {@code e}. */ static RuntimeException expectAimuxError(Pointer e, String context) { diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt index 3ddc6fda..4c18eaed 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt @@ -1,17 +1,17 @@ package ai.arcships.aimux import com.sun.jna.Pointer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.serializer /** * Machine-readable codes matching aimux-ffi `aimux_error_code_t` (aimux-error.h). - * 1..13 and 15..17 mirror the 15 core variants (1 is the catch-all `Other`; - * 4 is retired — the legacy `Tool` variant — and 14 is reserved). The + * 1..17 mirror the 16 core variants (1 is the catch-all `Other`; + * 4 is retired — the legacy `Tool` variant; 14 = `Retry`). The * per-status codes (Provider, Http, RateLimited, Auth, ModelNotFound) are * gone, every HTTP-shaped failure arrives as [AIMUX_E_API_CALL]. */ @@ -55,8 +55,7 @@ const val AIMUX_E_RETRY: Int = 14 * } * ``` * - * Transport: Rust → C `aimux_error_t *` with code 1..14 → [fromC]. - * Transport: Rust → C `aimux_error_t *` with code 1..13 / 15..17 → [fromC]. + * Transport: Rust → C `aimux_error_t *` with code 1..17 → [fromC]. * Primary path is not a JSON * error envelope. */ @@ -87,10 +86,7 @@ sealed class AimuxException( * string. Does not own the pointer: the caller ([expectAimuxError]) frees * the returned error afterwards (retry attempt errors are new owned * copies and are freed here). Code [AIMUX_OK] or a code outside - * 1..14 is a header/library mismatch and throws [IllegalStateException]. - * the returned error afterwards. Code [AIMUX_OK] or a code outside - * 1..13 / 15..17 is a header/library mismatch and throws - * [IllegalStateException]. + * 1..17 is a header/library mismatch and throws [IllegalStateException]. */ @JvmStatic internal fun fromC(error: Pointer, prefix: String = ""): AimuxException { @@ -184,13 +180,16 @@ sealed class AimuxException( json?.let(Json::parseToJsonElement) } catch (_: Exception) { null + } + /** Response headers arrive as one JSON object string of string→string pairs. */ private fun headerMap(json: String?): Map? = (parseJson(json) as? JsonObject)?.mapValues { (_, value) -> (value as? JsonPrimitive)?.content ?: value.toString() + } + /** - * Build the subclass for a core / C error code (1..14). - * Build the subclass for a core / C error code (1..13 / 15..17). + * Build the subclass for a core / C error code (1..17). * * Any other code — [AIMUX_OK] on a failure path or a code this binding * does not know — is a header/library mismatch and throws diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt index 4cd8a20e..598ba125 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt @@ -8,8 +8,7 @@ * Errors: every fallible C call returns an `aimux_error_t *` ([Pointer]?): * null = success, result written to the trailing out-parameter * ([LongByReference] for handles, [PointerByReference] for JSON strings); - * non-null = failure. Its unified code identifies [AimuxException] (1..14), - * non-null = failure. Its unified code identifies [AimuxException] (1..13 / 15..17), + * non-null = failure. Its unified code identifies [AimuxException] (1..17), * [RecordingException] (100..105), or a C ABI failure (200..206). The last * range maps to `IllegalStateException("aimux ffi: …")`. A decoder releases * the returned pointer with `aimux_error_free`. @@ -264,8 +263,7 @@ private fun ffiError(e: Pointer, prefix: String): IllegalStateException { } /** - * Decode an error from a call that may return `AiMuxError`: 1..14 → - * Decode an error from a call that may return `AiMuxError`: 1..13 / 15..17 → + * Decode an error from a call that may return `AiMuxError`: 1..17 → * [AimuxException]; 200..206 → [IllegalStateException]. Frees [e]. */ internal fun expectAimuxError(e: Pointer, context: String = ""): RuntimeException { diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt index 56700f0f..993011c3 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt @@ -8,8 +8,7 @@ * JSON strings (base64 for binary), matching the C ABI wire format. * * Every fallible C call returns an `aimux_error_t *` (null = success) that - * [expectAimuxError] decodes codes 1..14 as [AimuxException] and 200..206 as - * [expectAimuxError] decodes codes 1..13 / 15..17 as [AimuxException] and 200..206 as + * [expectAimuxError] decodes codes 1..17 as [AimuxException] and 200..206 as * [IllegalStateException] `"aimux ffi: …"` (malformed raw JSON is * caught before the C call by [requireJson] as [IllegalArgumentException]). * No JSON envelope on the primary path. diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt index 85f25d2b..37a10f71 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt @@ -215,14 +215,13 @@ class ErrorsTest { assertThat(noKey.retryable).isFalse() } - /** A code outside 1..14 is a header/library mismatch, not an error type. */ /** A code outside the enum is a header/library mismatch, not an error type. */ @Test fun `createByCode rejects codes outside the enum`() { assertThatThrownBy { AimuxException.createByCode(999, "?") } .isInstanceOf(IllegalStateException::class.java) - // 15 is the first unassigned value and is rejected. - assertThatThrownBy { AimuxException.createByCode(15, "?") } + // 18 is the first unassigned value and is rejected. + assertThatThrownBy { AimuxException.createByCode(18, "?") } .isInstanceOf(IllegalStateException::class.java) assertThatThrownBy { AimuxException.createByCode(AIMUX_OK, "?") } .isInstanceOf(IllegalStateException::class.java) diff --git a/bindings/swift/Sources/Aimux/Aimux.swift b/bindings/swift/Sources/Aimux/Aimux.swift index c9ae6fdd..9ed80dcd 100644 --- a/bindings/swift/Sources/Aimux/Aimux.swift +++ b/bindings/swift/Sources/Aimux/Aimux.swift @@ -11,8 +11,7 @@ import Foundation // // Every fallible C function returns `aimux_error_t *` (`OpaquePointer?`): // NULL = success (result in the trailing out-param), non-NULL = failure. The -// unified code is AiMuxError (1...14), RecordingError (100...105), or a C ABI -// unified code is AiMuxError (1...13, 15...17), RecordingError (100...105), or a C ABI +// unified code is AiMuxError (1...17), RecordingError (100...105), or a C ABI // failure (200...206). The three `expect*` decoders copy the relevant fields, release // it with `aimux_error_free` (exactly once) and return the Swift error // to throw. Errors are not handles: never `aimux_drop_handle` one. @@ -45,8 +44,7 @@ func expectFfiError(_ e: OpaquePointer, context: String) -> any Error { return invariant("aimux ffi: \(context): \(message)") } -/// Decode a returned error from an `[AiMuxError]` call: 1...14 become -/// Decode a returned error from an `[AiMuxError]` call: 1...13 / 15...17 becomes +/// Decode a returned error from an `[AiMuxError]` call: 1...17 becomes /// `AimuxError`; 200...206 is decoded by `expectFfiError`. Frees `e` once. func expectAimuxError(_ e: OpaquePointer, context: String) -> any Error { let code = aimux_error_code(e) @@ -121,8 +119,7 @@ public enum RetryErrorReason: String, Equatable, Sendable { /// Structured aimux failure type (Swift `Error`). /// -/// Maps 1:1 from the 14 core `AiMuxError` variants. Every HTTP-shaped failure -/// Maps 1:1 from the 15 core `AiMuxError` variants. Every HTTP-shaped failure +/// Maps 1:1 from the 16 core `AiMuxError` variants. Every HTTP-shaped failure /// is `.apiCall` (`AIMUX_E_API_CALL`). Only aimux-core produces these: a /// binding-local failure (raw JSON that does not parse, a typed value that /// fails to encode, library output that fails to decode) surfaces as the diff --git a/docs/api/c.md b/docs/api/c.md index d8ec3572..8a6e66a2 100644 --- a/docs/api/c.md +++ b/docs/api/c.md @@ -71,7 +71,7 @@ The code range identifies the source: | Range | Meaning | |---|---| -| `1..14` | `AiMuxError` (14 = `Retry`) | +| `1..17` | `AiMuxError` (4 retired, 14 = `Retry`) | | `100..105` | `RecordingError` | | `200..206` | failure detected by the C ABI | @@ -139,19 +139,10 @@ it with these same getters — it can itself be any AiMuxError code, including `aimux_error_free` independently of the parent (free order is unconstrained). -The unified `aimux_error_code_t` extends the AiMuxError values to 1–14 -(`AIMUX_E_RETRY` = 14 reclaims the slot the pre-unification `Other` vacated — -the opaque-pointer ABI break means no old caller can misread it), adds -RecordingError values 100–105, and assigns C ABI -failures 200–206: -`NULL_POINTER`, `INVALID_UTF8`, `INVALID_WIRE_JSON`, `INVALID_HANDLE`, -`REENTRANT_CALL`, `RESULT_SERIALIZATION`, and `CALLBACK_FAILURE`. Values are -never renumbered or reused. A code outside the enum is a header/library -mismatch. -The unified `aimux_error_code_t` maps AiMuxError variants to values 1–13 and -15–17 (`AIMUX_E_NO_SUCH_TOOL` 15, `AIMUX_E_INVALID_TOOL_INPUT` 16, +The unified `aimux_error_code_t` maps AiMuxError variants to values 1–17 +(`AIMUX_E_RETRY` 14, `AIMUX_E_NO_SUCH_TOOL` 15, `AIMUX_E_INVALID_TOOL_INPUT` 16, `AIMUX_E_TOOL_CALL_REPAIR` 17; 4 is retired — the legacy catch-all `Tool` -variant, never produced — and 14 is reserved), adds RecordingError values +variant, never produced), adds RecordingError values 100–105, and assigns C ABI failures 200–206: `NULL_POINTER`, `INVALID_UTF8`, `INVALID_WIRE_JSON`, `INVALID_HANDLE`, `REENTRANT_CALL`, `RESULT_SERIALIZATION`, and `CALLBACK_FAILURE`. Values are never renumbered diff --git a/docs/api/flutter.md b/docs/api/flutter.md index d46ccaf4..ce1aac6f 100644 --- a/docs/api/flutter.md +++ b/docs/api/flutter.md @@ -76,8 +76,7 @@ shares a base with the other (both just `implements Exception`): | Source | Rust | Dart | C code | |---|---|---|---| -| AiMux | `AiMuxError` | `AimuxException` hierarchy | 1..14 | -| AiMux | `AiMuxError` | `AimuxException` hierarchy | 1..13, 15..17 (4 retired, 14 reserved) | +| AiMux | `AiMuxError` | `AimuxException` hierarchy | 1..17 (4 retired) | | recorder | `RecordingError` | `RecordingException` | 100..105 | Every fallible C call returns an opaque `aimux_error_t *` (`NULL` = @@ -85,8 +84,7 @@ success, result in the trailing out-parameter). The binding has one decoder (`errors.dart`): `expectAimuxError(e, context)` for model calls, `expectRecordingError(e, context)` for `initRecording` / `recordingTryFlush`, `expectFfiError(e, context)` for utilities that can only fail in the C ABI. -One unified code selects 1..14, 100..105, or 200..206; each decoder copies the -One unified code selects 1..13 / 15..17, 100..105, or 200..206; each decoder copies the +One unified code selects 1..17, 100..105, or 200..206; each decoder copies the relevant fields, releases the error with `aimux_error_free` exactly once, and throws the matching `AimuxException` subclass / `RecordingException`. Codes 200..206 throw the native diff --git a/docs/api/go.md b/docs/api/go.md index 4360d68b..4c2dee9c 100644 --- a/docs/api/go.md +++ b/docs/api/go.md @@ -64,11 +64,8 @@ if err != nil { | `ToolInput` | `CodeInvalidToolInput` payload: the raw argument text the model produced | | `OriginalError` | `CodeToolCallRepair` payload: the pre-repair failure as `json.RawMessage` wire JSON (same encoding as `ToolCall.Error`) | -`Code` values 1..14 mirror aimux-core's `AiMuxError` variants. A code outside -the enum is a header/library mismatch and fails with a `panic`, not an error -type. -`Code` values 1..13 and 15..17 mirror aimux-core's `AiMuxError` variants; 4 is -retired (the legacy `Tool` variant) and 14 is reserved. The tool-call variants +`Code` values 1..17 mirror aimux-core's `AiMuxError` variants; 4 is retired +(the legacy `Tool` variant) and 14 is `CodeRetry`. The tool-call variants arrive as `CodeNoSuchTool` (15), `CodeInvalidToolInput` (16), and `CodeToolCallRepair` (17). A code outside the enum is a header/library mismatch and fails with a `panic`, not an error type. @@ -103,9 +100,7 @@ no Go type of their own; the binding maps them to native Go errors: Decoder: every fallible C call returns an opaque `aimux_error_t *` (NULL = success, result in the out-parameter). One `aimux_error_code()` distinguishes -`AiMuxError` (1–14), `RecordingError` (100–105), and C ABI failures (200–206). -`AiMuxError` (1–13, 15–17), `RecordingError` (100–105), and C ABI failures -(200–206). +`AiMuxError` (1–17), `RecordingError` (100–105), and C ABI failures (200–206). `expectAimuxError`, `expectRecordingError`, and `expectFfiError` enforce the range expected by each call; the first two restore `*Error` and `*RecordingError`, while 200–206 becomes a plain `error`. Every path frees the @@ -122,8 +117,7 @@ design** — that one is opt-in, and every one of its five entry points has a |------------|--------------------------| | `aimux.go` `mustNew` — behind `OpenAI` / `OpenAIWithBase` / `Anthropic` / `AnthropicWithBase` / `DeepSeek` | **Yes, by design.** `regexp.MustCompile` convention: an `apiKey` / `modelID` / `baseURL` that is not valid UTF-8 or contains a NUL panics, as does any AiMuxError failure. Use `NewOpenAI` / `NewOpenAIWithBase` / `NewAnthropic` / `NewAnthropicWithBase` / `NewDeepSeek` for anything caller-supplied | | `aimux.go` `InitLogging` — `expectFfiError` returned an error | No. `level` is coerced first: empty, non-UTF-8, or NUL-bearing falls back to `"warn"`, which is what aimux-core does with an unparseable level anyway (`AIMUX_LOG` / `AIMUX_LOG_LEVEL` outrank it regardless). That leaves no documented failure for `aimux_init_logging`, so a non-nil error here is a header/library mismatch | -| `aimux.go` `expectAimuxError` — `aimux_error_code_t` outside 1..14 | No. Header/library version mismatch | -| `aimux.go` `expectAimuxError` — `aimux_error_code_t` outside 1..13, 15..17 | No. Header/library version mismatch | +| `aimux.go` `expectAimuxError` — `aimux_error_code_t` outside 1..17 | No. Header/library version mismatch | | `aimux.go` `expectRecordingError` — `aimux_error_code_t` outside the enum | No. Header/library version mismatch | | `multimodal.go` `TranscriptionSession.NextPart` — unknown `aimux_transcription_next_part` state | No. Header/library version mismatch | diff --git a/docs/api/java.md b/docs/api/java.md index 3cf6250d..5522cf0a 100644 --- a/docs/api/java.md +++ b/docs/api/java.md @@ -87,8 +87,7 @@ Every instance has: | Field | Meaning | |-------|---------| | `getMessage()` | human-readable text from C | -| `getCode()` | `aimux_error_code_t` value 1–14, where 14 = `Retry` (matches `aimux-error.h`) | -| `getCode()` | `aimux_error_code_t` value 1–13 or 15–17 (4 retired, 14 reserved; matches `aimux-error.h`) | +| `getCode()` | `aimux_error_code_t` value 1–17 (4 retired, 14 = `Retry`; matches `aimux-error.h`) | | `getStatusCode()` | HTTP status, or `-1` | | `getRetryMs()` | rate-limit hint, or `-1` (`0` = retry now) | | `isRetryable()` | the `AiMuxError` retry verdict (not derivable from status) | @@ -154,8 +153,7 @@ types and share no base beyond `RuntimeException`. Transport: every fallible C call returns an opaque `aimux_error_t *` (JNA `Pointer`) — `null` on success with the result in a trailing out-parameter (`LongByReference` handle / `PointerByReference` JSON), non-null on failure. -`AimuxResult` reads one unified code: 1–14 restores the matching -`AimuxResult` reads one unified code: 1–13 / 15–17 restores the matching +`AimuxResult` reads one unified code: 1–17 restores the matching `AimuxException` subclass, 100–105 restores `RecordingException`, and 200–206 becomes `IllegalStateException("aimux ffi: …")`. Payload getters are read only under their owning AiMuxError code; a `RetryError`'s attempt errors are new diff --git a/docs/api/kotlin.md b/docs/api/kotlin.md index 07fc7066..227f6c4c 100644 --- a/docs/api/kotlin.md +++ b/docs/api/kotlin.md @@ -56,8 +56,7 @@ Two aimux exception types, each mirroring its own Rust type — **AiMux** (`AimuxException`) and **recorder** (`RecordingException`). They share no base beyond `RuntimeException`; catch each on its own. Every fallible C call returns an `aimux_error_t *` (null = success, result in the out-parameter). The -binding reads one unified code: 1..14 restores an `AimuxException` subclass, -binding reads one unified code: 1..13 / 15..17 restores an `AimuxException` subclass, +binding reads one unified code: 1..17 restores an `AimuxException` subclass, 100..105 restores `RecordingException`, and 200..206 becomes `IllegalStateException("aimux ffi: …")`. Payload getters are read only under their owning AiMuxError code. @@ -108,8 +107,7 @@ try { | Field | Meaning | |-------|---------| -| `code` | `AIMUX_E_*` matching C `aimux_error_code_t` (1..14, where 14 = `Retry`; 1 is the catch-all `Other`) | -| `code` | `AIMUX_E_*` matching C `aimux_error_code_t` (1..13 and 15..17; 1 is the catch-all `Other`; 4 is retired — the legacy `Tool` variant — and 14 is reserved) | +| `code` | `AIMUX_E_*` matching C `aimux_error_code_t` (1..17; 1 is the catch-all `Other`; 4 is retired — the legacy `Tool` variant; 14 = `Retry`) | | `status` | HTTP status when known; otherwise `-1` | | `retryMs` | Rate-limit hint in ms; `-1` if none; `0` = retry immediately | diff --git a/docs/api/swift.md b/docs/api/swift.md index e455adea..a43867ff 100644 --- a/docs/api/swift.md +++ b/docs/api/swift.md @@ -107,8 +107,7 @@ have no Swift type — see "C ABI failures" below. Every fallible C function returns an opaque `aimux_error_t *` (`OpaquePointer?`): `NULL` = success (the result is in the trailing out-parameter), non-`NULL` = failure. One unified code selects `AimuxError` -(1...14), `RecordingError` (100...105), or a C ABI failure (200...206). -(1...13 and 15...17; 4 retired, 14 reserved), `RecordingError` (100...105), +(1...17; 4 retired), `RecordingError` (100...105), or a C ABI failure (200...206). The three decoders enforce the range expected by each call and restore the Swift error type; 200...206 collapses to `DecodingError.dataCorrupted`. diff --git a/docs/error-model.md b/docs/error-model.md index ddb55459..151e84e4 100644 --- a/docs/error-model.md +++ b/docs/error-model.md @@ -20,7 +20,7 @@ - C ABI 成功时返回 `NULL` 并写入出参;失败时返回一个由调用方释放一次的 不透明 `aimux_error_t *`。 - Go、Java、Kotlin、Swift 和 Flutter 将 C 错误还原为本语言错误。错误码 - `1..14` 属于核心,`100..105` 属于录制,`200..206` 属于绑定边界。 + `1..17` 属于核心,`100..105` 属于录制,`200..206` 属于绑定边界。 结构化字段只放在真正拥有它的错误上。例如,HTTP 状态和响应头属于 `APICallError`,不放进通用基类(retry hint 与 request id 从 `response_headers` 读取)。