fix(responses): serialize flattened namespace tools and keep tool results adjacent to tool_calls

This commit is contained in:
Mateo Wang 2026-08-11 23:41:44 -07:00
parent ae2a1f4aba
commit 397fcd0e6b
3 changed files with 111 additions and 1 deletions

View file

@ -545,9 +545,52 @@ class LiteLLMCompletionResponsesConfig:
messages.extend(deduped_in_place)
continue
merged_assistant = LiteLLMCompletionResponsesConfig._merged_trailing_assistant_message(
messages=messages,
chat_completion_messages=chat_completion_messages,
)
if merged_assistant is not None:
messages[-1] = merged_assistant
continue
messages.extend(chat_completion_messages)
return messages
@staticmethod
def _merged_trailing_assistant_message(
messages: Sequence[
AllMessageValues
| GenericChatCompletionMessage
| ChatCompletionMessageToolCall
| ChatCompletionResponseMessage
],
chat_completion_messages: Sequence[
AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage
],
) -> ChatCompletionResponseMessage | None:
"""Fold an assistant content message into a directly preceding assistant
tool_calls message. Providers like DeepSeek and Anthropic require tool
results immediately after the tool_calls message, so an assistant message
between them is rejected."""
if not messages or len(chat_completion_messages) != 1:
return None
last_message = messages[-1]
new_message = chat_completion_messages[0]
if not isinstance(last_message, dict):
return None
if last_message.get("role") != "assistant" or new_message.get("role") != "assistant":
return None
if not last_message.get("tool_calls") or last_message.get("content") or new_message.get("tool_calls"):
return None
new_content = new_message.get("content")
if new_content is None:
return None
merged: Final = { # mutable-ok: json.dumps rejects MappingProxyType in outbound chat messages
**last_message,
"content": new_content,
}
return cast(ChatCompletionResponseMessage, merged) # cast-ok: TypedDict spread widens to dict[str, object]
@staticmethod
def _deduplicate_tool_call_output_messages(
tool_call_output_messages: list[
@ -1373,7 +1416,9 @@ class LiteLLMCompletionResponsesConfig:
function: Final = ChatCompletionToolParamFunctionChunk(
name=chat_tool_name,
description=description,
parameters=normalized_parameters,
parameters=dict( # mutable-ok: json.dumps rejects MappingProxyType in the outbound payload
normalized_parameters
),
strict=bool(namespace_tool.get("strict", False)),
)
allowed_callers: Final = validated_allowed_callers(namespace_tool.get("allowed_callers"))

View file

@ -1,3 +1,4 @@
import json
import os
import sys
@ -1816,6 +1817,32 @@ class TestToolTransformation:
assert result_tool["function"]["parameters"] == namespace_tool["tools"][0]["parameters"]
assert result_tool["function"]["description"] == "Multi-agent tools\n\nSpawn an agent"
def test_transform_namespace_tools_are_json_serializable(self):
"""Outbound chat payloads go through json.dumps, which rejects MappingProxyType."""
namespace_tool = {
"type": "namespace",
"name": "mcp__everything",
"description": "MCP tools",
"tools": [
{
"type": "function",
"name": "get_sum",
"description": "Add two numbers",
"parameters": {
"type": "object",
"properties": {"a": {"type": "number"}, "b": {"type": "number"}},
"required": ["a", "b"],
},
}
],
}
result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
tools=[namespace_tool]
)
assert "mcp__everything__get_sum" in json.dumps(result_tools)
@pytest.mark.parametrize("nested", [True, False])
def test_transform_namespace_tools_preserves_allowed_callers(self, nested):
function_tool = {

View file

@ -75,3 +75,41 @@ def test_function_call_output_stays_adjacent_to_tool_call():
# Tool output must be right after tool call, and before the assistant "Done." message.
assert tool_msg_idx == tool_call_idx + 1
assert assistant_ok_idx > tool_msg_idx
def test_assistant_message_after_tool_call_is_folded_into_it():
"""Codex echoes history as [function_call, assistant message, function_call_output].
The assistant message must fold into the tool_calls message so the tool result
stays immediately after it (DeepSeek and Anthropic reject it otherwise)."""
msgs = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
input=[
{
"role": "user",
"type": "message",
"content": [{"type": "input_text", "text": "Add 21 and 21."}],
},
{
"type": "function_call",
"name": "mcp__everything__get_sum",
"call_id": "call_1",
"arguments": '{"a":21,"b":21}',
},
{
"role": "assistant",
"type": "message",
"content": [{"type": "output_text", "text": ""}],
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "42",
},
]
)
roles = [m.get("role") for m in msgs if isinstance(m, dict)]
assert roles.count("assistant") == 1
tool_call_idx = next(i for i, m in enumerate(msgs) if isinstance(m, dict) and m.get("tool_calls"))
assert msgs[tool_call_idx].get("role") == "assistant"
assert msgs[tool_call_idx + 1].get("role") == "tool"