diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 748347fe938..f0e5086b660 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1747,6 +1747,46 @@ def hoist_images_from_tool_messages( ] +def _is_tool_reference_part(part: object) -> bool: + return isinstance(part, dict) and part.get("type") == "tool_reference" + + +def _tool_message_carries_tool_reference(message: AllMessageValues) -> bool: + if message.get("role") != "tool": + return False + content = message.get("content") + return isinstance(content, list) and any(_is_tool_reference_part(part) for part in content) + + +def _drop_tool_reference_parts(message: AllMessageValues) -> AllMessageValues: + if not _tool_message_carries_tool_reference(message): + return message + content = cast(list, message.get("content")) # cast-ok: shape checked by _tool_message_carries_tool_reference + remaining_parts = [ # mutable-ok: tool message content must stay a json list + part for part in content if not _is_tool_reference_part(part) + ] + new_content = remaining_parts if remaining_parts else "" + rewritten = {**message, "content": new_content} # mutable-ok: chat messages are plain json dicts + return cast(AllMessageValues, rewritten) # cast-ok: dict spread keeps keys like cache_control + + +def drop_tool_reference_parts_from_tool_messages( + messages: list[AllMessageValues], # mutable-ok: message pipelines type messages as mutable lists +) -> list[AllMessageValues]: # mutable-ok: message pipelines type messages as mutable lists + """ + Remove tool_reference content parts from role:"tool" messages. + + The OpenAI chat spec only accepts text in tool messages, so a tool_reference + part carried through the Anthropic adapter makes strict providers reject the + request. The reference names an already-declared tool rather than carrying + content, so it is dropped; a reference-only result keeps its tool message with + empty text so the preceding tool_call stays answered. + """ + if not any(_tool_message_carries_tool_reference(message) for message in messages): + return messages + return [_drop_tool_reference_parts(message) for message in messages] # mutable-ok: pipelines mutate message lists + + def _attempt_json_repair(s: str) -> Any | None: """ Attempt to repair truncated JSON produced by LLM tool calls. diff --git a/litellm/llms/azure/chat/gpt_transformation.py b/litellm/llms/azure/chat/gpt_transformation.py index 0d50609555a..30fc3635d9d 100644 --- a/litellm/llms/azure/chat/gpt_transformation.py +++ b/litellm/llms/azure/chat/gpt_transformation.py @@ -4,6 +4,7 @@ from httpx._models import Headers, Response import litellm from litellm.litellm_core_utils.prompt_templates.common_utils import ( + drop_tool_reference_parts_from_tool_messages, hoist_images_from_tool_messages, ) from litellm.litellm_core_utils.prompt_templates.factory import ( @@ -252,7 +253,8 @@ class AzureOpenAIConfig(BaseConfig): litellm_params: dict, headers: dict, ) -> dict: - azure_messages: Final = convert_to_azure_openai_messages(hoist_images_from_tool_messages(messages)) + stripped_messages: Final = drop_tool_reference_parts_from_tool_messages(messages) + azure_messages: Final = convert_to_azure_openai_messages(hoist_images_from_tool_messages(stripped_messages)) return { "model": model, "messages": azure_messages, diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index 16fd042cb2f..9b7c5a3f857 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -18,6 +18,7 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo _should_convert_tool_call_to_json_mode, ) from litellm.litellm_core_utils.prompt_templates.common_utils import ( + drop_tool_reference_parts_from_tool_messages, get_tool_call_names, hoist_images_from_tool_messages, ) @@ -336,7 +337,8 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): self, messages: list[AllMessageValues], model: str, is_async: bool = False ) -> list[AllMessageValues] | Coroutine[Any, Any, list[AllMessageValues]]: """OpenAI no longer supports image_url as a string, so we need to convert it to a dict""" - hoisted_messages: Final = hoist_images_from_tool_messages(messages) + stripped_messages: Final = drop_tool_reference_parts_from_tool_messages(messages) + hoisted_messages: Final = hoist_images_from_tool_messages(stripped_messages) async def _async_transform(): for message in hoisted_messages: diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 44f91c98d81..aec6d12069f 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -1027,3 +1027,70 @@ def test_update_messages_xlitellm_decode_does_not_override_mapping(): updated = update_messages_with_model_file_ids(messages, "model-A", mapping) assert updated[0]["content"][0]["file"]["file_id"] == "provider-explicit-id" + + +def test_drop_tool_reference_parts_keeps_text_parts(): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + drop_tool_reference_parts_from_tool_messages, + ) + + messages = [ + _assistant_tool_call_msg("call_1"), + _tool_msg( + [ + {"type": "text", "text": "WebFetch tool loaded successfully."}, + {"type": "tool_reference", "tool_name": "WebFetch"}, + ] + ), + ] + + result = drop_tool_reference_parts_from_tool_messages(messages) + + assert result[1]["content"] == [{"type": "text", "text": "WebFetch tool loaded successfully."}] + assert result[1]["tool_call_id"] == "call_1" + + +def test_drop_tool_reference_parts_reference_only_becomes_empty_text(): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + drop_tool_reference_parts_from_tool_messages, + ) + + messages = [ + _assistant_tool_call_msg("call_1"), + _tool_msg([{"type": "tool_reference", "tool_name": "WebFetch"}]), + ] + + result = drop_tool_reference_parts_from_tool_messages(messages) + + assert result[1] == {"role": "tool", "tool_call_id": "call_1", "content": ""} + + +def test_drop_tool_reference_parts_without_references_passes_through(): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + drop_tool_reference_parts_from_tool_messages, + ) + + messages = [ + _assistant_tool_call_msg("call_1"), + _tool_msg([{"type": "text", "text": "plain result"}]), + ] + + assert drop_tool_reference_parts_from_tool_messages(messages) is messages + + +def test_drop_tool_reference_parts_leaves_non_tool_messages_alone(): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + drop_tool_reference_parts_from_tool_messages, + ) + + user_message = {"role": "user", "content": [{"type": "tool_reference", "tool_name": "WebFetch"}]} + messages = [ + user_message, + _assistant_tool_call_msg("call_1"), + _tool_msg([{"type": "tool_reference", "tool_name": "WebFetch"}]), + ] + + result = drop_tool_reference_parts_from_tool_messages(messages) + + assert result[0] == user_message + assert result[2]["content"] == "" diff --git a/tests/test_litellm/llms/azure/chat/test_azure_chat_gpt_transformation.py b/tests/test_litellm/llms/azure/chat/test_azure_chat_gpt_transformation.py index ad34199c4c6..2cf7cd142d6 100644 --- a/tests/test_litellm/llms/azure/chat/test_azure_chat_gpt_transformation.py +++ b/tests/test_litellm/llms/azure/chat/test_azure_chat_gpt_transformation.py @@ -102,6 +102,35 @@ def test_transform_request_hoists_tool_message_image(): ] +def test_transform_request_drops_tool_reference_parts(): + """Azure's transform_request shares the tool-message sanitizing with OpenAI: + tool_reference parts are dropped, a reference-only result keeps its tool + message with empty text (#37462 round trip).""" + messages = [ + {"role": "user", "content": "load the WebFetch tool"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "ToolSearch", "arguments": "{}"}}], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": [{"type": "tool_reference", "tool_name": "WebFetch"}], + }, + ] + + request = AzureOpenAIConfig().transform_request( + model="gpt-4o", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert request["messages"][2]["content"] == "" + + @pytest.mark.parametrize( "model, emitted_key, absent_key", [ diff --git a/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py b/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py index f4c38f8f797..3f346b5e8e7 100644 --- a/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py +++ b/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py @@ -869,6 +869,69 @@ class TestToolMessageImageHoisting: assert result[3]["content"] == self.HOISTED_USER_CONTENT +class TestToolReferenceStripping: + """transform_request drops tool_reference parts from tool messages: OpenAI's + chat API rejects them, and the reference names an already-declared tool + rather than carrying content (#37462 round trip).""" + + def setup_method(self): + self.config = OpenAIGPTConfig() + + def _messages_with_tool_reference(self, extra_parts=()): + return [ + {"role": "user", "content": "load the WebFetch tool"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "ToolSearch", "arguments": "{}"}} + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": [*extra_parts, {"type": "tool_reference", "tool_name": "WebFetch"}], + }, + ] + + def test_transform_request_keeps_text_and_drops_reference(self): + request = self.config.transform_request( + model="gpt-4.1", + messages=self._messages_with_tool_reference(extra_parts=({"type": "text", "text": "loaded"},)), + optional_params={}, + litellm_params={}, + headers={}, + ) + + tool_message = request["messages"][2] + assert tool_message["content"] == [{"type": "text", "text": "loaded"}] + assert tool_message["tool_call_id"] == "call_1" + + def test_transform_request_reference_only_keeps_tool_message_with_empty_text(self): + request = self.config.transform_request( + model="gpt-4.1", + messages=self._messages_with_tool_reference(), + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert [m.get("role") for m in request["messages"]] == ["user", "assistant", "tool"] + assert request["messages"][2]["content"] == "" + + @pytest.mark.asyncio + async def test_async_transform_request_drops_reference(self): + request = await self.config.async_transform_request( + model="gpt-4.1", + messages=self._messages_with_tool_reference(), + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert request["messages"][2]["content"] == "" + + class TestOpenAIPromptCacheBreakpointChatPath: """Chat-path shape for OpenAI explicit prompt caching (#37509)."""