fix: empty tool results can't cause errors in bedrock, claude, openai, or vertexai clients anymore
This commit is contained in:
4 files changed
+203
-19
No files matched your search
+54
-7
@@ -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
@@ -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
@@ -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
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user