diff --git a/lib/crates/fabro-llm/src/codec/bedrock_converse/decode.rs b/lib/crates/fabro-llm/src/codec/bedrock_converse/decode.rs index 9a37cbc35..36ec5878d 100644 --- a/lib/crates/fabro-llm/src/codec/bedrock_converse/decode.rs +++ b/lib/crates/fabro-llm/src/codec/bedrock_converse/decode.rs @@ -115,7 +115,12 @@ pub(super) fn decode_content_block(block: &Value) -> Option { if let Some(tool_use) = block.get("toolUse") { let id = tool_use.get("toolUseId").and_then(Value::as_str)?; let name = tool_use.get("name").and_then(Value::as_str)?; - let input = tool_use.get("input").cloned().unwrap_or(Value::Null); + // A no-argument tool call is canonically `{}`, not null (so it + // re-encodes to a valid Converse `toolUse.input` object). + let input = match tool_use.get("input") { + Some(Value::Null) | None => Value::Object(serde_json::Map::new()), + Some(value) => value.clone(), + }; return Some(ContentPart::ToolCall(ToolCall::new(id, name, input))); } if let Some(reasoning) = block.get("reasoningContent") { diff --git a/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs b/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs index f158e7fb6..41059a153 100644 --- a/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs +++ b/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs @@ -173,13 +173,24 @@ fn encode_content_part(part: &ContentPart) -> Option { } })) } - ContentPart::ToolCall(tool_call) => Some(json!({ - "toolUse": { - "toolUseId": tool_call.id, - "name": tool_call.name, - "input": tool_call.arguments, - } - })), + ContentPart::ToolCall(tool_call) => { + // Converse requires `toolUse.input` to be a JSON object document. + // A no-argument tool call carries `Null` (the stream decoder gets + // no input fragments to parse), which Bedrock rejects as + // "toolUse.input is empty". Coerce any non-object to `{}` so the + // wire is always valid, regardless of where the call originated. + let input = match &tool_call.arguments { + Value::Object(_) => tool_call.arguments.clone(), + _ => json!({}), + }; + Some(json!({ + "toolUse": { + "toolUseId": tool_call.id, + "name": tool_call.name, + "input": input, + } + })) + } ContentPart::ToolResult(result) => { let content = match &result.content { Value::String(text) => json!([{ "text": text }]), @@ -242,7 +253,7 @@ fn encode_tool_config(request: &Request, caching: bool) -> Option { "toolSpec": { "name": tool.name, "description": tool.description, - "inputSchema": { "json": tool.parameters }, + "inputSchema": { "json": tool_input_schema(&tool.parameters) }, } }) }) @@ -269,6 +280,24 @@ fn encode_tool_config(request: &Request, caching: bool) -> Option { Some(Value::Object(config)) } +/// Normalize a tool's JSON-Schema for Bedrock's `toolSpec.inputSchema.json`. +/// Converse strictly validates the schema and requires a top-level `type`; +/// some model families (e.g. DeepSeek) reject a typeless schema that Claude +/// tolerates. Tools may arrive with a loose schema (no top-level `type`, or a +/// bare `{}` for a no-argument tool), so default the type to `object`. +fn tool_input_schema(parameters: &Value) -> Value { + match parameters { + Value::Object(map) => { + let mut map = map.clone(); + map.entry("type").or_insert_with(|| json!("object")); + Value::Object(map) + } + // A non-object schema is not a valid tool input schema; substitute the + // empty-object schema Bedrock accepts. + _ => json!({ "type": "object", "properties": {} }), + } +} + /// Mirror the anthropic codec's conversation-prefix cache placement: a /// `cachePoint` at the end of the second-to-last user message, so the prior /// turns stay cached while the newest turn streams. @@ -321,7 +350,7 @@ mod tests { use super::*; use crate::codec::CodecParams; use crate::types::{ - ResponseFormat, ResponseFormatType, ThinkingData, ToolDefinition, ToolResult, + ResponseFormat, ResponseFormatType, ThinkingData, ToolCall, ToolDefinition, ToolResult, }; fn base_request(model: &str) -> Request { @@ -412,6 +441,36 @@ mod tests { ); } + #[test] + fn typeless_tool_schema_gains_object_type() { + // Bedrock rejects a tool inputSchema without a top-level `type` (some + // model families validate strictly); the encoder must default it. + let mut request = base_request("claude"); + request.tools = Some(vec![ + ToolDefinition::function("no_type", "schema without a type", json!({})), + ToolDefinition::function( + "props_only", + "properties but no top-level type", + json!({"properties": {"q": {"type": "string"}}}), + ), + ]); + let encoded = encode_with(&request); + let tools = &encoded.body["toolConfig"]["tools"]; + assert_eq!( + tools[0]["toolSpec"]["inputSchema"]["json"]["type"], + "object" + ); + assert_eq!( + tools[1]["toolSpec"]["inputSchema"]["json"]["type"], + "object" + ); + // An existing nested schema is preserved, not clobbered. + assert_eq!( + tools[1]["toolSpec"]["inputSchema"]["json"]["properties"]["q"]["type"], + "string" + ); + } + #[test] fn tool_results_ride_in_user_messages() { let mut request = base_request("claude"); @@ -437,6 +496,28 @@ mod tests { ); } + #[test] + fn no_argument_tool_call_encodes_empty_object_input() { + // A no-arg tool call decodes to `Null` arguments; Bedrock rejects a + // null/empty `toolUse.input`, so the encoder must emit `{}`. + let mut request = base_request("claude"); + request.messages = vec![Message { + role: Role::Assistant, + content: vec![ContentPart::ToolCall(ToolCall::new( + "tool-1", + "TaskList", + Value::Null, + ))], + name: None, + tool_call_id: None, + }]; + let encoded = encode_with(&request); + let tool_use = &encoded.body["messages"][0]["content"][0]["toolUse"]; + assert_eq!(tool_use["toolUseId"], "tool-1"); + assert_eq!(tool_use["name"], "TaskList"); + assert_eq!(tool_use["input"], json!({})); + } + #[test] fn thinking_parts_restructure_into_reasoning_text_blocks() { let mut request = base_request("claude"); diff --git a/lib/crates/fabro-llm/src/codec/bedrock_converse/stream.rs b/lib/crates/fabro-llm/src/codec/bedrock_converse/stream.rs index c0008e819..61d6a003b 100644 --- a/lib/crates/fabro-llm/src/codec/bedrock_converse/stream.rs +++ b/lib/crates/fabro-llm/src/codec/bedrock_converse/stream.rs @@ -218,7 +218,15 @@ impl ConverseStreamDecoder { vec![StreamEvent::ReasoningEnd] } BlockState::ToolUse { id, name, input } => { - let arguments = serde_json::from_str(&input).unwrap_or(Value::Null); + // A no-argument tool call streams no input fragments, leaving + // the buffer empty; canonically that is an empty object, not + // null (matching the anthropic/openai codecs, and what Bedrock + // wants back on re-encode). + let arguments = if input.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&input).unwrap_or(Value::Null) + }; let mut tool_call = ToolCall::new(&id, &name, arguments); tool_call.raw_arguments = Some(input); self.parts.push(ContentPart::ToolCall(tool_call.clone())); @@ -384,6 +392,25 @@ mod tests { assert!(d.finish().is_empty()); } + #[test] + fn no_argument_tool_call_decodes_empty_object_not_null() { + // A no-arg tool call (e.g. TaskList) streams no input fragments; the + // arguments must be `{}` so it re-encodes to a valid Converse input. + let mut d = decoder(); + feed(&mut d, "messageStart", r#"{"role":"assistant"}"#); + feed( + &mut d, + "contentBlockStart", + r#"{"start":{"toolUse":{"toolUseId":"tool-1","name":"TaskList"}},"contentBlockIndex":0}"#, + ); + let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#); + let StreamEvent::ToolCallEnd { tool_call } = &stop[0] else { + panic!("expected ToolCallEnd"); + }; + assert_eq!(tool_call.arguments, serde_json::json!({})); + assert!(!tool_call.arguments.is_null()); + } + #[test] fn tool_use_accumulates_string_input_fragments() { let mut d = decoder();