Skip to content

Commit 6ac7d8c

Browse files
committed
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).
1 parent 7ec6860 commit 6ac7d8c

1 file changed

Lines changed: 75 additions & 3 deletions

File tree

rust/crates/api/src/providers/openai_compat.rs

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -853,11 +853,16 @@ fn translate_message(message: &InputMessage) -> Vec<Value> {
853853
if text.is_empty() && tool_calls.is_empty() {
854854
Vec::new()
855855
} else {
856-
vec![json!({
856+
let mut msg = serde_json::json!({
857857
"role": "assistant",
858858
"content": (!text.is_empty()).then_some(text),
859-
"tool_calls": tool_calls,
860-
})]
859+
});
860+
// Only include tool_calls when non-empty: some providers reject
861+
// assistant messages with an explicit empty tool_calls array.
862+
if !tool_calls.is_empty() {
863+
msg["tool_calls"] = json!(tool_calls);
864+
}
865+
vec![msg]
861866
}
862867
}
863868
_ => message
@@ -1526,6 +1531,73 @@ mod tests {
15261531
);
15271532
}
15281533

1534+
#[test]
1535+
/// Regression: when building a multi-turn request where a prior assistant
1536+
/// turn has no tool calls, the serialized assistant message must NOT include
1537+
/// `tool_calls: []`. Some providers reject requests that carry an empty
1538+
/// tool_calls array on assistant turns (gaebal-gajae repro 2026-04-09).
1539+
#[test]
1540+
fn assistant_message_without_tool_calls_omits_tool_calls_field() {
1541+
use crate::types::{InputContentBlock, InputMessage};
1542+
1543+
let request = MessageRequest {
1544+
model: "gpt-4o".to_string(),
1545+
max_tokens: 100,
1546+
messages: vec![InputMessage {
1547+
role: "assistant".to_string(),
1548+
content: vec![InputContentBlock::Text {
1549+
text: "Hello".to_string(),
1550+
}],
1551+
}],
1552+
stream: false,
1553+
..Default::default()
1554+
};
1555+
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
1556+
let messages = payload["messages"].as_array().unwrap();
1557+
let assistant_msg = messages
1558+
.iter()
1559+
.find(|m| m["role"] == "assistant")
1560+
.expect("assistant message must be present");
1561+
assert!(
1562+
assistant_msg.get("tool_calls").is_none(),
1563+
"assistant message without tool calls must omit tool_calls field: {:?}",
1564+
assistant_msg
1565+
);
1566+
}
1567+
1568+
/// Regression: assistant messages WITH tool calls must still include
1569+
/// the tool_calls array (normal multi-turn tool-use flow).
1570+
#[test]
1571+
fn assistant_message_with_tool_calls_includes_tool_calls_field() {
1572+
use crate::types::{InputContentBlock, InputMessage};
1573+
1574+
let request = MessageRequest {
1575+
model: "gpt-4o".to_string(),
1576+
max_tokens: 100,
1577+
messages: vec![InputMessage {
1578+
role: "assistant".to_string(),
1579+
content: vec![InputContentBlock::ToolUse {
1580+
id: "call_1".to_string(),
1581+
name: "read_file".to_string(),
1582+
input: serde_json::json!({"path": "/tmp/test"}),
1583+
}],
1584+
}],
1585+
stream: false,
1586+
..Default::default()
1587+
};
1588+
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
1589+
let messages = payload["messages"].as_array().unwrap();
1590+
let assistant_msg = messages
1591+
.iter()
1592+
.find(|m| m["role"] == "assistant")
1593+
.expect("assistant message must be present");
1594+
let tool_calls = assistant_msg
1595+
.get("tool_calls")
1596+
.expect("assistant message with tool calls must include tool_calls field");
1597+
assert!(tool_calls.is_array());
1598+
assert_eq!(tool_calls.as_array().unwrap().len(), 1);
1599+
}
1600+
15291601
#[test]
15301602
fn non_gpt5_uses_max_tokens() {
15311603
// Older OpenAI models expect `max_tokens`; verify gpt-4o is unaffected.

0 commit comments

Comments
 (0)