diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index c0d0e618578..d5a54bb1e41 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -52,6 +52,7 @@ from litellm.llms.anthropic.chat.transformation import ( ) from litellm.llms.anthropic.common_utils import AnthropicModelInfo from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException +from litellm.llms.bedrock.chat.tool_result_images import place_tool_result_images from litellm.llms.bedrock.common_utils import bedrock_model_supports_regex_lookaround from litellm.llms.bedrock.request_metadata import ( bedrock_request_metadata_headers, @@ -2037,11 +2038,16 @@ class AmazonConverseConfig(BaseConfig): litellm_params=litellm_params, ) - bedrock_messages: Final = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model=model, - llm_provider="bedrock_converse", - user_continue_message=litellm_params.pop("user_continue_message", None), + bedrock_messages: Final = list( + place_tool_result_images( + await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model=model, + llm_provider="bedrock_converse", + user_continue_message=litellm_params.pop("user_continue_message", None), + ), + model, + ) ) request_metadata: Final = resolve_bedrock_request_metadata( @@ -2100,11 +2106,16 @@ class AmazonConverseConfig(BaseConfig): ) ## TRANSFORMATION ## - bedrock_messages: Final[list[MessageBlock]] = _bedrock_converse_messages_pt( - messages=messages, - model=model, - llm_provider="bedrock_converse", - user_continue_message=litellm_params.pop("user_continue_message", None), + bedrock_messages: Final[list[MessageBlock]] = list( + place_tool_result_images( + _bedrock_converse_messages_pt( + messages=messages, + model=model, + llm_provider="bedrock_converse", + user_continue_message=litellm_params.pop("user_continue_message", None), + ), + model, + ) ) request_metadata: Final = resolve_bedrock_request_metadata( diff --git a/litellm/llms/bedrock/chat/tool_result_images.py b/litellm/llms/bedrock/chat/tool_result_images.py new file mode 100644 index 00000000000..0bc72ca2b3b --- /dev/null +++ b/litellm/llms/bedrock/chat/tool_result_images.py @@ -0,0 +1,63 @@ +"""Place tool-result images where Bedrock Converse will accept them.""" + +from collections.abc import Iterator, Sequence +from typing import Final + +from litellm.litellm_core_utils.prompt_templates.common_utils import TOOL_RESULT_IMAGE_PLACEHOLDER +from litellm.llms.bedrock.common_utils import bedrock_converse_supports_tool_result_images +from litellm.types.llms.bedrock import ContentBlock, ImageBlock, MessageBlock, ToolResultBlock, ToolResultContentBlock + + +def place_tool_result_images(messages: Sequence[MessageBlock], model: str) -> tuple[MessageBlock, ...]: + """Move images out of ``toolResult.content`` when this model rejects them there.""" + if bedrock_converse_supports_tool_result_images(model): + return tuple(messages) + return tuple(_message_with_sibling_images(message) for message in messages) + + +def _message_with_sibling_images(message: MessageBlock) -> MessageBlock: + content: Final = tuple(message.get("content") or ()) + rewritten: Final = tuple(_blocks_with_sibling_images(content)) + if rewritten == content: + return message + return MessageBlock(role=message["role"], content=list(rewritten)) + + +def _blocks_with_sibling_images(content: Sequence[ContentBlock]) -> Iterator[ContentBlock]: + for block in content: + tool_result = block.get("toolResult") + if tool_result is None: + yield block + continue + parts = tuple(tool_result.get("content") or ()) + images = tuple(_images(parts)) + if not images: + yield block + continue + kept = tuple(_without_images(parts)) or (ToolResultContentBlock(text=TOOL_RESULT_IMAGE_PLACEHOLDER),) + yield ContentBlock(toolResult=_tool_result_without_images(tool_result, kept)) + yield from (ContentBlock(image=image) for image in images) + + +def _images(parts: Sequence[ToolResultContentBlock]) -> Iterator[ImageBlock]: + for part in parts: + if "image" in part: + yield part["image"] + + +def _without_images(parts: Sequence[ToolResultContentBlock]) -> Iterator[ToolResultContentBlock]: + for part in parts: + if "image" not in part: + yield part + + +def _tool_result_without_images( + tool_result: ToolResultBlock, + kept: Sequence[ToolResultContentBlock], +) -> ToolResultBlock: + content: Final = list(kept) + tool_use_id: Final = tool_result["toolUseId"] + status: Final = tool_result.get("status") + if status is None: + return ToolResultBlock(content=content, toolUseId=tool_use_id) + return ToolResultBlock(content=content, toolUseId=tool_use_id, status=status) diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 76c85b438d3..151f4858fdb 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -923,6 +923,19 @@ def bedrock_runtime_chat_completions_enforces_response_format(model: str) -> boo return _bedrock_price_map_flag(model, "supports_bedrock_runtime_chat_completions_response_format") +def bedrock_converse_supports_tool_result_images(model: str) -> bool: + """Whether Converse accepts an image nested in ``toolResult.content``. + + Missing means yes, which is what Claude accepts. A price-map row sets + ``supports_bedrock_converse_tool_result_images`` to false when Bedrock + rejects that image and it has to sit beside the tool result instead. + """ + entries: Final = tuple(entry for entry in _bedrock_price_map_entries(model) if entry is not None) + if not entries: + return True + return all(entry.get("supports_bedrock_converse_tool_result_images") is not False for entry in entries) + + def bedrock_model_is_openai_gpt(model: str) -> bool: """A GPT-5.x or GPT-6.x id, never GPT-OSS: the families whose sampling params AWS ties to reasoning being off.""" return _openai_gpt_version(model) is not None diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 90f6bb11c4a..6d49e7cd7fd 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -56693,6 +56693,7 @@ "output_cost_per_token": 2.2e-05, "output_cost_per_token_above_272k_tokens": 3.3e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56729,6 +56730,7 @@ "output_cost_per_token": 2e-05, "output_cost_per_token_above_272k_tokens": 3e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56765,6 +56767,7 @@ "output_cost_per_token": 1.32e-05, "output_cost_per_token_above_272k_tokens": 1.98e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56801,6 +56804,7 @@ "output_cost_per_token": 1.2e-05, "output_cost_per_token_above_272k_tokens": 1.8e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56837,6 +56841,7 @@ "output_cost_per_token": 1.32e-06, "output_cost_per_token_above_272k_tokens": 1.98e-06, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56870,6 +56875,7 @@ "output_cost_per_token": 1.65e-05, "output_cost_per_token_above_272k_tokens": 2.475e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56901,6 +56907,7 @@ "output_cost_per_token": 1.5e-05, "output_cost_per_token_above_272k_tokens": 2.25e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56932,6 +56939,7 @@ "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56963,6 +56971,7 @@ "output_cost_per_token": 3e-05, "output_cost_per_token_above_272k_tokens": 4.5e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56997,6 +57006,7 @@ "output_cost_per_token": 1.2e-06, "output_cost_per_token_above_272k_tokens": 1.8e-06, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57164,6 +57174,7 @@ "output_cost_per_token": 5.5e-05, "output_cost_per_token_above_272k_tokens": 8.25e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57204,6 +57215,7 @@ "output_cost_per_token": 1.1e-05, "output_cost_per_token_above_272k_tokens": 1.65e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57242,6 +57254,7 @@ "output_cost_per_token": 5.5e-07, "output_cost_per_token_above_272k_tokens": 8.25e-07, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57286,6 +57299,7 @@ "output_cost_per_token": 5e-05, "output_cost_per_token_above_272k_tokens": 7.5e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57325,6 +57339,7 @@ "output_cost_per_token": 1e-05, "output_cost_per_token_above_272k_tokens": 1.5e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57359,6 +57374,7 @@ "output_cost_per_token": 1e-05, "output_cost_per_token_above_272k_tokens": 1.5e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57396,6 +57412,7 @@ "output_cost_per_token": 5e-07, "output_cost_per_token_above_272k_tokens": 7.5e-07, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57430,6 +57447,7 @@ "output_cost_per_token": 5e-07, "output_cost_per_token_above_272k_tokens": 7.5e-07, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -70505,6 +70523,7 @@ "input_cost_per_token_above_272k_tokens_ultrafast": 2.4e-05, "input_cost_per_token_ultrafast": 1.2e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1000000, "max_output_tokens": 131072, "max_tokens": 131072, @@ -70598,6 +70617,7 @@ "input_cost_per_token_above_272k_tokens_ultrafast": 2.64e-05, "input_cost_per_token_ultrafast": 1.32e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1000000, "max_output_tokens": 131072, "max_tokens": 131072, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 90f6bb11c4a..6d49e7cd7fd 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -56693,6 +56693,7 @@ "output_cost_per_token": 2.2e-05, "output_cost_per_token_above_272k_tokens": 3.3e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56729,6 +56730,7 @@ "output_cost_per_token": 2e-05, "output_cost_per_token_above_272k_tokens": 3e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56765,6 +56767,7 @@ "output_cost_per_token": 1.32e-05, "output_cost_per_token_above_272k_tokens": 1.98e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56801,6 +56804,7 @@ "output_cost_per_token": 1.2e-05, "output_cost_per_token_above_272k_tokens": 1.8e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56837,6 +56841,7 @@ "output_cost_per_token": 1.32e-06, "output_cost_per_token_above_272k_tokens": 1.98e-06, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56870,6 +56875,7 @@ "output_cost_per_token": 1.65e-05, "output_cost_per_token_above_272k_tokens": 2.475e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56901,6 +56907,7 @@ "output_cost_per_token": 1.5e-05, "output_cost_per_token_above_272k_tokens": 2.25e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56932,6 +56939,7 @@ "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56963,6 +56971,7 @@ "output_cost_per_token": 3e-05, "output_cost_per_token_above_272k_tokens": 4.5e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -56997,6 +57006,7 @@ "output_cost_per_token": 1.2e-06, "output_cost_per_token_above_272k_tokens": 1.8e-06, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57164,6 +57174,7 @@ "output_cost_per_token": 5.5e-05, "output_cost_per_token_above_272k_tokens": 8.25e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57204,6 +57215,7 @@ "output_cost_per_token": 1.1e-05, "output_cost_per_token_above_272k_tokens": 1.65e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57242,6 +57254,7 @@ "output_cost_per_token": 5.5e-07, "output_cost_per_token_above_272k_tokens": 8.25e-07, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57286,6 +57299,7 @@ "output_cost_per_token": 5e-05, "output_cost_per_token_above_272k_tokens": 7.5e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57325,6 +57339,7 @@ "output_cost_per_token": 1e-05, "output_cost_per_token_above_272k_tokens": 1.5e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57359,6 +57374,7 @@ "output_cost_per_token": 1e-05, "output_cost_per_token_above_272k_tokens": 1.5e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57396,6 +57412,7 @@ "output_cost_per_token": 5e-07, "output_cost_per_token_above_272k_tokens": 7.5e-07, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -57430,6 +57447,7 @@ "output_cost_per_token": 5e-07, "output_cost_per_token_above_272k_tokens": 7.5e-07, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, @@ -70505,6 +70523,7 @@ "input_cost_per_token_above_272k_tokens_ultrafast": 2.4e-05, "input_cost_per_token_ultrafast": 1.2e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1000000, "max_output_tokens": 131072, "max_tokens": 131072, @@ -70598,6 +70617,7 @@ "input_cost_per_token_above_272k_tokens_ultrafast": 2.64e-05, "input_cost_per_token_ultrafast": 1.32e-05, "litellm_provider": "bedrock_converse", + "supports_bedrock_converse_tool_result_images": false, "max_input_tokens": 1000000, "max_output_tokens": 131072, "max_tokens": 131072, diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json index 7f58501eed5..6f7435fb026 100644 --- a/model_prices_and_context_window.schema.json +++ b/model_prices_and_context_window.schema.json @@ -984,6 +984,9 @@ "supports_audio_output": { "type": "boolean" }, + "supports_bedrock_converse_tool_result_images": { + "type": "boolean" + }, "supports_bedrock_runtime_chat_completions_inline_reasoning": { "type": "boolean" }, diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index e42ee95f8d5..8406166b772 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -17,7 +17,8 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig from litellm.llms.custom_httpx.http_handler import HTTPHandler -from litellm.types.llms.bedrock import ConverseTokenUsageBlock +from litellm.types.llms.bedrock import ContentBlock, ConverseTokenUsageBlock +from litellm.types.llms.openai import AllMessageValues def test_transform_usage(): @@ -6558,6 +6559,81 @@ def test_bedrock_tool_message_image_url_png_still_becomes_image(): assert block["image"]["source"]["bytes"] == png_b64 +def _png_tool_messages(text: str | None = None) -> list[AllMessageValues]: + from litellm.types.llms.openai import ( + ChatCompletionImageObject, + ChatCompletionImageUrlObject, + ChatCompletionTextObject, + ChatCompletionToolMessage, + ChatCompletionUserMessage, + ) + + png_b64: Final = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==" + image: Final = ChatCompletionImageObject( + type="image_url", + image_url=ChatCompletionImageUrlObject(url=f"data:image/png;base64,{png_b64}"), + ) + parts: Final = ( + (ChatCompletionTextObject(type="text", text=text), image) if text is not None else (image,) + ) + user: Final = ChatCompletionUserMessage(role="user", content="Describe the attached image.") + tool: Final = ChatCompletionToolMessage(role="tool", tool_call_id="tooluse_png_gpt", content=list(parts)) + return [user, tool] + + +def _gpt_tool_turn(text: str | None) -> list[ContentBlock]: + from litellm.types.llms.openai import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk + + fetch_tool: Final = ChatCompletionToolParam( + type="function", + function=ChatCompletionToolParamFunctionChunk( + name="fetch_image", + description="Returns an image", + parameters={"type": "object", "properties": {}}, + ), + ) + body: Final = AmazonConverseConfig()._transform_request( + model="bedrock/global.openai.gpt-6.1-sol", + messages=_png_tool_messages(text), + optional_params={"tools": [fetch_tool]}, + litellm_params={}, + headers={}, + ) + messages: Final = body["messages"] + assert isinstance(messages, list) + last: Final = messages[-1] + assert isinstance(last, dict) + content: Final = last["content"] + assert isinstance(content, list) + return [ContentBlock(**block) for block in content if isinstance(block, dict)] + + +def test_openai_gpt_tool_result_image_sits_beside_the_tool_result(): + """gpt-6.1-sol rejects an image nested in toolResult.content. The image has to be a sibling block.""" + turn: Final = _gpt_tool_turn("tool image") + tool_index: Final = next(i for i, block in enumerate(turn) if "toolResult" in block) + tool_result: Final = turn[tool_index]["toolResult"] + assert isinstance(tool_result, dict) + assert tool_result["toolUseId"] == "tooluse_png_gpt" + assert tool_result["content"] == [{"text": "tool image"}] + image: Final = turn[tool_index + 1]["image"] + assert isinstance(image, dict) + assert image["format"] == "png" + + +def test_openai_gpt_image_only_tool_result_keeps_a_text_block(): + from litellm.litellm_core_utils.prompt_templates.common_utils import TOOL_RESULT_IMAGE_PLACEHOLDER + + turn: Final = _gpt_tool_turn(None) + tool_index: Final = next(i for i, block in enumerate(turn) if "toolResult" in block) + tool_result: Final = turn[tool_index]["toolResult"] + assert isinstance(tool_result, dict) + assert tool_result["content"] == [{"text": TOOL_RESULT_IMAGE_PLACEHOLDER}] + image: Final = turn[tool_index + 1]["image"] + assert isinstance(image, dict) + assert image["format"] == "png" + + def test_transform_response_does_not_leak_body_on_parse_failure(): from litellm.llms.bedrock.common_utils import BedrockError diff --git a/tests/unit/llms/bedrock/chat/test_tool_result_images.py b/tests/unit/llms/bedrock/chat/test_tool_result_images.py new file mode 100644 index 00000000000..4e9800c83f6 --- /dev/null +++ b/tests/unit/llms/bedrock/chat/test_tool_result_images.py @@ -0,0 +1,98 @@ +from typing import Final + +from litellm.litellm_core_utils.prompt_templates.common_utils import TOOL_RESULT_IMAGE_PLACEHOLDER +from litellm.llms.bedrock.chat.tool_result_images import place_tool_result_images +from litellm.types.llms.bedrock import ( + ContentBlock, + ImageBlock, + MessageBlock, + ToolResultBlock, + ToolResultContentBlock, +) + +_PNG_BYTES: Final = b"\x89PNG\r\n\x1a\n" +_TOOL_IMAGE: Final = ImageBlock(format="png", source={"bytes": _PNG_BYTES}) + + +def test_place_tool_result_images_keeps_nested_images_for_claude(): + message: Final = MessageBlock( + role="user", + content=[ + ContentBlock( + toolResult=ToolResultBlock( + toolUseId="tooluse_nested", + content=[ + ToolResultContentBlock(text="nested", image=_TOOL_IMAGE), + ], + ), + ), + ], + ) + placed: Final = place_tool_result_images( + [message], + "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + ) + assert placed == (message,) + assert placed[0] is message + + +def test_place_tool_result_images_leaves_text_only_tool_results_on_gpt(): + message: Final = MessageBlock( + role="user", + content=[ + ContentBlock(text="plain user text"), + ContentBlock( + toolResult=ToolResultBlock( + toolUseId="tooluse_text", + content=[ToolResultContentBlock(text="only text")], + ), + ), + ], + ) + placed: Final = place_tool_result_images([message], "bedrock/global.openai.gpt-6.1-sol") + assert placed[0] is message + + +def test_place_tool_result_images_keeps_text_when_image_is_a_separate_part(): + message: Final = MessageBlock( + role="user", + content=[ + ContentBlock( + toolResult=ToolResultBlock( + toolUseId="tooluse_split", + content=[ + ToolResultContentBlock(text="failed"), + ToolResultContentBlock(image=_TOOL_IMAGE), + ], + ), + ), + ], + ) + placed: Final = place_tool_result_images([message], "bedrock/global.openai.gpt-6.1-sol") + tool_result: Final = placed[0]["content"][0]["toolResult"] + assert isinstance(tool_result, dict) + assert tool_result["content"] == [{"text": "failed"}] + assert "status" not in tool_result + + +def test_place_tool_result_images_preserves_tool_result_status_on_gpt(): + message: Final = MessageBlock( + role="user", + content=[ + ContentBlock( + toolResult=ToolResultBlock( + toolUseId="tooluse_status", + status="error", + content=[ToolResultContentBlock(image=_TOOL_IMAGE)], + ), + ), + ], + ) + placed: Final = place_tool_result_images([message], "bedrock/global.openai.gpt-6.1-sol") + tool_result: Final = placed[0]["content"][0]["toolResult"] + assert isinstance(tool_result, dict) + assert tool_result["status"] == "error" + assert tool_result["content"] == [{"text": TOOL_RESULT_IMAGE_PLACEHOLDER}] + image: Final = placed[0]["content"][1]["image"] + assert isinstance(image, dict) + assert image["format"] == "png" diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 4ca9ae163d2..034d501483c 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1022,6 +1022,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "supports_bedrock_runtime_chat_completions_tools_with_reasoning": {"type": "boolean"}, "supports_bedrock_runtime_chat_completions_response_format": {"type": "boolean"}, "supports_bedrock_runtime_chat_completions_inline_reasoning": {"type": "boolean"}, + "supports_bedrock_converse_tool_result_images": {"type": "boolean"}, "supports_url_context": {"type": "boolean"}, "supports_multimodal": {"type": "boolean"}, "uses_embed_content": {"type": "boolean"},