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/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/error.rs b/aimux-core/src/error.rs index d8420941..e5796b5a 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -178,8 +178,36 @@ 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>, + /// 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. + #[error("Invalid input for tool {tool_name}: {cause}")] + InvalidToolInput { + tool_name: String, + tool_input: String, + cause: String, + }, + + /// The repair callback failed or returned a call that was still invalid. + #[error("Error repairing tool call: {cause}")] + ToolCallRepair { + original_error: Box, + cause: Box, + }, #[error("invalid argument: {0}")] InvalidArgument(String), @@ -228,6 +256,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..af6660bf 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -16,12 +16,12 @@ 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::parse_tool_call::{RawToolCall, ToolCallRepair, parse_tool_call}; use crate::result::{ FilePart, GenerateContent, GenerateResult, ReasoningPart, SourcePart, StreamResult, StreamTextResultAggregated, @@ -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 { @@ -306,8 +311,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(); @@ -320,7 +323,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 rm = crate::response_messages::ResponseMessageBuilder::new(); let mut saw_output = false; let mut saw_finish = false; @@ -345,59 +348,74 @@ impl StreamTextResult { saw_output = true; } match part { - StreamPart::TextDelta { delta, .. } => { + StreamPart::TextStart { + provider_metadata, .. + } => rm.text_start(provider_metadata), + StreamPart::TextDelta { + delta, + provider_metadata, + .. + } => { text.push_str(&delta); - // Accumulate for response_messages lazily (see Finish below). - } - StreamPart::ReasoningDelta { delta, .. } => { - reasoning_text_buf.push_str(&delta); + rm.text_delta(&delta, provider_metadata); } + StreamPart::TextEnd { + provider_metadata, .. + } => rm.text_end(provider_metadata), + StreamPart::ReasoningStart { + provider_metadata, .. + } => rm.reasoning_start(provider_metadata), + StreamPart::ReasoningDelta { + delta, + provider_metadata, + .. + } => rm.reasoning_delta(&delta, 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, - }); - } - } + } => rm.reasoning_end(provider_metadata), StreamPart::ToolCall { 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, - thought_signature: thought_signature.clone(), - }); - // Defer adding to response_content_parts — order is rebuilt - // after the loop (reasoning → text → tool_calls). + let call = crate::tool::ToolCall { + tool_call_id, + tool_name, + input, + provider_executed, + dynamic, + thought_signature, + provider_metadata, + invalid, + error, + }; + rm.tool_call(&call); + tool_calls.push(call); } + StreamPart::ToolResult { + tool_call_id, + tool_name, + result, + is_error, + preliminary, + dynamic, + provider_metadata, + } => rm.tool_result( + tool_call_id, + tool_name, + result, + is_error, + preliminary, + dynamic, + provider_metadata, + ), StreamPart::Source { id, source_type, @@ -425,18 +443,6 @@ 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(); - } raw_finish_reason = fr.raw.clone(); finish_reason = fr; usage = u.clone(); @@ -483,31 +489,10 @@ 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: tc.input.clone(), - thought_signature: tc.thought_signature.clone(), - provider_options: None, - }); - } - 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() @@ -567,7 +552,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. @@ -652,72 +640,56 @@ 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(); - 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 { text: t, .. } => { + GenerateContent::Text { + text: t, + provider_metadata, + } => { text.push_str(t); - response_content_parts.push(ContentPart::Text { - text: t.clone(), - provider_options: None, - }); + rm.text(t, provider_metadata.as_ref()); } GenerateContent::ToolCall { 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(), - }); - 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, - }); + let parsed = parse_tool_call( + RawToolCall { + tool_call_id: tool_call_id.clone(), + tool_name: tool_name.clone(), + input: input.clone(), + 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; + 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, @@ -741,21 +713,49 @@ 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, + } => { + 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()) .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), + ); + } // Extract fields before moving `result` into `raw`. let raw_finish_reason = result.finish_reason.raw.clone(); @@ -859,20 +859,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 @@ -910,7 +896,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 +1096,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: crate::parse_tool_call::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(), @@ -1139,7 +1175,6 @@ impl Drop for AbortOnDrop { self.0.abort(); } } - /// 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. @@ -1183,8 +1218,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. @@ -1192,6 +1228,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. @@ -1223,7 +1261,32 @@ 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, + tool_call.error.as_ref(), + ), + }, + }) + .collect(), + ) + }; + } + Ok(completion) } /// Stream text and return the result as a stream of OpenAI Chat Completion chunks. @@ -1270,12 +1333,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/lib.rs b/aimux-core/src/lib.rs index cbd5fc80..adcf2c35 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -35,10 +35,12 @@ 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; pub mod reranking_model; +pub(crate) mod response_messages; pub mod result; pub mod retry; pub mod router; @@ -85,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, 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..6e8fdfb6 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -245,10 +245,13 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio input, .. } => { - let arguments = if input.is_null() { + // 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.trim().is_empty() { "{}".to_string() } else { - input.to_string() + input.clone() }; tool_calls.push(ChatCompletionToolCall { id: tool_call_id.clone(), @@ -405,13 +408,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; @@ -469,6 +495,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, @@ -479,10 +507,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, @@ -492,6 +522,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, @@ -615,40 +646,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; @@ -659,6 +696,7 @@ impl StreamState { index: idx, id: id.clone(), name: String::new(), + arguments: delta.clone(), }, ); self.tool_call_order.push(id.clone()); @@ -666,25 +704,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 { .. } => {} @@ -692,13 +732,98 @@ impl StreamState { tool_call_id, tool_name, input, + invalid, + error, .. } => { // 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, error.as_ref()); + 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, + error.as_ref(), + ), + }, + ); + 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, error.as_ref()); + 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); @@ -711,16 +836,13 @@ impl StreamState { index, id: tool_call_id.clone(), name: tool_name.clone(), + arguments: parsed_tool_call_arguments(input, *invalid, error.as_ref()), }, ); 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 { @@ -971,6 +1093,47 @@ fn now_unix() -> u64 { .unwrap_or(0) } +// 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 { tool_input, .. } => tool_input.clone(), + AiMuxError::ToolCallRepair { original_error, .. } => raw_tool_call_text(original_error), + _ => 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. +/// +/// 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, + error: Option<&AiMuxError>, +) -> String { + if invalid == Some(true) + && let Some(error) = error + && 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 + // "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. + 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. @@ -1049,7 +1212,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, @@ -1079,13 +1242,156 @@ 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: r#"{"city":"Tokyo"}"#.to_string(), + 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 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 + // constraint rejected it) must round-trip as `null`, not `{}`. + let arguments = parsed_tool_call_arguments(&Value::Null, None, None); + assert_eq!(arguments, "null"); + } + + /// 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 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 tools = [crate::tool::Tool::Function(crate::tool::FunctionTool::new( + "get_weather", + json!({"type": "object", "properties": {}, "additionalProperties": false}), + ))]; + + 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, + provider_executed: parsed.provider_executed, + dynamic: parsed.dynamic, + thought_signature: parsed.thought_signature, + invalid: parsed.invalid, + error: parsed.error, + provider_metadata: parsed.provider_metadata, + })]; + 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] fn test_tool_call_null_content() { // Tool call with no text → content should be null. 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, @@ -1269,6 +1575,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, @@ -1334,6 +1651,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/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs new file mode 100644 index 00000000..89890d87 --- /dev/null +++ b/aimux-core/src/parse_tool_call.rs @@ -0,0 +1,320 @@ +//! 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, + tool_input: Some(tool_call.input.clone()), + }) + }; + 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, + AiMuxError::ToolCallRepair { + original_error: Box::new(original_error), + cause: Box::new(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(), + tool_input: Some(tool_call.input.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 { + // 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, + 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/replay.rs b/aimux-core/src/replay.rs index 46b88965..79c599fe 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/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/result.rs b/aimux-core/src/result.rs index 5c498ad8..c9d451aa 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -14,6 +14,26 @@ 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 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>, +{ + 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)] @@ -28,7 +48,17 @@ pub enum GenerateContent { ToolCall { tool_call_id: String, tool_name: String, - input: serde_json::Value, + /// 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 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. /// If false/unset, the tool call is executed by the client. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -219,3 +249,40 @@ 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` (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_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": input, + } + }) + }; + 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); + + let GenerateContent::ToolCall { input, .. } = &from_legacy else { + panic!("expected ToolCall, got {from_legacy:?}"); + }; + assert_eq!(input, r#"{"city":"Tokyo"}"#); + assert_eq!( + serde_json::to_value(&from_legacy).unwrap(), + wire(serde_json::json!(r#"{"city":"Tokyo"}"#)) + ); + } +} 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..7c1176da 100644 --- a/aimux-core/src/tool.rs +++ b/aimux-core/src/tool.rs @@ -6,6 +6,9 @@ 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)] @@ -106,7 +109,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 +124,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/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 b3161f78..8df2b9aa 100644 --- a/aimux-core/tests/error_value_golden_test.rs +++ b/aimux-core/tests/error_value_golden_test.rs @@ -105,9 +105,42 @@ 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()]), + tool_input: Some(r#""hello""#.into()), + }, + 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"}}"#, + ), + ( + 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, + tool_input: 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 +186,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 +227,24 @@ 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, + tool_input: 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, + tool_input: None, + }), + cause: Box::new(AiMuxError::Other("x".into())), + }, AiMuxError::InvalidArgument("x".into()), AiMuxError::InvalidPrompt("x".into()), AiMuxError::TokenExpired("x".into()), @@ -202,14 +279,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" diff --git a/aimux-core/tests/m7_aggregation_test.rs b/aimux-core/tests/m7_aggregation_test.rs index f7411c30..51fecfe5 100644 --- a/aimux-core/tests/m7_aggregation_test.rs +++ b/aimux-core/tests/m7_aggregation_test.rs @@ -41,7 +41,7 @@ impl LanguageModel for RichModel { GenerateContent::ToolCall { tool_call_id: "tc-1".into(), tool_name: "search".into(), - input: serde_json::json!({ "q": "test" }), + input: r#"{"q":"test"}"#.to_string(), provider_executed: None, dynamic: None, thought_signature: None, 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..f93a2c8e --- /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: r#"{"query":"Rust"}"#.to_string(), + 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: "{".to_string(), + 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 new file mode 100644 index 00000000..0077989e --- /dev/null +++ b/aimux-core/tests/tool_input_test.rs @@ -0,0 +1,718 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use aimux_core::error::AiMuxError; +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::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, Tool}; +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", r#"{"city":"Tokyo"}"#), + Some(&[weather_tool()]), + None, + &[], + None, + ) + .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!( + 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::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}"#) + )); +} + +#[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 { + 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 { + 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: self.tool_name.into(), + input: self.input.to_string(), + 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 { + 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, + title: None, + provider_metadata: None, + }), + Ok(StreamPart::ToolInputDelta { + id: "call-1".into(), + delta: self.input.into(), + provider_metadata: None, + }), + 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, + }) + } +} + +#[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( + &model, + "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 model = RawToolModel::new(r#"{"city":"Singapore"}"#); + let mut result = stream_text( + &model, + "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"); +} + +#[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"}"#) + ); +} + +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 5cc92bf0..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 @@ -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 / 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 + * 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/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 0eb72557..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. //! @@ -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,65 @@ 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` / `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(), + ) +} + +/// `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 +3330,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()); @@ -3512,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())], @@ -3620,7 +3686,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 +3711,33 @@ 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, + tool_input: 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, + tool_input: None, + }), + cause: Box::new(AiMuxError::Other(s("x"))), + }, + AIMUX_E_TOOL_CALL_REPAIR, + ), ( AiMuxError::InvalidArgument(s("x")), AIMUX_E_INVALID_ARGUMENT, @@ -3683,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/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/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 7a53b146..604222d7 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,101 @@ 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, + } +} + +/// 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) + { + // 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 +} + +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 +395,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 +486,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 +540,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 +569,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(), - input: input.clone(), + tool_name: names.to_custom_tool_name(name).to_string(), + input: 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 +599,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: input.clone(), - provider_executed: None, - dynamic: None, + tool_name: names.to_custom_tool_name(provider_name).to_string(), + input: normalized_server_tool_input(name, input).to_string(), + provider_executed: Some(true), + dynamic: (provider_name == "code_execution" + && names.mark_code_execution_dynamic()) + .then_some(true), thought_signature: None, provider_metadata: None, }); @@ -515,7 +622,7 @@ pub(crate) fn parse_anthropic_content( content.push(GenerateContent::ToolCall { tool_call_id: id.clone(), tool_name: name.clone(), - input: input.clone(), + input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), thought_signature: None, @@ -644,11 +751,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 +892,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 +973,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 +1011,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,21 +1031,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: input.clone(), + 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, + 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, }); } @@ -929,10 +1091,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", @@ -968,6 +1132,7 @@ pub(crate) async fn anthropic_stream_core( &other, &tool_names, &mcp_tool_calls, + &server_tool_calls, ) { yield Ok(part); } @@ -1008,21 +1173,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, }); } @@ -1107,27 +1288,33 @@ pub(crate) async fn anthropic_stream_core( id, name, accumulated_json, + provider_executed, + dynamic, + provider_tool_name, + provider_tool_input_type, + provider_metadata, + .. } => { 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!({})) - }; + 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, input, - provider_executed: None, - dynamic: None, + provider_executed, + dynamic, thought_signature: None, - provider_metadata: None, + invalid: None, + error: 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/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index d7ad90f0..b1c0c96e 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) -> 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 62a303f7..aaa923a5 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(); @@ -431,10 +435,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; @@ -450,21 +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(), - input: ec.clone(), - provider_executed: None, + 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, - provider_metadata: None, + invalid: None, + error: 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)); @@ -474,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") { @@ -506,10 +524,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 @@ -655,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 @@ -665,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; @@ -698,17 +736,18 @@ fn extract_content_from_candidate(candidate: &Candidate) -> (Vec (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 = arguments.to_string(); content.push(GenerateContent::ToolCall { tool_call_id: call_id.to_string(), tool_name: name.to_string(), @@ -1160,14 +1158,13 @@ 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 = arguments.to_string(); content.push(GenerateContent::ToolCall { tool_call_id: id.to_string(), tool_name: name.to_string(), input, - provider_executed: None, - dynamic: None, + provider_executed: Some(true), + dynamic: Some(true), thought_signature: None, provider_metadata: None, }); @@ -1179,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, }); } @@ -1191,9 +1188,9 @@ 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, - dynamic: None, + input: json!({ "server_label": server_label }).to_string(), + provider_executed: Some(true), + dynamic: Some(true), thought_signature: None, provider_metadata: None, }); @@ -1205,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/mistral/model.rs b/aimux-providers/src/mistral/model.rs index 547a877a..52d63e87 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 = 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..340237e5 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 = 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..6445d52c 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 = 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..ea51c247 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 = arguments; content.push(GenerateContent::ToolCall { tool_call_id: call_id, tool_name: name, @@ -218,8 +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 = serde_json::from_str(input_str) - .unwrap_or_else(|_| Value::String(input_str.to_string())); + 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, @@ -794,10 +793,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 +801,8 @@ where provider_executed: None, dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } @@ -829,10 +827,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 +839,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..f707441e 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::{ + 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; 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,20 +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: input.clone(), + 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, + 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, }); } @@ -340,10 +389,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", @@ -366,6 +417,7 @@ impl LanguageModel for VertexAnthropicModel { &other, &tool_names, &mcp_tool_calls, + &server_tool_calls, ) { yield Ok(part); } @@ -395,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, }); } @@ -460,24 +527,30 @@ impl LanguageModel for VertexAnthropicModel { id, name, accumulated_json, + provider_executed, + dynamic, + provider_tool_name, + provider_tool_input_type, + provider_metadata, + .. } => { 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!({})) - }; + 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, input, - provider_executed: None, - dynamic: None, + provider_executed, + dynamic, thought_signature: None, - provider_metadata: None, + invalid: None, + error: None, + provider_metadata, }); } } @@ -552,6 +625,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 2b8b5d91..e151adfd 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,23 +421,28 @@ 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(), - input: args, + input: Value::String(args.to_string()), provider_executed: None, dynamic: None, thought_signature, - provider_metadata: None, + invalid: None, + error: None, + provider_metadata: tool_metadata, }); has_tool_calls = true; } else if let Some(ec) = part.get("executableCode") { @@ -434,21 +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(), - input: ec.clone(), - provider_executed: None, + 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, - provider_metadata: None, + invalid: None, + error: 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)); @@ -458,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") { @@ -481,18 +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: args, - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, + input: Value::String(args.to_string()), + provider_executed: Some(true), + dynamic: Some(true), + thought_signature, + invalid: None, + error: 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(|| { @@ -504,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), }); } } @@ -559,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 { @@ -597,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: 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") { @@ -630,16 +760,68 @@ fn extract_content_from_candidate(candidate: &Candidate) -> (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 70a85b07..e8beb368 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -135,8 +135,8 @@ 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()), - provider_executed: None, + input: String::new(), + provider_executed: Some(true), dynamic: None, thought_signature: None, provider_metadata: None, @@ -192,8 +192,8 @@ impl LanguageModel for XaiResponsesModel { content.push(GenerateContent::ToolCall { tool_call_id: part_id.to_string(), tool_name, - input: Value::String(tool_input), - provider_executed: None, + input: tool_input, + provider_executed: Some(true), dynamic: None, thought_signature: None, provider_metadata: None, @@ -245,8 +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 = serde_json::from_str(arguments) - .unwrap_or_else(|_| 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(), @@ -696,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, @@ -714,9 +713,11 @@ impl LanguageModel for XaiResponsesModel { tool_call_id: part_id.to_string(), tool_name: tool_name.clone(), input: Value::String(String::new()), - provider_executed: None, + provider_executed: Some(true), dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } @@ -767,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, @@ -785,9 +786,11 @@ impl LanguageModel for XaiResponsesModel { tool_call_id: part_id.to_string(), tool_name: tool_name.clone(), input: Value::String(tool_input), - provider_executed: None, + provider_executed: Some(true), dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } @@ -871,8 +874,7 @@ impl LanguageModel for XaiResponsesModel { id: call_id.to_string(), provider_metadata: None, }); - let input: Value = serde_json::from_str(arguments) - .unwrap_or_else(|_| Value::String(arguments.to_string())); + let input = Value::String(arguments.to_string()); yield Ok(StreamPart::ToolCall { tool_call_id: call_id.to_string(), tool_name: name.to_string(), @@ -880,6 +882,8 @@ impl LanguageModel for XaiResponsesModel { provider_executed: None, dynamic: None, thought_signature: None, + invalid: None, + error: None, provider_metadata: None, }); } diff --git a/aimux-providers/tests/alibaba_test.rs b/aimux-providers/tests/alibaba_test.rs index 0340864b..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, }], @@ -531,7 +532,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:?}"), } 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 &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, @@ -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_cache_control_test.rs b/aimux-providers/tests/anthropic_cache_control_test.rs index 9b7e9fe8..ae63e238 100644 --- a/aimux-providers/tests/anthropic_cache_control_test.rs +++ b/aimux-providers/tests/anthropic_cache_control_test.rs @@ -237,6 +237,7 @@ mod assistant_message { tool_call_id: "test-id".to_string(), tool_name: "test-tool".to_string(), input: json!({ "some": "arg" }), + provider_executed: None, thought_signature: None, provider_options: Some(cache_control_opts(json!({ "type": "ephemeral" }))), }], diff --git a/aimux-providers/tests/anthropic_model_test.rs b/aimux-providers/tests/anthropic_model_test.rs index 8619ce18..d567328c 100644 --- a/aimux-providers/tests/anthropic_model_test.rs +++ b/aimux-providers/tests/anthropic_model_test.rs @@ -26,13 +26,14 @@ 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::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::message::{MessageContent, Role}; use aimux_core::options::{CallOptions, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; +use aimux_core::tool::{FunctionTool, ProviderTool}; use aimux_core::types::FinishReasonUnified; use aimux_providers::anthropic::AnthropicConfig; @@ -132,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, @@ -298,7 +299,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 +460,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 +503,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"]}"#) ); } @@ -783,6 +781,68 @@ mod do_generate { assert!(body.get("tools").is_none()); assert!(body.get("tool_choice").is_none()); } + + #[tokio::test] + async fn programmatic_tool_caller_reaches_the_public_result() { + let server = MockServer::start().await; + mock_json( + &server, + 200, + json!({ + "id": "msg_programmatic", + "type": "message", + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_programmatic", + "name": "test-tool", + "input": { "value": "from code" }, + "caller": { + "type": "code_execution_20250825", + "tool_id": "srvtoolu_code", + }, + }], + "model": "claude-3-haiku-20240307", + "stop_reason": "tool_use", + "usage": { "input_tokens": 100, "output_tokens": 50 }, + }), + ) + .await; + let model = make_model(&server); + + let result = generate_text( + &model, + "Run the tool", + GenerateTextOptions { + tools: Some(vec![value_tool()]), + ..GenerateTextOptions::default() + }, + ) + .await + .unwrap(); + + let metadata = result.tool_calls[0] + .provider_metadata + .as_ref() + .expect("caller metadata"); + assert_eq!( + metadata["anthropic"]["caller"], + json!({ + "type": "code_execution_20250825", + "toolId": "srvtoolu_code", + }) + ); + let response_metadata = match &result.response_messages[0].content { + MessageContent::Parts(parts) => 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)); + } } // ═════════════════════════════════════════════════════════════════════════════ @@ -1113,7 +1173,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 +1188,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 @@ -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. @@ -1192,7 +1437,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 +1592,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 +1641,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 ────────────────────────────────────────────── @@ -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/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..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, @@ -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..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, @@ -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..c61f75f5 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: @@ -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; @@ -264,7 +265,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 +277,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 +325,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 +552,13 @@ 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"})); + // 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()) + ); // Verify the accumulated deltas. let deltas: Vec = parts @@ -570,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() { @@ -1477,8 +1561,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/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 1cbf5b30..3810d3c0 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,22 @@ 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 = + serde_json::from_str(input).unwrap_or_else(|_| Value::String(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 +562,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 +885,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); @@ -926,7 +933,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] @@ -1023,7 +1033,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..e269d81e 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(), @@ -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 9e49d2f8..c05de148 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`). //! @@ -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; @@ -194,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 { @@ -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() { @@ -1140,7 +1139,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, @@ -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" }, @@ -1191,14 +1286,14 @@ 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); 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" @@ -1478,7 +1578,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); @@ -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,19 +1921,17 @@ 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" - && *input == json!({ "language": "PYTHON", "code": "print(\"hello\")" }) + name == "runCode" + && *input == json!(r#"{"language":"PYTHON","code":"print(\"hello\")"}"#) }); assert!( has_call, @@ -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] @@ -2024,7 +2190,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..52fe83bd 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: @@ -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; @@ -543,7 +544,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:?}"), } @@ -570,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" })); } // ════════════════════════════════════════════════════════════════════════════ @@ -1104,7 +1118,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 +1200,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/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/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_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 33d093e6..29556217 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; @@ -95,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, @@ -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. @@ -197,11 +205,191 @@ 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); } +#[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,15 +433,23 @@ async fn vertex_generate_tool_call_with_thought_signature() { tool_name, input, thought_signature, + provider_metadata, .. } => { 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") ); + 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:?}"), } @@ -567,7 +763,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. @@ -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!({ "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 b2f14b68..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() { @@ -1509,7 +1591,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:?}"), } @@ -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 @@ -2003,7 +2112,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. @@ -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/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..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..14), 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,9 +22,8 @@ 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 +/// 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 /// by [AimuxException.status] (401 auth, 404 model, 429 rate limit; @@ -34,7 +33,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 +42,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 +52,6 @@ abstract final class AimuxErrorCode { ok: 'OK', jsonParse: 'JsonParse', invalidResponseData: 'InvalidResponseData', - tool: 'Tool', invalidArgument: 'InvalidArgument', invalidPrompt: 'InvalidPrompt', tokenExpired: 'TokenExpired', @@ -61,6 +61,9 @@ abstract final class AimuxErrorCode { apiCall: 'ApiCall', timeout: 'Timeout', aborted: 'Aborted', + noSuchTool: 'NoSuchTool', + invalidToolInput: 'InvalidToolInput', + toolCallRepair: 'ToolCallRepair', other: 'Other', retry: 'Retry', }; @@ -75,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..14 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; @@ -308,6 +311,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 +481,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 +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 the published set (1..14) 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); @@ -540,6 +549,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 +590,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 +621,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 +656,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..7a8271ba 100644 --- a/bindings/flutter/lib/types.dart +++ b/bindings/flutter/lib/types.dart @@ -161,6 +161,13 @@ class ToolCall { final bool? isDynamic; @JsonKey(name: 'thought_signature') final String? thoughtSignature; + /// 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. + final dynamic error; ToolCall({ required this.toolCallId, @@ -169,6 +176,9 @@ class ToolCall { this.providerExecuted, this.isDynamic, this.thoughtSignature, + this.providerMetadata, + this.invalid, + this.error, }); factory ToolCall.fromJson(Map json) => @@ -1251,6 +1261,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 +1274,8 @@ final class StreamPartToolCall extends StreamPart { this.isDynamic, this.thoughtSignature, this.providerMetadata, + this.invalid, + this.error, }); factory StreamPartToolCall.fromJson(Map json) => @@ -1272,6 +1288,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 +1302,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, }, }; } @@ -1798,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?, ); @@ -1816,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 21013b26..a0a8f56f 100644 --- a/bindings/flutter/lib/types.g.dart +++ b/bindings/flutter/lib/types.g.dart @@ -58,6 +58,9 @@ 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'], + invalid: json['invalid'] as bool?, + error: json['error'], ); Map _$ToolCallToJson(ToolCall instance) => { @@ -67,6 +70,10 @@ Map _$ToolCallToJson(ToolCall instance) => { 'provider_executed': instance.providerExecuted, 'dynamic': instance.isDynamic, 'thought_signature': instance.thoughtSignature, + if (instance.providerMetadata != null) + 'provider_metadata': instance.providerMetadata, + 'invalid': instance.invalid, + 'error': instance.error, }; FunctionTool _$FunctionToolFromJson(Map json) => FunctionTool( 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/errors_test.dart b/bindings/flutter/test/errors_test.dart index 3a2a4ba9..07fe3833 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 18 is the first unassigned value. expect(() => AimuxException.fromCode(999, 'future'), throwsStateError); - expect(() => AimuxException.fromCode(15, 'unused'), throwsStateError); + expect(() => AimuxException.fromCode(4, 'retired'), throwsStateError); + expect(() => AimuxException.fromCode(18, 'unassigned'), throwsStateError); }); test('bare retry code synthesizes a single-attempt RetryError', () { @@ -175,11 +178,20 @@ 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–17 (4 retired). + 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..0bb43261 100644 --- a/bindings/flutter/test/typed_round_trip_test.dart +++ b/bindings/flutter/test/typed_round_trip_test.dart @@ -33,6 +33,45 @@ Map deepFlatten(Map json) => jsonDecode(jsonEncode(json)) as Map; void main() { + 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 decoded = ToolCall.fromJson(json); + expect(decoded.providerMetadata, original.providerMetadata); + }); + + 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'))); + }); + }); + // ───────────────────────────────────────────────────────────────────────── // GenerateContent (6 variants + Unknown) // ───────────────────────────────────────────────────────────────────────── diff --git a/bindings/go/aimux.go b/bindings/go/aimux.go index 7d0d671b..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,7 +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..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 +1226,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..ac858cbb 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). 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..091ea918 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":{"itemId":"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..cc75fe58 100644 --- a/bindings/go/types.go +++ b/bindings/go/types.go @@ -109,6 +109,12 @@ 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"` + // 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/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/AimuxException.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java index db259467..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–14). + *

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,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–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. @@ -43,8 +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 + // 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. @@ -52,7 +51,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 +60,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; @@ -70,7 +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 16 subclass constructors keep // their public signatures. private boolean retryable; @@ -102,7 +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–17). */ public int getCode() { return code; } @@ -139,7 +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 → + * A code outside 1–17 is a header/library mismatch → * {@link IllegalStateException}. */ static AimuxException fromC(Pointer error, String prefix) { @@ -179,6 +180,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 +201,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 +298,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 +319,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 +341,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 +359,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 +388,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 +582,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..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,7 +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–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 @@ -64,7 +64,7 @@ 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}. * Frees {@code e}. */ @@ -79,8 +79,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..0795f223 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,25 @@ 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; @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, + 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; } public String getToolCallId() { return toolCallId; } @@ -425,6 +435,12 @@ 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; } + /** 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 +450,25 @@ 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; 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 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); } + public ToolCall build() { + return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, providerMetadata, + invalid, error); + } } @Override @@ -453,12 +480,17 @@ 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(thoughtSignature, that.thoughtSignature) + && 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, thoughtSignature, providerMetadata, + invalid, error); } } @@ -1239,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(); } @@ -1262,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 @@ -1280,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 { @@ -2145,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; } @@ -2165,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(); } @@ -2175,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; } @@ -2182,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); } } @@ -2199,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); } } @@ -3360,19 +3417,26 @@ 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; @JsonCreator ToolCall() {} private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean providerExecuted, - Boolean dynamic, JsonNode providerMetadata) { + 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; } public String getToolCallId() { return toolCallId; } @@ -3380,7 +3444,12 @@ 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; } + /** The typed lookup, parse, schema, or repair failure for an invalid call. */ + public JsonNode getError() { return error; } public static Builder builder() { return new Builder(); } @@ -3390,17 +3459,24 @@ 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; 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 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); + return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, + providerMetadata, invalid, error); } } @@ -3414,12 +3490,16 @@ 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(thoughtSignature, that.thoughtSignature) + && 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, thoughtSignature, + 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/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/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..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,6 +1,8 @@ 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 @@ -8,15 +10,14 @@ import kotlinx.serialization.json.JsonPrimitive /** * 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..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]. */ 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 @@ -51,7 +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..17 → [fromC]. * Primary path is not a JSON * error envelope. */ @@ -82,7 +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]. + * 1..17 is a header/library mismatch and throws [IllegalStateException]. */ @JvmStatic internal fun fromC(error: Pointer, prefix: String = ""): AimuxException { @@ -126,6 +130,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) } } @@ -163,7 +189,7 @@ sealed class AimuxException( } /** - * Build the subclass for a core / C error code (1..14). + * 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 @@ -189,10 +215,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 +248,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 +261,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 +270,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 +296,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 +434,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..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,7 +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..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 +130,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? @@ -259,7 +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..17 → * [AimuxException]; 200..206 → [IllegalStateException]. Frees [e]. */ internal fun expectAimuxError(e: Pointer, context: String = ""): RuntimeException { @@ -267,7 +271,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..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,7 +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..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..c933f344 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, ) // ───────────────────────────────────────────────────────────────────────────── @@ -385,6 +392,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 @@ -1053,6 +1061,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 +1074,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/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/ErrorsTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt index 04af9b5a..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,13 +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/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt index 890dcf27..578584ac 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,22 @@ class TypedModelTest { // ───────────────────────────────────────────────────────────────────────────── class TypedModelRoundTripTest { + @Test + fun `top-level ToolCall provider metadata round-trips`() { + val original = ToolCall( + toolCallId = "call_1", + toolName = "get_weather", + 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) + assertThat(json).contains("\"provider_metadata\"") + val decoded = AimuxJson.decodeFromString(ToolCall.serializer(), json) + assertThat(decoded).isEqualTo(original) + } + // ── GenerateContent (externally tagged) ────────────────────────────── @Test @@ -368,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/__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..bea1e27b 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,30 @@ 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, + tool_input, + } => { + 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())?; + } + } + 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..9fbf1105 100644 --- a/bindings/node/src/error.ts +++ b/bindings/node/src/error.ts @@ -78,7 +78,27 @@ 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[] + /** 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 { + /** 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 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 +} 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..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 } | { "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, +/** + * 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/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/node/src/types/GenerateContent.ts b/bindings/node/src/types/GenerateContent.ts index a64676c5..f6da7630 100644 --- a/bindings/node/src/types/GenerateContent.ts +++ b/bindings/node/src/types/GenerateContent.ts @@ -5,7 +5,19 @@ 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, +/** + * 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 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, /** * 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..ca86c99d 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): @@ -726,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 @@ -827,6 +833,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..be2165d5 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,26 @@ fn exception_instance<'py>( AiMuxError::NoSuchProvider { provider_id } => { inst.setattr("provider_id", provider_id.as_str())?; } + AiMuxError::NoSuchTool { + tool_name, + available_tools, + tool_input, + } => { + 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, + 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 +373,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_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/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..9ed80dcd 100644 --- a/bindings/swift/Sources/Aimux/Aimux.swift +++ b/bindings/swift/Sources/Aimux/Aimux.swift @@ -11,7 +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...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. @@ -44,7 +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...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) @@ -119,7 +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 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 @@ -143,7 +143,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 +170,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 +187,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 +196,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 +214,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 +224,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 +329,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 +389,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 +431,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..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")) @@ -629,6 +636,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 +650,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 } } @@ -743,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?) @@ -754,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" @@ -776,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), @@ -810,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")) @@ -1187,7 +1211,9 @@ 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?) // P2: file @@ -1207,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 + 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 @@ -1262,7 +1288,10 @@ 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)) + 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)) case "ToolResult": self = .toolResult(toolCallId: try n.decode(String.self, forKey: .toolCallId), toolName: try n.decode(String.self, forKey: .toolName), @@ -1337,12 +1366,14 @@ 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 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): 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/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 b706a1aa..3241b030 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. @@ -72,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 @@ -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/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/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..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,15 +139,20 @@ 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–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), 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..ce1aac6f 100644 --- a/docs/api/flutter.md +++ b/docs/api/flutter.md @@ -76,7 +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..17 (4 retired) | | recorder | `RecordingError` | `RecordingException` | 100..105 | Every fallible C call returns an opaque `aimux_error_t *` (`NULL` = @@ -84,7 +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..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 +97,6 @@ throws the matching `AimuxException` subclass / `RecordingException`. Codes Exception (implements) └── AimuxException ├── JSONParseError / InvalidResponseDataError - ├── ToolError ├── InvalidArgumentError / InvalidPromptError ├── TokenExpiredError // status 401 ├── UnsupportedFunctionalityError @@ -106,6 +105,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 +118,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 +269,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..f11d07b2 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,63 @@ 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. + +--- + +## 9. Wire-shape migration: `GenerateContent::ToolCall.input` + +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 | +|---|---|---| +| 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. + +`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/docs/api/go.md b/docs/api/go.md index 4e500ff0..4c2dee9c 100644 --- a/docs/api/go.md +++ b/docs/api/go.md @@ -59,10 +59,16 @@ 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..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. Recording failures are a separate type, as in Rust (`recording::RecordingError` is unrelated to `AiMuxError`): `RecordingTryFlush() error` returns @@ -94,7 +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–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 @@ -111,7 +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..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 +520,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..5522cf0a 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 ``` @@ -85,7 +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–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) | @@ -103,6 +105,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()` @@ -140,7 +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–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 +376,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..227f6c4c 100644 --- a/docs/api/kotlin.md +++ b/docs/api/kotlin.md @@ -56,7 +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..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 +74,6 @@ their owning code. RuntimeException └── AimuxException // code, status, retryMs, retryable ├── JSONParseError / InvalidResponseDataError - ├── ToolError ├── InvalidArgumentError / InvalidPromptError ├── TokenExpiredError // 401, refresh and retry ├── UnsupportedFunctionalityError @@ -83,6 +82,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 ``` @@ -105,7 +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..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 | @@ -247,6 +249,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..a43867ff 100644 --- a/docs/api/swift.md +++ b/docs/api/swift.md @@ -107,7 +107,8 @@ 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...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`. Every path copies its strings (freed with @@ -123,7 +124,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 +134,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 +153,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 +224,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/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` 读取)。 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/AiMuxError.ts b/tools/aimux-web/web/src/types/AiMuxError.ts index bd81f0a3..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 } | { "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, +/** + * 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/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