mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
Merge e698b09442 into c80f1c2ec8
This commit is contained in:
commit
c668d14fac
8 changed files with 244 additions and 15 deletions
|
|
@ -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 _:
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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], ...])
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue