diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 7654b56ad9a..ced17d11681 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -18,7 +18,7 @@ from copy import deepcopy from dataclasses import dataclass from itertools import chain, repeat from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Protocol, cast, overload, runtime_checkable +from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast, overload, runtime_checkable from pydantic import TypeAdapter, ValidationError from typing_extensions import ReadOnly, TypedDict, assert_never @@ -35,6 +35,7 @@ from litellm.llms.anthropic.pass_through.stream_assembly import ( build_complete_streaming_response, build_usage_only_response_from_chunks, ) +from litellm.llms.anthropic.pass_through.utils import object_items, search_result_fields, search_result_inner_texts from litellm.llms.base_llm.guardrail_translation.base_translation import ( BaseTranslation, StreamingScanKey, @@ -109,6 +110,26 @@ class ToolResultBlockTextTarget: block_idx: int +@dataclass(frozen=True, slots=True) +class ToolResultSearchResultTextTarget: + """A text block inside a search_result block inside a tool_result.""" + + msg_idx: int + content_idx: int + block_idx: int + inner_idx: int + + +@dataclass(frozen=True, slots=True) +class ToolResultSearchResultFieldTarget: + """The title or source string of a search_result block inside a tool_result.""" + + msg_idx: int + content_idx: int + block_idx: int + field: Literal["title", "source"] + + @dataclass(frozen=True, slots=True) class SystemStringTarget: pass @@ -125,7 +146,14 @@ class ToolUseInputTarget: content_idx: int -MessageTextTarget = MessageContentTarget | ContentBlockTextTarget | ToolResultStringTarget | ToolResultBlockTextTarget +MessageTextTarget = ( + MessageContentTarget + | ContentBlockTextTarget + | ToolResultStringTarget + | ToolResultBlockTextTarget + | ToolResultSearchResultTextTarget + | ToolResultSearchResultFieldTarget +) InputWriteBackTarget = SystemStringTarget | SystemBlockTextTarget | MessageTextTarget @@ -223,6 +251,12 @@ def _write_back_message_text(message: _WritableMessage, target: MessageTextTarge case ToolResultBlockTextTarget(content_idx=content_idx, block_idx=block_idx): if isinstance(content, list): content[content_idx]["content"][block_idx]["text"] = response + case ToolResultSearchResultTextTarget(content_idx=content_idx, block_idx=block_idx, inner_idx=inner_idx): + if isinstance(content, list): + content[content_idx]["content"][block_idx]["content"][inner_idx]["text"] = response + case ToolResultSearchResultFieldTarget(content_idx=content_idx, block_idx=block_idx, field=field): + if isinstance(content, list): + content[content_idx]["content"][block_idx][field] = response case _: assert_never(target) @@ -1038,17 +1072,39 @@ class AnthropicMessagesHandler(BaseTranslation): blocks: Final = tuple( (block_idx, block) for block_idx, block in enumerate(tool_result_content) if isinstance(block, dict) ) + tool_result_content_items: Final = object_items(content_item.get("content")) return ExtractedInput( - scanned=tuple( - ScannedText(block["text"], ToolResultBlockTextTarget(msg_idx, content_idx, block_idx)) - for block_idx, block in blocks - if isinstance(block.get("text"), str) + scanned=( + *( + ScannedText(block["text"], ToolResultBlockTextTarget(msg_idx, content_idx, block_idx)) + for block_idx, block in blocks + if isinstance(block.get("text"), str) + ), + *chain.from_iterable( + cls._search_result_texts(block, msg_idx, content_idx, block_idx) + for block_idx, block in enumerate(tool_result_content_items) + ), ), images=tuple( image for _, block in blocks if block.get("type") == "image" for image in cls._image_sources(block) ), ) + @staticmethod + def _search_result_texts(block: object, msg_idx: int, content_idx: int, block_idx: int) -> tuple[ScannedText, ...]: + """The title, source and text blocks a search_result carries, each written back to its own place.""" + fields: Final = search_result_fields(block) + return ( + *( + ScannedText(value, ToolResultSearchResultFieldTarget(msg_idx, content_idx, block_idx, field)) + for field, value in fields + ), + *( + ScannedText(text, ToolResultSearchResultTextTarget(msg_idx, content_idx, block_idx, inner_idx)) + for inner_idx, text in search_result_inner_texts(block) + ), + ) + @staticmethod def _image_sources(block: Mapping[str, object]) -> tuple[str, ...]: """Normalize an Anthropic image block into strings a guardrail can read. @@ -1096,7 +1152,9 @@ class AnthropicMessagesHandler(BaseTranslation): MessageContentTarget() | ContentBlockTextTarget() | ToolResultStringTarget() - | ToolResultBlockTextTarget() as message_target + | ToolResultBlockTextTarget() + | ToolResultSearchResultTextTarget() + | ToolResultSearchResultFieldTarget() as message_target ): _write_back_message_text(messages[message_target.msg_idx], message_target, guardrail_response) case _: diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index 0d3e48c49fd..3ce4b9985d1 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -13,6 +13,7 @@ from litellm.llms.anthropic.pass_through.utils import ( is_reasoning_auto_summary_enabled, json_copy_or_original, prompt_cache_key_from_user_id, + search_result_text, ) from litellm.responses.utils import auto_reasoning_summary @@ -1337,6 +1338,8 @@ class LiteLLMAnthropicMessagesAdapter: return ChatCompletionTextObject(type="text", text=str(block.get("text") or "")) case "image" | "document": return self._tool_result_image_part(block.get("source")) + case "search_result": + return self._tool_result_search_result_part(block) case "tool_reference": return ChatCompletionToolReferenceObject( type="tool_reference", tool_name=str(block.get("tool_name") or "") @@ -1352,6 +1355,11 @@ class LiteLLMAnthropicMessagesAdapter: return None return ChatCompletionImageObject(type="image_url", image_url=ChatCompletionImageUrlObject(url=openai_image_url)) + @staticmethod + def _tool_result_search_result_part(block: Mapping[str, object]) -> ChatCompletionTextObject | None: + rendered: Final = search_result_text(block) + return ChatCompletionTextObject(type="text", text=rendered) if rendered else None + def _translate_openai_content_to_anthropic( self, choices: list[Choices], diff --git a/litellm/llms/anthropic/pass_through/convertible_blocks.py b/litellm/llms/anthropic/pass_through/convertible_blocks.py index 543a6efd4c4..1c07205c608 100644 --- a/litellm/llms/anthropic/pass_through/convertible_blocks.py +++ b/litellm/llms/anthropic/pass_through/convertible_blocks.py @@ -16,7 +16,7 @@ _CONVERTIBLE_BLOCK_TYPES: Final = MappingProxyType( } ) _SOURCED_BLOCK_TYPES: Final = frozenset({"image", "document"}) -_TOOL_RESULT_BLOCK_TYPES: Final = frozenset({"text", "image", "document"}) +_TOOL_RESULT_BLOCK_TYPES: Final = frozenset({"text", "image", "document", "search_result"}) _JSON_MESSAGES: Final = TypeAdapter(tuple[Mapping[str, JsonValue], ...]) diff --git a/litellm/llms/anthropic/pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/pass_through/responses_adapters/transformation.py index fb611b64dda..e8845c0b8e2 100644 --- a/litellm/llms/anthropic/pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/responses_adapters/transformation.py @@ -28,6 +28,7 @@ from litellm.llms.anthropic.pass_through.utils import ( is_reasoning_auto_summary_enabled, json_copy_or_original, prompt_cache_key_from_user_id, + search_result_text, ) from litellm.types.llms.anthropic import ( AllAnthropicPassThroughMessageValues, @@ -207,6 +208,16 @@ class LiteLLMAnthropicToResponsesAPIAdapter: ], } + @staticmethod + def _tool_result_inner_text(block: Mapping[str, object]) -> str: + match block.get("type"): + case "text": + return str(block.get("text", "")) + case "search_result": + return search_result_text(block) + case _: + return "" + @staticmethod def _tool_result_output_value( output_text: str, @@ -392,7 +403,9 @@ class LiteLLMAnthropicToResponsesAPIAdapter: output_text = inner elif isinstance(inner, list): parts = [ - c.get("text", "") for c in inner if isinstance(c, dict) and c.get("type") == "text" + text + for c in inner + if isinstance(c, dict) and (text := self._tool_result_inner_text(c)) ] output_text = "\n".join(parts) if covered_image_parts or later_image_parts: diff --git a/litellm/llms/anthropic/pass_through/utils.py b/litellm/llms/anthropic/pass_through/utils.py index f4b587842bf..8d4590829a3 100644 --- a/litellm/llms/anthropic/pass_through/utils.py +++ b/litellm/llms/anthropic/pass_through/utils.py @@ -2,7 +2,7 @@ import json import os from collections.abc import Mapping from types import MappingProxyType -from typing import TYPE_CHECKING, Final, TypeVar +from typing import TYPE_CHECKING, Final, Literal, TypeVar from pydantic import ConfigDict, TypeAdapter, ValidationError from typing_extensions import ReadOnly @@ -224,3 +224,52 @@ def normalize_reasoning_effort_value( accepted_tiers: Final = tuple(level for level in supported if level != _THINKING_OFF) return next((level for level in (*chain, *accepted_tiers) if level in supported), chain[-1]) + + +_OBJECT_ITEMS: Final = TypeAdapter(tuple[object, ...]) + + +def object_items(value: object) -> tuple[object, ...]: + return _OBJECT_ITEMS.validate_python(value) if isinstance(value, list) else () + + +_OBJECT_FIELDS: Final = TypeAdapter(dict[object, object]) + + +def _object_fields(value: object) -> Mapping[object, object]: + return _OBJECT_FIELDS.validate_python(value) if isinstance(value, dict) else MappingProxyType({}) + + +def search_result_inner_texts(block: object) -> tuple[tuple[int, str], ...]: + """Each non-empty text a search_result block carries, with its index in the block's content.""" + fields: Final = _object_fields(block) + if fields.get("type") != "search_result": + return () + return tuple( + (index, text) + for index, item in enumerate(object_items(fields.get("content"))) + if isinstance(text := _object_fields(item).get("text"), str) and text + ) + + +def search_result_fields(block: object) -> tuple[tuple[Literal["title", "source"], str], ...]: + """The non-empty title and source strings of a search_result block.""" + fields: Final = _object_fields(block) + if fields.get("type") != "search_result": + return () + title: Final = fields.get("title") + source: Final = fields.get("source") + return ( + *((("title", title),) if isinstance(title, str) and title else ()), + *((("source", source),) if isinstance(source, str) and source else ()), + ) + + +def search_result_text(block: Mapping[str, object]) -> str: + """A search_result block as plain text: its title and source lines, then its text blocks.""" + labels: Final = {"title": "Title", "source": "Source"} + lines: Final = ( + *(f"{labels[field]}: {value}" for field, value in search_result_fields(block)), + *(text for _, text in search_result_inner_texts(block)), + ) + return "\n".join(lines) diff --git a/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py b/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py index 0aca60351bf..a2799f99a8b 100644 --- a/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py +++ b/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py @@ -1964,6 +1964,48 @@ class TestAnthropicMessagesToolResultScanning: assert blocks[0]["text"] == "first [BLOCKED] block" assert blocks[1]["text"] == "second [BLOCKED] block" + @pytest.mark.asyncio + async def test_search_result_inside_tool_result_is_scanned_and_written_back(self): + handler = AnthropicMessagesHandler() + guardrail = MockCanaryMaskingGuardrail() + messages = [ + {"role": "user", "content": "search the docs"}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "tu1", + "content": [ + { + "type": "search_result", + "source": "https://example.com/POISON", + "title": "Docs about POISON", + "content": [ + {"type": "text", "text": "first POISON snippet"}, + {"type": "text", "text": "second POISON snippet"}, + ], + }, + {"type": "text", "text": "trailing POISON text"}, + ], + } + ], + }, + ] + + await handler.process_input_messages(data=self._data(messages), guardrail_to_apply=guardrail) + + assert "first POISON snippet" in guardrail.seen_texts + assert "second POISON snippet" in guardrail.seen_texts + blocks = messages[1]["content"][0]["content"] + assert blocks[0]["title"] == "Docs about [BLOCKED]" + assert blocks[0]["source"] == "https://example.com/[BLOCKED]" + assert [inner["text"] for inner in blocks[0]["content"]] == [ + "first [BLOCKED] snippet", + "second [BLOCKED] snippet", + ] + assert blocks[1]["text"] == "trailing [BLOCKED] text" + @pytest.mark.asyncio async def test_write_back_targets_stay_aligned_across_mixed_shapes(self): """The write-back is positional, so a single mis-indexed target silently diff --git a/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 9e0bd5c60b8..ec890916a63 100644 --- a/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -4688,7 +4688,7 @@ def test_tool_result_text_beside_tool_reference_keeps_both_parts_in_order(): "", {"not": "a list"}, [{"type": "future_block", "payload": 1}], - [{"type": "search_result", "source": "https://example.com", "title": "t", "content": []}], + [{"type": "search_result", "content": []}], ], ids=["empty_list", "null", "empty_string", "non_list", "unknown_block", "search_result_only"], ) @@ -5235,3 +5235,63 @@ def test_eager_input_streaming_tool_reaches_bedrock_converse_as_beta(): assert data["additionalModelRequestFields"]["anthropic_beta"] == ["fine-grained-tool-streaming-2025-05-14"] assert data["toolConfig"]["tools"][0]["toolSpec"]["inputSchema"]["json"] == EAGER_INPUT_SCHEMA + + +def test_translate_anthropic_to_openai_keeps_search_result_tool_output(): + """A tool_result carrying search_result blocks (RAG citations) must reach the model as text. + + The adapter only translated text/image/document/tool_reference blocks, so a + search_result-only tool result became an empty tool message and the retrieved + content never reached the model. + """ + openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai( + { + "model": "claude-sonnet-4-5", + "max_tokens": 100, + "messages": [ + {"role": "user", "content": "How do I configure the timeout?"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_1", + "name": "search_knowledge_base", + "input": {"query": "timeout"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": [ + { + "type": "search_result", + "source": "https://docs.company.com/product-guide", + "title": "Product Configuration Guide", + "content": [ + { + "type": "text", + "text": "The default timeout is 30 seconds.", + } + ], + "citations": {"enabled": True}, + } + ], + } + ], + }, + ], + } + ) + + tool_message: Final = openai_request["messages"][2] + assert tool_message["role"] == "tool" + assert tool_message["content"] == ( + "Title: Product Configuration Guide\n" + "Source: https://docs.company.com/product-guide\n" + "The default timeout is 30 seconds." + ) diff --git a/tests/unit/llms/anthropic/pass_through/test_convertible_blocks.py b/tests/unit/llms/anthropic/pass_through/test_convertible_blocks.py index b5301b413ff..3935e4c52f7 100644 --- a/tests/unit/llms/anthropic/pass_through/test_convertible_blocks.py +++ b/tests/unit/llms/anthropic/pass_through/test_convertible_blocks.py @@ -117,9 +117,6 @@ _KEPT_INSIDE_TOOL_RESULT: Final[tuple[tuple[Mapping[str, object], str], ...]] = ({"type": "text", "text": "forty-two"}, "forty-two"), ({"type": "image", "source": _PNG}, _PNG["data"]), ({"type": "document", "source": _PDF}, _PDF["data"]), -) - -_DROPPED_INSIDE_TOOL_RESULT: Final[tuple[tuple[Mapping[str, object], str, str], ...]] = ( ( { "type": "search_result", @@ -128,8 +125,10 @@ _DROPPED_INSIDE_TOOL_RESULT: Final[tuple[tuple[Mapping[str, object], str, str], "content": [{"type": "text", "text": "search-snippet"}], }, "search-snippet", - "search_result inside a tool_result", ), +) + +_DROPPED_INSIDE_TOOL_RESULT: Final[tuple[tuple[Mapping[str, object], str, str], ...]] = ( ( {"type": "document", "source": {"type": "text", "data": "text-notes"}}, "text-notes",