From 6ac7d8cd46dd8bfc6b353a93ea773c8b039157df Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 9 Apr 2026 22:06:25 +0900 Subject: [PATCH] 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). --- .../crates/api/src/providers/openai_compat.rs | 78 ++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 7d4d3ec..82d5c3a 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -853,11 +853,16 @@ fn translate_message(message: &InputMessage) -> Vec { 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.