fix: empty tool results can't cause errors in bedrock, claude, openai, or vertexai clients anymore

This commit is contained in:
Dark-Alex-17 committed 2026-09-14 14:46:46 -06:00
1 parent 23f941b09f
commit 755b25f9b2
4 files changed
+203 -19

No files matched your search

+54 -7
View File
@@ -457,10 +457,15 @@ fn build_chat_completions_body(
} }
})); }));
} }
vec![ // Empty tool_results (reachable via deserialized sessions) must not emit an empty pair.
json!({ "role": "assistant", "content": assistant_parts }), if assistant_parts.is_empty() {
json!({ "role": "user", "content": user_parts }), vec![]
] } else {
vec![
json!({ "role": "assistant", "content": assistant_parts }),
json!({ "role": "user", "content": user_parts }),
]
}
} else { } else {
// One pair per round: Claude can reuse tool_use IDs across API calls. // One pair per round: Claude can reuse tool_use IDs across API calls.
// A round boundary is detected by the presence of round text, but // A round boundary is detected by the presence of round text, but
@@ -872,7 +877,7 @@ mod tests {
} }
} }
fn build_body(tool_results: Vec<ToolResult>) -> Value { fn build_body_with(tool_results: Vec<ToolResult>, text: &str, sequence: bool) -> Value {
let data = ChatCompletionsData { let data = ChatCompletionsData {
messages: vec![ messages: vec![
Message::new(MessageRole::User, MessageContent::Text("hello".to_string())), Message::new(MessageRole::User, MessageContent::Text("hello".to_string())),
@@ -880,8 +885,8 @@ mod tests {
MessageRole::Assistant, MessageRole::Assistant,
MessageContent::ToolCalls(MessageContentToolCalls { MessageContent::ToolCalls(MessageContentToolCalls {
tool_results, tool_results,
text: String::new(), text: text.to_string(),
sequence: true, sequence,
}), }),
), ),
], ],
@@ -894,6 +899,10 @@ mod tests {
build_chat_completions_body(data, &Model::new("bedrock", "test"), false).unwrap() build_chat_completions_body(data, &Model::new("bedrock", "test"), false).unwrap()
} }
fn build_body(tool_results: Vec<ToolResult>) -> Value {
build_body_with(tool_results, "", true)
}
fn tool_result_content_block(output: Value) -> Value { fn tool_result_content_block(output: Value) -> Value {
let body = build_body(vec![tool_result(output)]); let body = build_body(vec![tool_result(output)]);
body["messages"][2]["content"][0]["toolResult"]["content"][0].clone() body["messages"][2]["content"][0]["toolResult"]["content"][0].clone()
@@ -1063,6 +1072,44 @@ mod tests {
assert_unique_tool_use_ids_per_message(&body); assert_unique_tool_use_ids_per_message(&body);
} }
#[test]
fn non_sequence_emits_assistant_user_pair() {
let body = build_body_with(
vec![
sequence_tool_result("toolu_A", None),
sequence_tool_result("toolu_B", None),
],
"",
false,
);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 3, "body: {body}");
assert_eq!(messages[1]["role"], "assistant");
assert_eq!(messages[1]["content"].as_array().unwrap().len(), 2);
assert_eq!(messages[2]["role"], "user");
assert_eq!(messages[2]["content"].as_array().unwrap().len(), 2);
}
#[test]
fn non_sequence_empty_tool_results_emits_no_messages() {
let body = build_body_with(vec![], "leftover text", false);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1, "body: {body}");
}
#[test]
fn sequence_empty_tool_results_emits_no_messages() {
let body = build_body_with(vec![], "leftover text", true);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1, "body: {body}");
}
#[test] #[test]
fn test_bedrock_tool_result_object_uses_json_block() { fn test_bedrock_tool_result_object_uses_json_block() {
assert_eq!( assert_eq!(
+57 -7
View File
@@ -381,10 +381,15 @@ pub fn claude_build_chat_completions_body(
"content": tool_result.output.to_string(), "content": tool_result.output.to_string(),
})); }));
} }
vec![ // Empty tool_results (reachable via deserialized sessions) must not emit an empty pair.
json!({ "role": "assistant", "content": assistant_parts }), if assistant_parts.is_empty() {
json!({ "role": "user", "content": user_parts }), vec![]
] } else {
vec![
json!({ "role": "assistant", "content": assistant_parts }),
json!({ "role": "user", "content": user_parts }),
]
}
} else { } else {
// One pair per round: Claude can reuse tool_use IDs across API calls. // One pair per round: Claude can reuse tool_use IDs across API calls.
// A round boundary is detected by the presence of round text, but // A round boundary is detected by the presence of round text, but
@@ -638,7 +643,12 @@ mod tests {
} }
} }
fn build_body(tool_results: Vec<ToolResult>, prompt_cache: bool) -> Value { fn build_body_with(
tool_results: Vec<ToolResult>,
text: &str,
sequence: bool,
prompt_cache: bool,
) -> Value {
let data = ChatCompletionsData { let data = ChatCompletionsData {
messages: vec![ messages: vec![
Message::new(MessageRole::User, MessageContent::Text("hello".to_string())), Message::new(MessageRole::User, MessageContent::Text("hello".to_string())),
@@ -646,8 +656,8 @@ mod tests {
MessageRole::Assistant, MessageRole::Assistant,
MessageContent::ToolCalls(MessageContentToolCalls { MessageContent::ToolCalls(MessageContentToolCalls {
tool_results, tool_results,
text: String::new(), text: text.to_string(),
sequence: true, sequence,
}), }),
), ),
], ],
@@ -661,6 +671,10 @@ mod tests {
.unwrap() .unwrap()
} }
fn build_body(tool_results: Vec<ToolResult>, prompt_cache: bool) -> Value {
build_body_with(tool_results, "", true, prompt_cache)
}
fn multi_turn_data(functions: Option<Vec<FunctionDeclaration>>) -> ChatCompletionsData { fn multi_turn_data(functions: Option<Vec<FunctionDeclaration>>) -> ChatCompletionsData {
ChatCompletionsData { ChatCompletionsData {
messages: vec![ messages: vec![
@@ -766,6 +780,42 @@ mod tests {
assert_unique_tool_use_ids_per_message(&body); assert_unique_tool_use_ids_per_message(&body);
} }
#[test]
fn non_sequence_emits_assistant_user_pair() {
let body = build_body_with(
vec![tool_result("toolu_A", None), tool_result("toolu_B", None)],
"",
false,
false,
);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 3, "body: {body}");
assert_eq!(messages[1]["role"], "assistant");
assert_eq!(messages[1]["content"].as_array().unwrap().len(), 2);
assert_eq!(messages[2]["role"], "user");
assert_eq!(messages[2]["content"].as_array().unwrap().len(), 2);
}
#[test]
fn non_sequence_empty_tool_results_emits_no_messages() {
let body = build_body_with(vec![], "leftover text", false, false);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1, "body: {body}");
}
#[test]
fn sequence_empty_tool_results_emits_no_messages() {
let body = build_body_with(vec![], "leftover text", true, false);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1, "body: {body}");
}
#[test] #[test]
fn prompt_cache_places_breakpoints() { fn prompt_cache_places_breakpoints() {
let functions = vec![function_declaration("a"), function_declaration("b")]; let functions = vec![function_declaration("a"), function_declaration("b")];
+49 -1
View File
@@ -373,7 +373,10 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
text, text,
sequence, sequence,
}) => { }) => {
if !sequence { // Empty tool_results (reachable via deserialized sessions) must not emit empty tool_calls.
if tool_results.is_empty() {
vec![]
} else if !sequence {
let tool_calls: Vec<_> = tool_results let tool_calls: Vec<_> = tool_results
.iter() .iter()
.map(|tool_result| { .map(|tool_result| {
@@ -745,3 +748,48 @@ pub async fn openai_responses_streaming(
sse_stream(builder, handle).await sse_stream(builder, handle).await
} }
#[cfg(test)]
mod tests {
use super::*;
fn build_body(sequence: bool) -> Value {
let data = ChatCompletionsData {
messages: vec![
Message::new(MessageRole::User, MessageContent::Text("hello".to_string())),
Message::new(
MessageRole::Assistant,
MessageContent::ToolCalls(MessageContentToolCalls {
tool_results: vec![],
text: "leftover text".to_string(),
sequence,
}),
),
],
temperature: None,
top_p: None,
reasoning_effort: None,
functions: None,
stream: false,
};
openai_build_chat_completions_body(data, &Model::new("openai", "gpt-test"))
}
#[test]
fn non_sequence_empty_tool_results_emits_no_messages() {
let body = build_body(false);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1, "body: {body}");
}
#[test]
fn sequence_empty_tool_results_emits_no_messages() {
let body = build_body(true);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1, "body: {body}");
}
}
+43 -4
View File
@@ -412,10 +412,15 @@ pub fn gemini_build_chat_completions_body(
} }
}) })
}).collect(); }).collect();
vec![ // Empty tool_results (reachable via deserialized sessions) must not emit an empty pair.
json!({ "role": "model", "parts": model_parts }), if function_parts.is_empty() {
json!({ "role": "function", "parts": function_parts }), vec![]
] } else {
vec![
json!({ "role": "model", "parts": model_parts }),
json!({ "role": "function", "parts": function_parts }),
]
}
} }
} }
}) })
@@ -579,3 +584,37 @@ fn strip_model_version(name: &str) -> &str {
None => name, None => name,
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gemini_empty_tool_results_emits_no_messages() {
let data = ChatCompletionsData {
messages: vec![
Message::new(MessageRole::User, MessageContent::Text("hello".to_string())),
Message::new(
MessageRole::Assistant,
MessageContent::ToolCalls(MessageContentToolCalls {
tool_results: vec![],
text: "leftover text".to_string(),
sequence: false,
}),
),
],
temperature: None,
top_p: None,
reasoning_effort: None,
functions: None,
stream: false,
};
let body = gemini_build_chat_completions_body(data, &Model::new("vertexai", "gemini-test"))
.unwrap();
let contents = body["contents"].as_array().unwrap();
assert_eq!(contents.len(), 1, "body: {body}");
}
}