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"];