fix(api): omit tool_calls field from assistant messages when empty

When serializing a multi-turn conversation for the OpenAI-compatible path,
assistant messages with no tool calls were always emitting 'tool_calls: []'.
Some providers reject requests where a prior assistant turn carries an
explicit empty tool_calls array (400 on subsequent turns after a plain
text assistant response).

Fix: only include 'tool_calls' in the serialized assistant message when
the vec is non-empty. Empty case omits the field entirely.

This is a companion fix to fd7aade (null tool_calls in stream delta).
The two bugs are symmetric: fd7aade handled inbound null -> empty vec;
this handles outbound empty vec -> field omitted.

Two regression tests added:
- assistant_message_without_tool_calls_omits_tool_calls_field
- assistant_message_with_tool_calls_includes_tool_calls_field

115 api tests pass. Fmt clean.

Source: gaebal-gajae repro 2026-04-09 (400 on multi-turn, companion to
null tool_calls stream-delta fix).
This commit is contained in:
YeonGyu-Kim
2026-04-09 22:06:25 +09:00
parent 7ec6860d9a
commit 6ac7d8cd46

View File

@@ -853,11 +853,16 @@ fn translate_message(message: &InputMessage) -> Vec<Value> {
if text.is_empty() && tool_calls.is_empty() {
Vec::new()
} else {
vec![json!({
let mut msg = serde_json::json!({
"role": "assistant",
"content": (!text.is_empty()).then_some(text),
"tool_calls": tool_calls,
})]
});
// Only include tool_calls when non-empty: some providers reject
// assistant messages with an explicit empty tool_calls array.
if !tool_calls.is_empty() {
msg["tool_calls"] = json!(tool_calls);
}
vec![msg]
}
}
_ => message
@@ -1526,6 +1531,73 @@ mod tests {
);
}
#[test]
/// Regression: when building a multi-turn request where a prior assistant
/// turn has no tool calls, the serialized assistant message must NOT include
/// `tool_calls: []`. Some providers reject requests that carry an empty
/// tool_calls array on assistant turns (gaebal-gajae repro 2026-04-09).
#[test]
fn assistant_message_without_tool_calls_omits_tool_calls_field() {
use crate::types::{InputContentBlock, InputMessage};
let request = MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 100,
messages: vec![InputMessage {
role: "assistant".to_string(),
content: vec![InputContentBlock::Text {
text: "Hello".to_string(),
}],
}],
stream: false,
..Default::default()
};
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
let messages = payload["messages"].as_array().unwrap();
let assistant_msg = messages
.iter()
.find(|m| m["role"] == "assistant")
.expect("assistant message must be present");
assert!(
assistant_msg.get("tool_calls").is_none(),
"assistant message without tool calls must omit tool_calls field: {:?}",
assistant_msg
);
}
/// Regression: assistant messages WITH tool calls must still include
/// the tool_calls array (normal multi-turn tool-use flow).
#[test]
fn assistant_message_with_tool_calls_includes_tool_calls_field() {
use crate::types::{InputContentBlock, InputMessage};
let request = MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 100,
messages: vec![InputMessage {
role: "assistant".to_string(),
content: vec![InputContentBlock::ToolUse {
id: "call_1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "/tmp/test"}),
}],
}],
stream: false,
..Default::default()
};
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
let messages = payload["messages"].as_array().unwrap();
let assistant_msg = messages
.iter()
.find(|m| m["role"] == "assistant")
.expect("assistant message must be present");
let tool_calls = assistant_msg
.get("tool_calls")
.expect("assistant message with tool calls must include tool_calls field");
assert!(tool_calls.is_array());
assert_eq!(tool_calls.as_array().unwrap().len(), 1);
}
#[test]
fn non_gpt5_uses_max_tokens() {
// Older OpenAI models expect `max_tokens`; verify gpt-4o is unaffected.