Merge pull request #38261 from BerriAI/litellm_fix_responses_tool_result_document_drop

fix(anthropic): carry tool_result document blocks through the /v1/messages responses bridge
This commit is contained in:
Mateo Wang 2026-08-25 15:09:23 -07:00 committed by GitHub
commit dc40377959
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 190 additions and 2 deletions

View file

@ -87,6 +87,51 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
return source.get("url")
return None
@staticmethod
def _translate_anthropic_document_block_to_file_part(
block: Mapping[str, object],
) -> dict[str, str] | None: # mutable-ok: API message payload
"""Convert an Anthropic document block to a Responses input_file part."""
raw_source: Final = block.get("source")
if not isinstance(raw_source, Mapping):
return None
source: Final = cast(Mapping[str, object], raw_source) # cast-ok: untrusted client payload
source_type: Final = source.get("type")
if source_type == "base64":
data: Final = source.get("data")
if not isinstance(data, str) or not data:
return None
raw_media_type: Final = source.get("media_type")
media_type: Final = (
raw_media_type if isinstance(raw_media_type, str) and raw_media_type else "application/pdf"
)
raw_title: Final = block.get("title")
filename: Final = raw_title if isinstance(raw_title, str) and raw_title else "document.pdf"
return { # mutable-ok: API message payload
"type": "input_file",
"filename": filename,
"file_data": f"data:{media_type};base64,{data}",
}
if source_type == "url":
url: Final = source.get("url")
if not isinstance(url, str) or not url:
return None
return {"type": "input_file", "file_url": url} # mutable-ok: API message payload
return None
@staticmethod
def _tool_result_output_value(
output_text: str,
file_parts: tuple[dict[str, str], ...], # mutable-ok: json content parts
) -> str | list[dict[str, str]]: # mutable-ok: API message payload
"""Plain string output, or a part list when document file parts are present."""
if not file_parts:
return output_text
text_parts: Final = (
[{"type": "input_text", "text": output_text}] if output_text else [] # mutable-ok: API message payload
)
return [*text_parts, *file_parts] # mutable-ok: API message payload
@staticmethod
def _translate_midturn_system_content_to_responses(
content: str | Iterable[AnthropicSystemMessageContent],
@ -226,6 +271,16 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
elif btype == "tool_result":
tool_use_id = block.get("tool_use_id", "")
inner = block.get("content")
document_candidates = (
tuple(
self._translate_anthropic_document_block_to_file_part(c)
for c in inner
if isinstance(c, dict) and c.get("type") == "document"
)
if isinstance(inner, list)
else ()
)
tool_file_parts = tuple(part for part in document_candidates if part is not None)
if inner is None:
output_text = ""
elif isinstance(inner, str):
@ -258,7 +313,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
{
"type": "function_call_output",
"call_id": tool_use_id,
"output": output_text,
"output": self._tool_result_output_value(output_text, tool_file_parts),
}
)
if tool_image_parts:

View file

@ -16,7 +16,10 @@ from litellm.constants import (
DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET,
)
from litellm.litellm_core_utils.prompt_templates.common_utils import TOOL_RESULT_IMAGE_BOUNDARY
from litellm.litellm_core_utils.prompt_templates.common_utils import (
TOOL_RESULT_IMAGE_BOUNDARY,
TOOL_RESULT_IMAGE_PLACEHOLDER,
)
from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import (
LiteLLMAnthropicToResponsesAPIAdapter,
)
@ -1555,6 +1558,136 @@ class TestToolResultImages:
assert self._input_images(items) == []
class TestToolResultDocuments:
"""Documents inside tool_result blocks must survive translation (LIT-6135):
the function_call_output output becomes a list of parts carrying the joined
text as input_text and each document as an input_file. Without documents the
output stays the plain string it always was."""
PDF_B64 = "JVBERi0xLjQKJSBQT05H"
PDF_DATA_URI = "data:application/pdf;base64,JVBERi0xLjQKJSBQT05H"
PDF_URL = "https://example.com/report.pdf"
PNG_B64 = "iVBORw0KGgoAAAANSUhEUg=="
def _messages(self, tool_result_content):
return [
{"role": "user", "content": "read the pdf"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_01", "name": "read", "input": {}}],
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}
],
},
]
def _translate(self, tool_result_content):
return _ADAPTER.translate_messages_to_responses_input(self._messages(tool_result_content))
@staticmethod
def _tool_output(items):
return next(item for item in items if item.get("type") == "function_call_output")["output"]
def _base64_document(self, **extra):
return {
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": self.PDF_B64},
**extra,
}
def test_text_and_base64_document_produce_part_list(self):
output = self._tool_output(
self._translate([{"type": "text", "text": "PDF file read: mystery.pdf"}, self._base64_document()])
)
assert output == [
{"type": "input_text", "text": "PDF file read: mystery.pdf"},
{"type": "input_file", "filename": "document.pdf", "file_data": self.PDF_DATA_URI},
]
def test_document_only_produces_single_file_part(self):
output = self._tool_output(self._translate([self._base64_document()]))
assert output == [{"type": "input_file", "filename": "document.pdf", "file_data": self.PDF_DATA_URI}]
def test_document_title_becomes_filename(self):
output = self._tool_output(self._translate([self._base64_document(title="quarterly-report.pdf")]))
assert output == [
{"type": "input_file", "filename": "quarterly-report.pdf", "file_data": self.PDF_DATA_URI}
]
def test_url_document_becomes_file_url_part(self):
output = self._tool_output(
self._translate([{"type": "document", "source": {"type": "url", "url": self.PDF_URL}}])
)
assert output == [{"type": "input_file", "file_url": self.PDF_URL}]
def test_document_with_empty_data_falls_back_to_string_output(self):
output = self._tool_output(
self._translate(
[
{"type": "text", "text": "PDF file read"},
{"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": ""}},
]
)
)
assert output == "PDF file read"
def test_document_without_source_dict_keeps_string_output(self):
output = self._tool_output(
self._translate([{"type": "text", "text": "stub"}, {"type": "document", "source": self.PDF_URL}])
)
assert output == "stub"
def test_text_only_tool_result_keeps_plain_string_output(self):
output = self._tool_output(self._translate([{"type": "text", "text": "plain result"}]))
assert output == "plain result"
def test_file_id_source_document_keeps_string_output(self):
output = self._tool_output(
self._translate(
[
{"type": "text", "text": "stub"},
{"type": "document", "source": {"type": "file", "file_id": "file_abc123"}},
]
)
)
assert output == "stub"
def test_url_source_without_url_keeps_string_output(self):
output = self._tool_output(
self._translate([{"type": "text", "text": "stub"}, {"type": "document", "source": {"type": "url"}}])
)
assert output == "stub"
def test_text_image_and_document_mix(self):
items = self._translate(
[
{"type": "text", "text": "captured"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": self.PNG_B64}},
self._base64_document(),
]
)
output = self._tool_output(items)
assert output == [
{"type": "input_text", "text": f"captured\n{TOOL_RESULT_IMAGE_PLACEHOLDER}"},
{"type": "input_file", "filename": "document.pdf", "file_data": self.PDF_DATA_URI},
]
image_message = next(
item
for item in items
if item.get("type") == "message"
and any(part.get("type") == "input_image" for part in item.get("content", []))
)
assert image_message["content"] == [
{"type": "input_text", "text": TOOL_RESULT_IMAGE_BOUNDARY},
{"type": "input_image", "image_url": f"data:image/png;base64,{self.PNG_B64}"},
]
def _contains_key(value, key) -> bool:
if isinstance(value, dict):
return key in value or any(_contains_key(v, key) for v in value.values())