fix(bedrock): harden Converse tool encoding (object input + typed schemas)

Two Converse tool-encoding strictness issues surfaced by live multi-turn,
multi-model testing:

- A no-argument tool call decoded to null `toolUse.input`; Bedrock rejects
  null/empty input as "toolUse.input is empty". The encoder now guarantees a
  JSON object on the wire, and the decoders canonicalize a no-arg call to `{}`
  (matching the anthropic/openai codecs).
- A tool whose inputSchema omits a top-level `type` is rejected by strict
  model families (e.g. DeepSeek) as "inputSchema.json.type must be one of ..."
  while Claude tolerates it. The encoder now defaults the schema `type` to
  `object`, preserving existing nested schemas — guarding tools from any
  source (MCP, user-defined).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-06-14 22:16:25 -04:00
parent 27c46211a5
commit 3f68596d11
3 changed files with 124 additions and 11 deletions

View file

@ -115,7 +115,12 @@ pub(super) fn decode_content_block(block: &Value) -> Option<ContentPart> {
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") {

View file

@ -173,13 +173,24 @@ fn encode_content_part(part: &ContentPart) -> Option<Value> {
}
}))
}
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<Value> {
"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<Value> {
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");

View file

@ -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();