From 397fcd0e6b0cb2d6d2bc6cbf2fef7aa935218c45 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:41:44 -0700 Subject: [PATCH] fix(responses): serialize flattened namespace tools and keep tool results adjacent to tool_calls --- .../transformation.py | 47 ++++++++++++++++++- .../test_litellm_completion_responses.py | 27 +++++++++++ ..._tool_output_order_preserved_for_gemini.py | 38 +++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 40a45f59d36..11ea495008c 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -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")) diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index f4c744f726d..0fec2d8601c 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -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 = { diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py b/tests/test_litellm/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py index 6b893e12285..e03a1fcc08c 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py @@ -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"