This commit is contained in:
Zhu Lei 2026-10-19 15:44:32 +00:00 • committed by GitHub
commit c668d14fac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 244 additions and 15 deletions

View file

@ -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 _:

View file

@ -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],

View file

@ -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], ...])

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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."
)

View file

@ -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",