diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index 6d47d0de19f..d9d62e0719f 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -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: diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py index 8225e7cff39..44c956c8ee8 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py @@ -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())