fix(responses-api): merge consecutive function_call items into single assistant message

Fixes issue where multiple function_call items in Responses API input were
creating separate assistant messages, causing Claude's Messages API to fail with:
"Expected toolResult blocks at messages.2.content for the following Ids..."

Problem:
- Each function_call input item was creating a separate assistant message
- Claude Messages API expects all tool calls from the same turn to be in one message
- This caused the error when sending follow-up requests with tool results

Solution:
- Modified _transform_response_input_param_to_chat_completion_message to detect
  and group consecutive function_call items
- Merged all tool calls into a single assistant message
- If there's an existing assistant message with empty content immediately before
  the function_calls, the tool calls are merged into that message
- Preserves existing behavior for single function_calls and other message types

Changes:
- litellm/responses/litellm_completion_transformation/transformation.py:
  * Changed loop to use index-based iteration to allow grouping
  * Added logic to detect consecutive function_call items
  * Create single ChatCompletionResponseMessage with multiple tool_calls
  * Merge into existing empty assistant message when appropriate

- tests/test_litellm/responses/test_multiple_function_calls_merging.py:
  * Added comprehensive unit tests for the fix
  * Test multiple function_calls merging
  * Test edge cases (no preceding message, non-empty preceding message)
  * Test single function_call regression

Reported-by: HubSpot user (via GitHub issue)
This commit is contained in:
Claude 2026-01-20 19:09:23 +00:00
parent f95f5563ea
commit eb860aa09f
No known key found for this signature in database
2 changed files with 376 additions and 10 deletions

View file

@ -372,27 +372,112 @@ class LiteLLMCompletionResponsesConfig:
messages.append(ChatCompletionUserMessage(role="user", content=input))
elif isinstance(input, list):
existing_tool_call_ids: Set[str] = set()
for _input in input:
i = 0
while i < len(input):
_input = input[i]
# Check if this is the start of a sequence of function_call items
if LiteLLMCompletionResponsesConfig._is_input_item_function_call(
input_item=_input
):
# Collect all consecutive function_call items
function_call_items: List[Any] = []
j = i
while j < len(input) and LiteLLMCompletionResponsesConfig._is_input_item_function_call(
input_item=input[j]
):
function_call_items.append(input[j])
call_id_raw = input[j].get("call_id") or input[j].get("id") or ""
if call_id_raw:
existing_tool_call_ids.add(str(call_id_raw))
j += 1
# Create a single assistant message with all tool calls merged
tool_calls_list: List[ChatCompletionToolCallChunk] = []
for idx, func_call in enumerate(function_call_items):
tool_call = ChatCompletionToolCallChunk(
id=func_call.get("call_id") or func_call.get("id") or "",
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=func_call.get("name") or "",
arguments=str(func_call.get("arguments") or ""),
),
index=idx,
)
tool_calls_list.append(tool_call)
# Check if the last message is an assistant message with empty/None content
# If so, merge the tool calls into it
if messages and len(messages) > 0:
last_msg = messages[-1]
last_msg_role = (
last_msg.get("role") if isinstance(last_msg, dict)
else getattr(last_msg, "role", None)
)
last_msg_content = (
last_msg.get("content") if isinstance(last_msg, dict)
else getattr(last_msg, "content", None)
)
# If last message is assistant with empty/None content, merge tool_calls into it
if (
last_msg_role == "assistant"
and (
last_msg_content is None
or last_msg_content == ""
or (isinstance(last_msg_content, list) and all(
(isinstance(c, dict) and c.get("text") == "")
or (hasattr(c, "text") and getattr(c, "text", "") == "")
for c in last_msg_content
))
)
):
# Merge tool_calls into existing assistant message
if isinstance(last_msg, dict):
last_msg["tool_calls"] = tool_calls_list
else:
# For typed objects, we need to create a new message
messages[-1] = ChatCompletionResponseMessage(
role="assistant",
content=last_msg_content,
tool_calls=tool_calls_list,
)
else:
# Create a new assistant message with the tool calls
assistant_msg = ChatCompletionResponseMessage(
tool_calls=tool_calls_list,
role="assistant",
content=None,
)
messages.append(assistant_msg)
else:
# No previous messages, create a new assistant message
assistant_msg = ChatCompletionResponseMessage(
tool_calls=tool_calls_list,
role="assistant",
content=None,
)
messages.append(assistant_msg)
# Move index past all processed function_call items
i = j
continue
# Handle non-function_call items normally
chat_completion_messages = LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message(
input_item=_input
)
if LiteLLMCompletionResponsesConfig._is_input_item_function_call(
input_item=_input
):
call_id_raw = _input.get("call_id") or _input.get("id") or ""
if call_id_raw:
existing_tool_call_ids.add(str(call_id_raw))
#########################################################
# If Input Item is a Tool Call Output, add it to the tool_call_output_messages list
# preserving the ordering of tool call outputs. Some models require the tool
# result to immediately follow the assistant tool call.
# preserving the ordering of tool call outputs. Some models require the tool
# result to immediately follow the assistant tool call.
#########################################################
if LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(
input_item=_input
):
if not chat_completion_messages:
i += 1
continue
deduped_in_place: List[Any] = []
@ -432,9 +517,11 @@ class LiteLLMCompletionResponsesConfig:
deduped_in_place.append(m)
messages.extend(deduped_in_place)
i += 1
continue
messages.extend(chat_completion_messages)
i += 1
return messages
@staticmethod

View file

@ -0,0 +1,279 @@
"""
Unit tests for the fix to merge multiple consecutive function_call items
into a single assistant message.
This test verifies the fix for the issue where multiple function_call items
were creating separate assistant messages, causing Claude's Messages API
to fail with: "Expected toolResult blocks at messages.2.content for the following Ids..."
"""
import pytest
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
class TestMultipleFunctionCallsMerging:
"""Test cases for merging multiple function_call items into a single assistant message"""
def test_multiple_function_calls_merge_into_single_assistant_message(self):
"""
Test that multiple consecutive function_call items are merged into a single
assistant message with multiple tool_calls, fixing the Claude Messages API error.
"""
# Simulate the input from the Responses API after a streaming response
# This is what we get when Claude makes multiple tool calls
input_items = [
# Initial user message
{
"content": [
{
"text": "\n\n\nStructured Inputs:\ncompany: REI.com\n\n",
"type": "input_text",
"valid": True,
}
],
"role": "user",
"type": "message",
"valid": True,
},
# Empty assistant message (from the initial streaming response)
{
"id": "chatcmpl-0363cad9-9ecf-4a2a-b181-b2e68bb52913",
"content": [{"annotations": [], "text": "", "type": "output_text", "valid": True}],
"role": "assistant",
"status": "completed",
"type": "message",
"valid": True,
},
# Multiple function calls from the assistant
{
"arguments": '{"actionText": "Researching REI homepage", "contextText": "to gather company overview and positioning", "domain": "rei.com"}',
"call_id": "tooluse_gtWtOTKhTnmCQKrs60fSlg",
"name": "Research_company_web_page",
"type": "function_call",
"id": "tooluse_gtWtOTKhTnmCQKrs60fSlg",
"status": "completed",
"valid": True,
},
{
"arguments": '{"actionText": "Analyzing REI offerings", "contextText": "to understand products and target market", "domain": "rei.com"}',
"call_id": "tooluse_1M_saUAKTj-MC8EbawtbPg",
"name": "Infer_company_value_proposition_and_ICP",
"type": "function_call",
"id": "tooluse_1M_saUAKTj-MC8EbawtbPg",
"status": "completed",
"valid": True,
},
{
"arguments": '{"actionText": "Gathering recent news", "contextText": "to identify trigger events and company updates", "companyName": "REI", "domain": "rei.com"}',
"call_id": "tooluse_ynl6q84sSSOVTJ6mfTV5Qw",
"name": "Research_company_news",
"type": "function_call",
"id": "tooluse_ynl6q84sSSOVTJ6mfTV5Qw",
"status": "completed",
"valid": True,
},
# Tool outputs
{
"call_id": "tooluse_gtWtOTKhTnmCQKrs60fSlg",
"output": '"research: data here"',
"type": "function_call_output",
"id": "tooluse_gtWtOTKhTnmCQKrs60fSlg",
"status": "completed",
"valid": True,
},
{
"call_id": "tooluse_1M_saUAKTj-MC8EbawtbPg",
"output": '"targetIcp: data here"',
"type": "function_call_output",
"id": "tooluse_1M_saUAKTj-MC8EbawtbPg",
"status": "completed",
"valid": True,
},
{
"call_id": "tooluse_ynl6q84sSSOVTJ6mfTV5Qw",
"output": '"sources: data here"',
"type": "function_call_output",
"id": "tooluse_ynl6q84sSSOVTJ6mfTV5Qw",
"status": "completed",
"valid": True,
},
]
# Transform to chat completion messages
messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
input=input_items
)
# Expected behavior:
# 1. User message
# 2. Assistant message with ALL 3 tool calls merged into it
# 3-5. Three tool messages
# Count assistant messages
assistant_messages = [
m
for m in messages
if (m.get("role") if isinstance(m, dict) else m.role) == "assistant"
]
# The fix: We should have exactly 1 assistant message (with all tool calls merged)
assert (
len(assistant_messages) == 1
), f"Expected 1 assistant message, got {len(assistant_messages)}"
# Check that the single assistant message has all 3 tool calls
assistant_msg = assistant_messages[0]
tool_calls = (
assistant_msg.get("tool_calls")
if isinstance(assistant_msg, dict)
else assistant_msg.tool_calls
)
assert tool_calls is not None, "Expected tool_calls to be present"
assert len(tool_calls) == 3, f"Expected 3 tool calls, got {len(tool_calls)}"
# Verify all tool call IDs are present
tool_call_ids = {
(tc.get("id") if isinstance(tc, dict) else tc.id) for tc in tool_calls
}
expected_ids = {
"tooluse_gtWtOTKhTnmCQKrs60fSlg",
"tooluse_1M_saUAKTj-MC8EbawtbPg",
"tooluse_ynl6q84sSSOVTJ6mfTV5Qw",
}
assert tool_call_ids == expected_ids, f"Expected tool call IDs {expected_ids}, got {tool_call_ids}"
def test_function_calls_without_preceding_assistant_message(self):
"""
Test that function_call items create a new assistant message when there's
no preceding assistant message to merge into.
"""
input_items = [
# User message
{
"content": [{"text": "Test message", "type": "input_text"}],
"role": "user",
"type": "message",
},
# Multiple function calls (no preceding assistant message)
{
"arguments": '{"arg": "value1"}',
"call_id": "call_1",
"name": "tool1",
"type": "function_call",
},
{
"arguments": '{"arg": "value2"}',
"call_id": "call_2",
"name": "tool2",
"type": "function_call",
},
]
messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
input=input_items
)
# Should have user message + assistant message with 2 tool calls
assert len(messages) == 2, f"Expected 2 messages, got {len(messages)}"
assistant_msg = messages[1]
assert (assistant_msg.get("role") if isinstance(assistant_msg, dict) else assistant_msg.role) == "assistant"
tool_calls = (
assistant_msg.get("tool_calls")
if isinstance(assistant_msg, dict)
else assistant_msg.tool_calls
)
assert tool_calls is not None
assert len(tool_calls) == 2, f"Expected 2 tool calls, got {len(tool_calls)}"
def test_function_calls_with_non_empty_assistant_message(self):
"""
Test that function_call items create a NEW assistant message when the
preceding assistant message has non-empty content.
"""
input_items = [
# User message
{
"content": [{"text": "Test message", "type": "input_text"}],
"role": "user",
"type": "message",
},
# Assistant message with content
{
"content": [{"text": "I will call some tools", "type": "output_text"}],
"role": "assistant",
"type": "message",
},
# Function calls
{
"arguments": '{"arg": "value1"}',
"call_id": "call_1",
"name": "tool1",
"type": "function_call",
},
]
messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
input=input_items
)
# Should have user message + assistant message with content + assistant message with tool call
assert len(messages) == 3, f"Expected 3 messages, got {len(messages)}"
# First assistant message should have content, no tool calls
first_assistant = messages[1]
assert (first_assistant.get("role") if isinstance(first_assistant, dict) else first_assistant.role) == "assistant"
content = first_assistant.get("content") if isinstance(first_assistant, dict) else first_assistant.content
assert content is not None
# Second assistant message should have tool calls, no content
second_assistant = messages[2]
assert (second_assistant.get("role") if isinstance(second_assistant, dict) else second_assistant.role) == "assistant"
tool_calls = (
second_assistant.get("tool_calls")
if isinstance(second_assistant, dict)
else second_assistant.tool_calls
)
assert tool_calls is not None
assert len(tool_calls) == 1
def test_single_function_call_behavior_unchanged(self):
"""
Test that a single function_call item still works correctly
(regression test for existing behavior).
"""
input_items = [
# User message
{
"content": [{"text": "Test message", "type": "input_text"}],
"role": "user",
"type": "message",
},
# Single function call
{
"arguments": '{"arg": "value1"}',
"call_id": "call_1",
"name": "tool1",
"type": "function_call",
},
]
messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
input=input_items
)
# Should have user message + assistant message with 1 tool call
assert len(messages) == 2
assistant_msg = messages[1]
tool_calls = (
assistant_msg.get("tool_calls")
if isinstance(assistant_msg, dict)
else assistant_msg.tool_calls
)
assert tool_calls is not None
assert len(tool_calls) == 1