From 99af9ad9ebe0169eddee5347617ae9c088524421 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:35:46 -0700 Subject: [PATCH 1/2] fix(anthropic): carry tool_reference tool results through the guardrail translation round trip --- basedpyright-code-budget.json | 14 +- .../transformation.py | 35 +++-- .../anthropic_cache_control_hook.py | 2 +- .../prompt_templates/factory.py | 17 ++- .../adapters/transformation.py | 132 ++++++------------ litellm/llms/gemini/chat/transformation.py | 21 ++- litellm/llms/mistral/chat/transformation.py | 2 +- litellm/types/llms/anthropic.py | 7 +- litellm/types/llms/openai.py | 16 ++- ...responses_transformation_transformation.py | 36 +++++ ...llm_core_utils_prompt_templates_factory.py | 49 +++++++ .../test_anthropic_guardrail_handler.py | 69 +++++++++ ...al_pass_through_adapters_transformation.py | 69 +++++++++ type-discipline-budget.json | 4 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 15 +- 15 files changed, 363 insertions(+), 125 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 3357212a6c8..e768e83c899 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -6,10 +6,10 @@ "limit": 2564 }, "reportAssignmentType": { - "limit": 320 + "limit": 319 }, "reportAttributeAccessIssue": { - "limit": 483 + "limit": 480 }, "reportCallIssue": { "limit": 113 @@ -30,7 +30,7 @@ "limit": 7 }, "reportGeneralTypeIssues": { - "limit": 154 + "limit": 105 }, "reportIncompatibleMethodOverride": { "limit": 56 @@ -99,19 +99,19 @@ "limit": 0 }, "reportUnknownArgumentType": { - "limit": 44528 + "limit": 44526 }, "reportUnknownLambdaType": { "limit": 109 }, "reportUnknownMemberType": { - "limit": 38804 + "limit": 38782 }, "reportUnknownParameterType": { "limit": 19829 }, "reportUnknownVariableType": { - "limit": 30355 + "limit": 30349 }, "reportUnnecessaryCast": { "limit": 117 @@ -123,7 +123,7 @@ "limit": 5 }, "reportUnnecessaryIsInstance": { - "limit": 833 + "limit": 831 }, "reportUntypedBaseClass": { "limit": 0 diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 17815976b4a..85fb0bc8dc6 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -59,9 +59,11 @@ if TYPE_CHECKING: from litellm.types.llms.openai import ( ALL_RESPONSES_API_TOOL_PARAMS, AllMessageValues, + ChatCompletionFileObject, ChatCompletionImageObject, ChatCompletionRedactedThinkingBlock, ChatCompletionThinkingBlock, + ChatCompletionToolReferenceObject, OpenAIMessageContentListBlock, ) from litellm.types.utils import Choices @@ -175,6 +177,16 @@ def _map_incomplete_reason_to_finish_reason(incomplete_reason: str | None) -> Li return "length" +def _input_file_from_file_value(file_value: object) -> dict[str, object]: + if not isinstance(file_value, dict): + return {"type": "input_file"} + file_dict: Final = cast("dict[str, object]", file_value) # cast-ok: runtime dict checked + return { + "type": "input_file", + **{key: file_dict[key] for key in ("file_id", "file_data", "filename") if key in file_dict}, + } + + def _incomplete_reason_from_response_payload(response_payload: object) -> str | None: if not isinstance(response_payload, Mapping): return None @@ -957,7 +969,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): content: str | list[object] | Iterable[ - Union["OpenAIMessageContentListBlock", "ChatCompletionThinkingBlock", "ChatCompletionRedactedThinkingBlock"] + Union[ + "OpenAIMessageContentListBlock", + "ChatCompletionThinkingBlock", + "ChatCompletionRedactedThinkingBlock", + "ChatCompletionToolReferenceObject", + ] ] | None, role: str, @@ -1006,17 +1023,15 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): result.append(converted) verbose_logger.debug("Chat provider: image -> %s", converted) elif item_type == "file": - # Map Chat Completion file to Responses API input_file - # {"type": "file", "file": {"file_data": "...", "filename": "..."}} - # -> {"type": "input_file", "file_data": "...", "filename": "..."} - file_data = item.get("file", {}) - converted = {"type": "input_file"} - if isinstance(file_data, dict): - for key in ["file_id", "file_data", "filename"]: - if key in file_data: - converted[key] = file_data[key] + converted = _input_file_from_file_value( + cast("ChatCompletionFileObject", item).get("file"), # cast-ok: type tag checked + ) result.append(converted) verbose_logger.debug("Chat provider: file -> %s", converted) + elif item_type == "tool_reference": + verbose_logger.debug( + "Chat provider: tool_reference has no responses API equivalent; skipped" + ) elif item_type in [ "input_text", "input_image", diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py index ef2edbf1007..f972bad47e6 100644 --- a/litellm/integrations/anthropic_cache_control_hook.py +++ b/litellm/integrations/anthropic_cache_control_hook.py @@ -376,7 +376,7 @@ class AnthropicCacheControlHook(CustomPromptManagement): # 2. list of objects - only apply to last item per Anthropic spec elif isinstance(message_content, list): if len(message_content) > 0 and isinstance(message_content[-1], dict): - message_content[-1]["cache_control"] = control + message_content[-1]["cache_control"] = control # pyright: ignore[reportGeneralTypeIssues] # loose runtime dict return message @staticmethod diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 86cfbf70255..795fb36961e 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1412,7 +1412,7 @@ def convert_to_gemini_tool_call_result( ) except Exception as e: verbose_logger.warning("Failed to process image in tool response: %s", e) - elif content_type in ("file", "input_file"): + elif content_type in ("file", "input_file"): # pyright: ignore[reportUnnecessaryContains] # loose runtime dict # Extract file for inline_data (for tool results with PDF, audio, video, etc.) file_data = content.get("file_data", "") if not file_data: @@ -1564,14 +1564,23 @@ def convert_to_anthropic_tool_result( } """ anthropic_content: ( - str | list[AnthropicMessagesToolResultContent | AnthropicMessagesImageParam | AnthropicMessagesDocumentParam] + str + | list[ + AnthropicMessagesToolResultContent + | AnthropicMessagesImageParam + | AnthropicMessagesDocumentParam + | ToolReference + ] ) = "" if isinstance(message["content"], str): anthropic_content = message["content"] elif isinstance(message["content"], list): content_list: Final = message["content"] anthropic_content_list: list[ - AnthropicMessagesToolResultContent | AnthropicMessagesImageParam | AnthropicMessagesDocumentParam + AnthropicMessagesToolResultContent + | AnthropicMessagesImageParam + | AnthropicMessagesDocumentParam + | ToolReference ] = [] for content in content_list: if content["type"] == "text": @@ -1614,6 +1623,8 @@ def convert_to_anthropic_tool_result( original_content_element=content, ) anthropic_content_list.append(cast(AnthropicMessagesImageParam, _anthropic_image_param)) + elif content["type"] == "tool_reference": + anthropic_content_list.append(ToolReference(type="tool_reference", tool_name=content["tool_name"])) elif content["type"] == "file": file_content = cast(ChatCompletionFileObject, content) _file_block = anthropic_process_openai_file_message(file_content) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 109017bda27..0167dc73493 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -1,8 +1,8 @@ import copy import hashlib import json -from collections.abc import AsyncIterator, Iterator, Mapping -from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, cast +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence +from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast import litellm from litellm.llms.anthropic.experimental_pass_through.utils import ( @@ -125,7 +125,9 @@ from litellm.types.llms.openai import ( ChatCompletionToolMessage, ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk, + ChatCompletionToolReferenceObject, ChatCompletionUserMessage, + ToolMessageContentPart, ) from litellm.types.utils import Choices, ModelResponse, StreamingChoices, Usage @@ -134,6 +136,8 @@ from .streaming_iterator import AnthropicStreamWrapper if TYPE_CHECKING: from litellm.types.llms.anthropic import ContentBlockContentBlockDict +ToolResultContent: TypeAlias = str | list[ToolMessageContentPart] + class AnthropicAdapter: def __init__(self) -> None: @@ -411,90 +415,13 @@ class LiteLLMAnthropicMessagesAdapter: self._add_cache_control_if_applicable(content, doc_obj, model) new_user_content_list.append(doc_obj) elif content.get("type") == "tool_result": - if "content" not in content: - tool_result = ChatCompletionToolMessage( - role="tool", - tool_call_id=content.get("tool_use_id", ""), - content="", - ) - self._add_cache_control_if_applicable(content, tool_result, model) - tool_message_list.append(tool_result) - elif isinstance(content.get("content"), str): - tool_result = ChatCompletionToolMessage( - role="tool", - tool_call_id=content.get("tool_use_id", ""), - content=str(content.get("content", "")), - ) - self._add_cache_control_if_applicable(content, tool_result, model) - tool_message_list.append(tool_result) - elif isinstance(content.get("content"), list): - # Combine all content items into a single tool message - # to avoid creating multiple tool_result blocks with the same ID - # (each tool_use must have exactly one tool_result) - content_items = list(content.get("content", [])) - - # Single-item text keeps the backward-compatible string format; a single - # image or document becomes a structured image_url part - if len(content_items) == 1: - c = content_items[0] - if isinstance(c, str): - tool_result = ChatCompletionToolMessage( - role="tool", - tool_call_id=content.get("tool_use_id", ""), - content=c, - ) - self._add_cache_control_if_applicable(content, tool_result, model) - tool_message_list.append(tool_result) - elif isinstance(c, dict): - if c.get("type") == "text": - tool_result = ChatCompletionToolMessage( - role="tool", - tool_call_id=content.get("tool_use_id", ""), - content=c.get("text", ""), - ) - self._add_cache_control_if_applicable(content, tool_result, model) - tool_message_list.append(tool_result) - elif c.get("type") in ("image", "document"): - image_part = self._tool_result_image_part(c.get("source")) - tool_result = ChatCompletionToolMessage( - role="tool", - tool_call_id=content.get("tool_use_id", ""), - content=[image_part] # mutable-ok: content must be a json list - if image_part - else "", - ) - self._add_cache_control_if_applicable(content, tool_result, model) - tool_message_list.append(tool_result) - else: - # For multiple content items, combine into a single tool message - # with list content to preserve all items while having one tool_use_id - combined_content_parts: list[ - ChatCompletionTextObject | ChatCompletionImageObject - ] = [] - for c in content_items: - if isinstance(c, str): - combined_content_parts.append(ChatCompletionTextObject(type="text", text=c)) - elif isinstance(c, dict): - if c.get("type") == "text": - combined_content_parts.append( - ChatCompletionTextObject( - type="text", - text=c.get("text", ""), - ) - ) - elif c.get("type") in ("image", "document"): - image_part = self._tool_result_image_part(c.get("source")) - if image_part: - combined_content_parts.append(image_part) - # Create a single tool message with combined content - if combined_content_parts: - tool_result = ChatCompletionToolMessage( - role="tool", - tool_call_id=content.get("tool_use_id", ""), - content=combined_content_parts, - ) - self._add_cache_control_if_applicable(content, tool_result, model) - tool_message_list.append(tool_result) + tool_result = ChatCompletionToolMessage( + role="tool", + tool_call_id=content.get("tool_use_id", ""), + content=self._tool_result_content(content.get("content")), + ) + self._add_cache_control_if_applicable(content, tool_result, model) + tool_message_list.append(tool_result) if len(tool_message_list) > 0: new_messages.extend(tool_message_list) @@ -1209,6 +1136,39 @@ class LiteLLMAnthropicMessagesAdapter: return None + def _tool_result_content(self, raw_content: object) -> ToolResultContent: + if isinstance(raw_content, str): + return raw_content + if not isinstance(raw_content, list): + return "" + items: Final = cast(Sequence[object], raw_content) # cast-ok: untrusted client payload + parts: Final = tuple(part for part in (self._tool_result_part(item) for item in items) if part is not None) + match parts: + case (): + return "" + case ({"type": "text", "text": str(text)},): + return text + case _: + return list(parts) # mutable-ok: content must be a json list + + def _tool_result_part(self, item: object) -> ToolMessageContentPart | None: + if isinstance(item, str): + return ChatCompletionTextObject(type="text", text=item) + if not isinstance(item, dict): + return None + block: Final = cast(Mapping[str, object], item) # cast-ok: untrusted client payload + match block.get("type"): + case "text": + return ChatCompletionTextObject(type="text", text=str(block.get("text") or "")) + case "image" | "document": + return self._tool_result_image_part(block.get("source")) + case "tool_reference": + return ChatCompletionToolReferenceObject( + type="tool_reference", tool_name=str(block.get("tool_name") or "") + ) + case _: + return None + def _tool_result_image_part(self, image_source: object) -> ChatCompletionImageObject | None: if not isinstance(image_source, dict): return None diff --git a/litellm/llms/gemini/chat/transformation.py b/litellm/llms/gemini/chat/transformation.py index bc12995057e..1a67b33665b 100644 --- a/litellm/llms/gemini/chat/transformation.py +++ b/litellm/llms/gemini/chat/transformation.py @@ -8,7 +8,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( from litellm.litellm_core_utils.prompt_templates.image_handling import ( convert_url_to_base64, ) -from litellm.types.llms.openai import AllMessageValues, ChatCompletionFileObject +from litellm.types.llms.openai import AllMessageValues, ChatCompletionFileObject, ChatCompletionImageObject from litellm.types.llms.vertex_ai import ContentType, PartType from litellm.utils import supports_reasoning @@ -16,6 +16,13 @@ from ...vertex_ai.gemini.transformation import _gemini_convert_messages_with_his from ...vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig +def _image_url_fields(img_element: ChatCompletionImageObject) -> tuple[str | None, str | None, str | None]: + image_value: Final = img_element.get("image_url") + if isinstance(image_value, dict): + return image_value.get("url"), image_value.get("format"), image_value.get("detail") + return image_value, None, None + + class GoogleAIStudioGeminiConfig(VertexGeminiConfig): """ Reference: https://ai.google.dev/api/rest/v1beta/GenerationConfig @@ -118,16 +125,8 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig): _parts: list[PartType] = [] for element in _message_content: if element.get("type") == "image_url": - img_element = element - _image_url: str | None = None - format: str | None = None - detail: str | None = None - if isinstance(img_element.get("image_url"), dict): - _image_url = img_element["image_url"].get("url") - format = img_element["image_url"].get("format") - detail = img_element["image_url"].get("detail") - else: - _image_url = img_element.get("image_url") + img_element = cast(ChatCompletionImageObject, element) # cast-ok: runtime type tag checked + _image_url, format, detail = _image_url_fields(img_element) if _image_url and "https://" in _image_url: image_obj = convert_to_anthropic_image_obj(_image_url, format=format) converted_image_url = convert_generic_image_chunk_to_openai_image_obj(image_obj) diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index 0d9577669a4..0c95fd4df07 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -292,7 +292,7 @@ class MistralConfig(OpenAIGPTConfig): file_id = file_content.get("file", {}).get("file_id") if file_id: # Replace 'file' with 'file_id' - file_content["file_id"] = file_id + file_content["file_id"] = file_id # pyright: ignore[reportGeneralTypeIssues] # legacy in-place rewrite of the block shape file_content.pop("file", None) return messages diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index f127366cc21..eb6cf41285f 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -324,7 +324,12 @@ class AnthropicMessagesToolResultParam(TypedDict, total=False): is_error: bool content: ( str - | Iterable[AnthropicMessagesToolResultContent | AnthropicMessagesImageParam | AnthropicMessagesDocumentParam] + | Iterable[ + AnthropicMessagesToolResultContent + | AnthropicMessagesImageParam + | AnthropicMessagesDocumentParam + | ToolReference + ] ) cache_control: dict | ChatCompletionCachedContent | None diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 45f6b5c55a9..beb788b64c3 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -1,7 +1,7 @@ from collections.abc import Iterable, Mapping from enum import Enum from os import PathLike -from typing import IO, Any, Final, Literal, Optional, Union +from typing import IO, Any, Final, Literal, Optional, TypeAlias, Union import httpx from openai import Omit @@ -820,9 +820,21 @@ class ChatCompletionAssistantMessage(OpenAIChatCompletionAssistantMessage, total reasoning_items: list[ChatCompletionReasoningItem] | None +class ChatCompletionToolReferenceObject(TypedDict): + """Anthropic tool-search result block, carried through untouched so it survives a round trip.""" + + type: Literal["tool_reference"] # writable-ok: Pydantic warns on ReadOnly TypedDict fields + tool_name: str # writable-ok: Pydantic warns on ReadOnly TypedDict fields + + +ToolMessageContentPart: TypeAlias = ( + ChatCompletionTextObject | ChatCompletionImageObject | ChatCompletionToolReferenceObject +) + + class ChatCompletionToolMessage(TypedDict): role: Literal["tool"] - content: str | Iterable[ChatCompletionTextObject | ChatCompletionImageObject] + content: str | Iterable[ToolMessageContentPart] # writable-ok: Pydantic warns on ReadOnly TypedDict fields tool_call_id: str diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 6ca48ce63b8..21b60d7a216 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -3903,3 +3903,39 @@ def test_stored_reasoning_items_win_over_thinking_blocks(): reasoning_items = [item for item in input_items if item.get("type") == "reasoning"] assert len(reasoning_items) == 1 assert reasoning_items[0]["id"] == "rs_real" + + +def test_convert_chat_completion_messages_to_responses_api_tool_result_with_tool_reference(): + """Tool-search tool_reference blocks have no Responses API equivalent: skip them, never stringify them.""" + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + messages = [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": {"name": "ToolSearch", "arguments": '{"query": "web"}'}, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_abc123", + "content": [ + {"type": "tool_reference", "tool_name": "WebFetch"}, + {"type": "text", "text": "1 tool found"}, + ], + }, + ] + + response, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + + function_call_output = next(item for item in response if item.get("type") == "function_call_output") + assert function_call_output["output"] == [{"type": "input_text", "text": "1 tool found"}] diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 6265779b90d..72d26f31c60 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -3578,3 +3578,52 @@ async def test_bedrock_converse_pdf_only_user_message_gets_text_block_async(): assert len(result) == 1 assert any("document" in block for block in result[0]["content"]) assert _text_blocks(result[0]) == [BEDROCK_DOCUMENT_PLACEHOLDER_TEXT] + + +def test_convert_to_anthropic_tool_result_keeps_tool_reference_blocks(): + from litellm.litellm_core_utils.prompt_templates.factory import convert_to_anthropic_tool_result + + result = convert_to_anthropic_tool_result( + { + "role": "tool", + "tool_call_id": "toolu_01", + "content": [ + {"type": "text", "text": "loaded"}, + {"type": "tool_reference", "tool_name": "WebFetch"}, + ], + } + ) + + assert result == { + "type": "tool_result", + "tool_use_id": "toolu_01", + "content": [ + {"type": "text", "text": "loaded"}, + {"type": "tool_reference", "tool_name": "WebFetch"}, + ], + } + + +def test_convert_gemini_tool_call_result_answers_tool_reference_only_result(): + """Every Gemini function call needs a function response, even when the tool result carries no text. + Fixes: https://github.com/BerriAI/litellm/issues/37462 + """ + result = convert_to_gemini_tool_call_result( + message=ChatCompletionToolMessage( + role="tool", + tool_call_id="toolu_01", + content=[{"type": "tool_reference", "tool_name": "WebFetch"}], + ), + last_message_with_tool_calls={ + "role": "assistant", + "tool_calls": [ + { + "id": "toolu_01", + "type": "function", + "function": {"name": "ToolSearch", "arguments": '{"query": "select:WebFetch"}'}, + } + ], + }, + ) + + assert result == {"function_response": {"name": "ToolSearch", "response": {"content": ""}}} diff --git a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py index 2b392456763..0f51b321e59 100644 --- a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py @@ -1818,3 +1818,72 @@ class TestAnthropicMessagesScanOnlyToolResults: assert guardrail.captured_inputs is not None assert guardrail.captured_inputs.get("images") == ["TOOL_IMG"] + + +class TestStructuredWriteBackKeepsToolResults: + """A guardrail rewrite must never leave a tool_use without its tool_result (Claude Code ToolSearch, LIT-6103).""" + + @staticmethod + def _claude_code_tool_search_turns(tool_result_content): + return [ + {"role": "user", "content": "load WebFetch for bob@example.com"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01", + "name": "ToolSearch", + "input": {"query": "select:WebFetch"}, + } + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}, + {"type": "text", "text": "Now fetch the page."}, + ], + }, + ] + + @staticmethod + def _blocks(message): + return message["content"] if isinstance(message["content"], list) else [] + + @pytest.mark.parametrize( + ("tool_result_content", "expected_written_back_content"), + [ + ( + [{"type": "tool_reference", "tool_name": "WebFetch"}], + [{"type": "tool_reference", "tool_name": "WebFetch"}], + ), + ([], ""), + ], + ids=["tool_reference", "empty"], + ) + async def test_tool_result_stays_right_after_its_tool_use( + self, tool_result_content, expected_written_back_content + ): + handler = AnthropicMessagesHandler() + data = {"model": "claude-fable-5", "messages": self._claude_code_tool_search_turns(tool_result_content)} + + await handler.process_input_messages(data=data, guardrail_to_apply=MockStructuredMaskingGuardrail()) + + serialized = json.dumps(data["messages"]) + assert "bob@example.com" not in serialized + assert "" in serialized + + messages = data["messages"] + tool_use_index = next( + i for i, m in enumerate(messages) if any(b.get("type") == "tool_use" for b in self._blocks(m)) + ) + answer = messages[tool_use_index + 1] + assert answer["role"] == "user" + assert answer["content"][0] == { + "type": "tool_result", + "tool_use_id": "toolu_01", + "content": expected_written_back_content, + } + later_blocks = [b for m in messages[tool_use_index + 1 :] for b in self._blocks(m)] + assert {"type": "text", "text": "Now fetch the page."} in later_blocks diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index ee09baf28b6..9c88d7a67bb 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -3997,3 +3997,72 @@ def test_translate_anthropic_messages_to_openai_carries_midturn_system_prompt_ca assert result == [ {"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]} ] + + +def _tool_reference_block(tool_name="WebFetch"): + return {"type": "tool_reference", "tool_name": tool_name} + + +def test_tool_result_tool_reference_is_carried_through_untouched(): + adapter = LiteLLMAnthropicMessagesAdapter() + + result = adapter.translate_anthropic_messages_to_openai( + messages=[ + _anthropic_tool_use_turn("toolu_01"), + _anthropic_tool_result_turn({"toolu_01": [_tool_reference_block()]}), + ] + ) + + assert [m["role"] for m in result] == ["assistant", "tool"] + assert result[1]["tool_call_id"] == "toolu_01" + assert result[1]["content"] == [{"type": "tool_reference", "tool_name": "WebFetch"}] + + +def test_tool_result_text_beside_tool_reference_keeps_both_parts_in_order(): + adapter = LiteLLMAnthropicMessagesAdapter() + + result = adapter.translate_anthropic_messages_to_openai( + messages=[ + _anthropic_tool_use_turn("toolu_01"), + _anthropic_tool_result_turn( + {"toolu_01": [{"type": "text", "text": "loaded"}, _tool_reference_block("Grep")]} + ), + ] + ) + + assert result[1]["content"] == [ + {"type": "text", "text": "loaded"}, + {"type": "tool_reference", "tool_name": "Grep"}, + ] + + +@pytest.mark.parametrize( + "tool_result_content", + [ + [], + None, + "", + {"not": "a list"}, + [{"type": "future_block", "payload": 1}], + [{"type": "search_result", "source": "https://example.com", "title": "t", "content": []}], + ], + ids=["empty_list", "null", "empty_string", "non_list", "unknown_block", "search_result_only"], +) +def test_tool_result_without_translatable_content_still_answers_its_tool_use(tool_result_content): + adapter = LiteLLMAnthropicMessagesAdapter() + + result = adapter.translate_anthropic_messages_to_openai( + messages=[ + _anthropic_tool_use_turn("toolu_01"), + { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}], + }, + ] + ) + + assert result == [ + result[0], + {"role": "tool", "tool_call_id": "toolu_01", "content": ""}, + ] + assert result[0]["role"] == "assistant" diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 6fd7828906c..94f671d7c3c 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -3,7 +3,7 @@ "limit": 22733 }, "LIT002": { - "limit": 26863 + "limit": 26860 }, "LIT003": { "limit": 269 @@ -33,6 +33,6 @@ "limit": 5583 }, "LIT012": { - "limit": 4510 + "limit": 4509 } } diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4ccf7b59fbc..aafbb2eb6b2 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -23643,7 +23643,7 @@ export interface components { /** ChatCompletionToolMessage */ ChatCompletionToolMessage: { /** Content */ - content: string | (components["schemas"]["ChatCompletionTextObject"] | components["schemas"]["ChatCompletionImageObject"])[]; + content: string | (components["schemas"]["ChatCompletionTextObject"] | components["schemas"]["ChatCompletionImageObject"] | components["schemas"]["ChatCompletionToolReferenceObject"])[]; /** * Role * @constant @@ -23672,6 +23672,19 @@ export interface components { /** Strict */ strict?: boolean; }; + /** + * ChatCompletionToolReferenceObject + * @description Anthropic tool-search result block, carried through untouched so it survives a round trip. + */ + ChatCompletionToolReferenceObject: { + /** Tool Name */ + tool_name: string; + /** + * Type + * @constant + */ + type: "tool_reference"; + }; /** ChatCompletionUserMessage */ ChatCompletionUserMessage: { cache_control?: components["schemas"]["ChatCompletionCachedContent"]; From edde2e50efe848f0dfa600af5a5d936e6443e2c7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:43:41 -0700 Subject: [PATCH 2/2] fix(openai): drop tool_reference parts from tool messages at the chat boundary OpenAI's chat completions API rejects tool_reference content parts in role tool messages, so a mixed text plus reference tool result carried through the Anthropic adapter turned a previously working request into a 400 on chat-routed OpenAI and Azure deployments. Strip the reference parts there, keeping a reference-only result as an empty-text tool message so the preceding tool_call stays answered, mirroring the Responses bridge skip. --- .../prompt_templates/common_utils.py | 40 +++++++++++ litellm/llms/azure/chat/gpt_transformation.py | 4 +- .../llms/openai/chat/gpt_transformation.py | 4 +- ...ore_utils_prompt_templates_common_utils.py | 67 +++++++++++++++++++ .../test_azure_chat_gpt_transformation.py | 29 ++++++++ .../chat/test_openai_gpt_transformation.py | 63 +++++++++++++++++ 6 files changed, 205 insertions(+), 2 deletions(-) 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)."""