From f0ee067aaa3a6d356a9806a4216dd524916ff231 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:20:51 -0700 Subject: [PATCH] fix(anthropic): translate tool_result document blocks in the /v1/messages bridge (cherry picked from commit 17845b4fb01b8ec3d0c90254bece1b802807f77c) --- .../adapters/transformation.py | 6 +- ...al_pass_through_adapters_transformation.py | 71 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 34c2d837127..7a641403d31 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -433,7 +433,7 @@ class LiteLLMAnthropicMessagesAdapter: content_items = list(content.get("content", [])) # Single-item text keeps the backward-compatible string format; a single - # image becomes a structured image_url part + # image or document becomes a structured image_url part if len(content_items) == 1: c = content_items[0] if isinstance(c, str): @@ -453,7 +453,7 @@ class LiteLLMAnthropicMessagesAdapter: ) self._add_cache_control_if_applicable(content, tool_result, model) tool_message_list.append(tool_result) - elif c.get("type") == "image": + elif c.get("type") in ("image", "document"): image_part = self._tool_result_image_part(c.get("source")) tool_result = ChatCompletionToolMessage( role="tool", @@ -481,7 +481,7 @@ class LiteLLMAnthropicMessagesAdapter: text=c.get("text", ""), ) ) - elif c.get("type") == "image": + elif c.get("type") in ("image", "document"): image_part = self._tool_result_image_part(c.get("source")) if image_part: combined_content_parts.append(image_part) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index e4dacc308dc..d0f017cb638 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -1,3 +1,4 @@ +import base64 from typing import Any, cast import pytest @@ -11,6 +12,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( ) from litellm.litellm_core_utils.prompt_templates.factory import ( THOUGHT_SIGNATURE_SEPARATOR, + _bedrock_converse_messages_pt, ) from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( OPENAI_MAX_TOOL_NAME_LENGTH, @@ -3830,6 +3832,75 @@ def test_tool_result_plain_text_unchanged_by_openai_transform(): assert _image_urls_in_user_messages(result) == [] +TOOL_RESULT_PDF_B64 = base64.b64encode(b"%PDF-1.4 minimal regression fixture").decode() + + +def _base64_pdf_block(): + return { + "type": "document", + "source": {"type": "base64", "media_type": "application/pdf", "data": TOOL_RESULT_PDF_B64}, + } + + +def test_tool_result_single_document_kept_as_pdf_data_url(): + adapter = LiteLLMAnthropicMessagesAdapter() + translated = adapter.translate_anthropic_messages_to_openai( + messages=[ + _anthropic_tool_use_turn("toolu_01"), + _anthropic_tool_result_turn({"toolu_01": [_base64_pdf_block()]}), + ] + ) + + tool_messages = [m for m in translated if m.get("role") == "tool"] + assert len(tool_messages) == 1 + assert tool_messages[0]["content"] == [ + { + "type": "image_url", + "image_url": {"url": f"data:application/pdf;base64,{TOOL_RESULT_PDF_B64}"}, + } + ] + + +def test_tool_result_text_and_document_reach_bedrock_converse_tool_result(): + """Claude Code >= 2.1.245 sends Read-tool PDF output as a document block inside + tool_result; dropping it left bedrock converse models blind to the PDF content.""" + adapter = LiteLLMAnthropicMessagesAdapter() + translated = adapter.translate_anthropic_messages_to_openai( + messages=[ + AnthropicMessagesUserMessageParam(role="user", content="Read pong.pdf"), + _anthropic_tool_use_turn("toolu_01"), + _anthropic_tool_result_turn( + { + "toolu_01": [ + {"type": "text", "text": "PDF file read: pong.pdf (579 bytes)"}, + _base64_pdf_block(), + ] + } + ), + ] + ) + + converse_messages = _bedrock_converse_messages_pt( + messages=translated, + model="anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", + ) + + tool_results = [ + block["toolResult"] + for message in converse_messages + for block in message["content"] + if "toolResult" in block + ] + assert len(tool_results) == 1 + documents = [part["document"] for part in tool_results[0]["content"] if "document" in part] + assert len(documents) == 1 + assert documents[0]["format"] == "pdf" + assert documents[0]["source"]["bytes"] == TOOL_RESULT_PDF_B64 + texts = [part["text"] for part in tool_results[0]["content"] if "text" in part] + assert texts == ["PDF file read: pong.pdf (579 bytes)"] + + def test_translate_anthropic_to_openai_carries_prompt_cache_breakpoint_on_system_and_user_blocks(): explicit = {"mode": "explicit"} openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(