Skip to content

Commit cc9a573

Browse files
committed
[FIX] [litellm_wrapper] omit empty text block for image-only vision calls
The vision message builders always prepended {"type": "text", "text": task}, so an image-only call (empty or None task) produced a user message containing an empty text content block. Anthropic rejects empty text blocks; the wrapper already drops empty system blocks in _prepare_messages for the same reason, and the vision user-text block was the ungated case in that family. Add a _vision_content(task, image_block) helper that includes the text block only when task is a non-empty string, and route the anthropic (direct-URL and base64) and openai vision builders through it. Behavior is unchanged when a task is present.
1 parent b021ded commit cc9a573

2 files changed

Lines changed: 142 additions & 10 deletions

File tree

swarms/utils/litellm_wrapper.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,33 @@ def _apply_cache_request_params(
864864
)
865865
completion_params["extra_headers"] = headers
866866

867+
@staticmethod
868+
def _vision_content(task: str, image_block: dict) -> list:
869+
"""
870+
Build the content list for a vision user message.
871+
872+
The text block is included only when ``task`` is a non-empty string.
873+
Anthropic rejects content blocks whose text is empty with
874+
"text content blocks must be non-empty", so an image-only call
875+
(empty or None task) must omit the text block rather than send
876+
``{"type": "text", "text": ""}``. This mirrors the empty-system-block
877+
normalization already done in ``_prepare_messages``.
878+
879+
Args:
880+
task (str): The text task/prompt. Omitted from the content when
881+
empty, whitespace-only, or None.
882+
image_block (dict): The image content block to include.
883+
884+
Returns:
885+
list: ``[text_block, image_block]`` when task has text, else
886+
``[image_block]``.
887+
"""
888+
content = []
889+
if isinstance(task, str) and task.strip():
890+
content.append({"type": "text", "text": task})
891+
content.append(image_block)
892+
return content
893+
867894
def anthropic_vision_processing(
868895
self, task: str, image: str, messages: list
869896
) -> list:
@@ -898,15 +925,15 @@ def anthropic_vision_processing(
898925
messages.append(
899926
{
900927
"role": "user",
901-
"content": [
902-
{"type": "text", "text": task},
928+
"content": self._vision_content(
929+
task,
903930
{
904931
"type": "image_url",
905932
"image_url": {
906933
"url": image,
907934
},
908935
},
909-
],
936+
),
910937
}
911938
)
912939
else:
@@ -936,16 +963,16 @@ def anthropic_vision_processing(
936963
messages.append(
937964
{
938965
"role": "user",
939-
"content": [
940-
{"type": "text", "text": task},
966+
"content": self._vision_content(
967+
task,
941968
{
942969
"type": "image_url",
943970
"image_url": {
944971
"url": image_url,
945972
"format": mime_type,
946973
},
947974
},
948-
],
975+
),
949976
}
950977
)
951978

@@ -1033,10 +1060,7 @@ def openai_vision_processing(
10331060
messages.append(
10341061
{
10351062
"role": "user",
1036-
"content": [
1037-
{"type": "text", "text": task},
1038-
vision_message,
1039-
],
1063+
"content": self._vision_content(task, vision_message),
10401064
}
10411065
)
10421066

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Regression tests for vision message construction in ``LiteLLM``.
2+
3+
An image-only vision call (empty, whitespace-only, or ``None`` task) must not
4+
emit an empty text content block. Anthropic rejects such blocks with
5+
"text content blocks must be non-empty"; the wrapper already guards this for
6+
system blocks in ``_prepare_messages`` but historically emitted
7+
``{"type": "text", "text": ""}`` for the vision user message.
8+
9+
These assertions are on the message structure the wrapper builds (they do not
10+
make a live provider call).
11+
"""
12+
13+
import base64
14+
15+
import pytest
16+
17+
from swarms.utils.litellm_wrapper import LiteLLM
18+
19+
# Smallest valid 1x1 PNG, enough for get_image_base64() to encode from a file
20+
# without any network access.
21+
_PNG_1x1 = base64.b64decode(
22+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4"
23+
"2mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
24+
)
25+
26+
27+
@pytest.fixture
28+
def png_path(tmp_path):
29+
path = tmp_path / "pixel.png"
30+
path.write_bytes(_PNG_1x1)
31+
return str(path)
32+
33+
34+
def _user_content(messages):
35+
user = next(m for m in messages if m["role"] == "user")
36+
return user["content"]
37+
38+
39+
def _text_blocks(content):
40+
return [
41+
b
42+
for b in content
43+
if isinstance(b, dict) and b.get("type") == "text"
44+
]
45+
46+
47+
def _image_blocks(content):
48+
return [
49+
b
50+
for b in content
51+
if isinstance(b, dict) and b.get("type") == "image_url"
52+
]
53+
54+
55+
@pytest.mark.parametrize("task", ["", " ", None])
56+
def test_anthropic_vision_omits_empty_text_block(task, png_path):
57+
llm = LiteLLM(model_name="claude-3-5-sonnet-20241022")
58+
messages = llm.anthropic_vision_processing(task, png_path, [])
59+
content = _user_content(messages)
60+
assert _text_blocks(content) == []
61+
assert len(_image_blocks(content)) == 1
62+
63+
64+
@pytest.mark.parametrize("task", ["", " ", None])
65+
def test_openai_vision_omits_empty_text_block(task, png_path):
66+
llm = LiteLLM(model_name="gpt-4o")
67+
messages = llm.openai_vision_processing(task, png_path, [])
68+
content = _user_content(messages)
69+
assert _text_blocks(content) == []
70+
assert len(_image_blocks(content)) == 1
71+
72+
73+
def test_anthropic_direct_url_omits_empty_text_block(monkeypatch):
74+
# Force the direct-URL branch so the URL is embedded, not fetched.
75+
llm = LiteLLM(model_name="claude-3-5-sonnet-20241022")
76+
monkeypatch.setattr(
77+
llm, "_should_use_direct_url", lambda image: True
78+
)
79+
url = "https://example.com/pixel.png"
80+
messages = llm.anthropic_vision_processing("", url, [])
81+
content = _user_content(messages)
82+
assert _text_blocks(content) == []
83+
assert _image_blocks(content)[0]["image_url"]["url"] == url
84+
85+
86+
def test_anthropic_vision_keeps_nonempty_text_block(png_path):
87+
# Over-deletion guard: a real task must still carry its text block.
88+
llm = LiteLLM(model_name="claude-3-5-sonnet-20241022")
89+
messages = llm.anthropic_vision_processing(
90+
"Describe this image", png_path, []
91+
)
92+
content = _user_content(messages)
93+
blocks = _text_blocks(content)
94+
assert len(blocks) == 1
95+
assert blocks[0]["text"] == "Describe this image"
96+
assert len(_image_blocks(content)) == 1
97+
98+
99+
def test_openai_vision_keeps_nonempty_text_block(png_path):
100+
llm = LiteLLM(model_name="gpt-4o")
101+
messages = llm.openai_vision_processing(
102+
"Describe this image", png_path, []
103+
)
104+
content = _user_content(messages)
105+
blocks = _text_blocks(content)
106+
assert len(blocks) == 1
107+
assert blocks[0]["text"] == "Describe this image"
108+
assert len(_image_blocks(content)) == 1

0 commit comments

Comments
 (0)