mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
commit
dc40377959
2 changed files with 190 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue