From 5e6ce0f6db4c5b1a53b0531591b44574156e9f0c Mon Sep 17 00:00:00 2001 From: JingHao-Leon Date: Sat, 3 Oct 2026 01:31:50 +0800 Subject: [PATCH] fix(deepseek): forward image content in role=tool messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeekChatConfig._is_vision_forwardable_content() rejected every non-user role, so a role=tool message carrying image_url blocks was silently string-collapsed before the request left LiteLLM — agent loops that screenshot inside a tool sent pixels that never reached the model. The DeepSeek Chat Completions API itself accepts and reads tool-result images (verified directly against the API, issue #44211). Allow role=tool alongside role=user in the forwardable check; the model capability gate (supports_vision) still lives in the caller. --- litellm/llms/deepseek/chat/transformation.py | 17 +++-- .../test_deepseek_vision_tool_message.py | 67 +++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 tests/unit/llms/deepseek/test_deepseek_vision_tool_message.py diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py index ea19a7c7ddf..c54e84c3524 100644 --- a/litellm/llms/deepseek/chat/transformation.py +++ b/litellm/llms/deepseek/chat/transformation.py @@ -160,13 +160,18 @@ class DeepSeekChatConfig(OpenAIGPTConfig): def _is_vision_forwardable_content(self, message: AllMessageValues, content: Sequence[object]) -> bool: """ - True only for a user message whose content list holds well-formed - text and image_url blocks with at least one image; a block missing - its payload falls back to the string collapse instead of crashing - or reaching the wire malformed. The model capability gate lives in - the caller. + True only for a user or tool message whose content list holds + well-formed text and image_url blocks with at least one image; a block + missing its payload falls back to the string collapse instead of + crashing or reaching the wire malformed. The model capability gate + lives in the caller. + + ``role="tool"`` is forwardable: the DeepSeek Chat Completions API + accepts and reads image_url blocks in tool results (verified directly + against the API; bug #44211) — agent loops that screenshot inside a + tool rely on it. """ - if message.get("role") != "user": + if message.get("role") not in ("user", "tool"): return False if not all(self._is_forwardable_block(block) for block in content): return False diff --git a/tests/unit/llms/deepseek/test_deepseek_vision_tool_message.py b/tests/unit/llms/deepseek/test_deepseek_vision_tool_message.py new file mode 100644 index 00000000000..90771cb1194 --- /dev/null +++ b/tests/unit/llms/deepseek/test_deepseek_vision_tool_message.py @@ -0,0 +1,67 @@ +""" +Tests for DeepSeek vision-content forwarding in role=tool messages. + +Bug #44211: ``_is_vision_forwardable_content`` rejected every non-user role, +so a ``role=tool`` message carrying image_url blocks was silently collapsed +to text before the request left LiteLLM — while the DeepSeek API itself +accepts and reads tool-result images. +""" + +import pytest + +from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig + + +TOOL_MESSAGE_WITH_IMAGE = { + "role": "tool", + "tool_call_id": "abc", + "content": [ + {"type": "text", "text": "screenshot:"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,aGVsbG8="}}, + ], +} + + +class TestVisionForwardableContent: + def test_tool_message_with_image_is_forwardable(self): + config = DeepSeekChatConfig() + assert config._is_vision_forwardable_content( + message=TOOL_MESSAGE_WITH_IMAGE, + content=TOOL_MESSAGE_WITH_IMAGE["content"], + ) + + def test_user_message_with_image_stays_forwardable(self): + config = DeepSeekChatConfig() + message = {"role": "user", "content": TOOL_MESSAGE_WITH_IMAGE["content"]} + assert config._is_vision_forwardable_content(message=message, content=message["content"]) + + def test_assistant_message_stays_collapsed(self): + config = DeepSeekChatConfig() + message = {"role": "assistant", "content": TOOL_MESSAGE_WITH_IMAGE["content"]} + assert not config._is_vision_forwardable_content(message=message, content=message["content"]) + + def test_image_missing_payload_falls_back(self): + config = DeepSeekChatConfig() + message = { + "role": "tool", + "content": [ + {"type": "text", "text": "screenshot:"}, + {"type": "image_url", "image_url": {"url": ""}}, + ], + } + assert not config._is_vision_forwardable_content(message=message, content=message["content"]) + + +class TestForwardOrCollapseContent: + def test_tool_message_image_content_is_not_collapsed(self): + config = DeepSeekChatConfig() + out = config._forward_or_collapse_content(message=TOOL_MESSAGE_WITH_IMAGE, forward_images=True) + assert isinstance(out.get("content"), list) + blocks = out["content"] + assert any(isinstance(block, dict) and block.get("type") == "image_url" for block in blocks) + + def test_tool_message_text_only_still_collapsed(self): + config = DeepSeekChatConfig() + message = {"role": "tool", "content": [{"type": "text", "text": "plain"}]} + out = config._forward_or_collapse_content(message=message, forward_images=True) + assert out.get("content") == "plain"