Skip to content

Commit f2f75bf

Browse files
Merge pull request BerriAI#14893 from vertexcover-io/fix/openai-image-edit-support-images
🐛 Fix a bug where openai image edit siltently ignores multiple images
2 parents 625ed3f + af0cb7c commit f2f75bf

File tree

3 files changed

+269
-18
lines changed

3 files changed

+269
-18
lines changed

litellm/llms/openai/image_edit/transformation.py

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -82,31 +82,29 @@ def transform_image_edit_request(
8282
#########################################################
8383
# Separate images and masks as `files` and send other parameters as `data`
8484
#########################################################
85-
_image = request_dict.get("image")
85+
_image_list = request_dict.get("image")
8686
_mask = request_dict.get("mask")
8787
data_without_files = {
8888
k: v for k, v in request_dict.items() if k not in ["image", "mask"]
8989
}
9090
files_list: List[Tuple[str, Any]] = []
9191

9292
# Handle image parameter
93-
if _image is not None:
94-
# Handle case where image can be a list (extract first image)
95-
if isinstance(_image, list):
96-
_image = _image[0] if _image else None
97-
98-
if _image is not None:
99-
image_content_type: str = ImageEditRequestUtils.get_image_content_type(
100-
_image
101-
)
102-
if isinstance(_image, BufferedReader):
103-
files_list.append(
104-
("image", (_image.name, _image, image_content_type))
105-
)
106-
else:
107-
files_list.append(
108-
("image", ("image.png", _image, image_content_type))
93+
if _image_list is not None:
94+
image_list = [_image_list] if not isinstance(_image_list, list) else _image_list
95+
for _image in image_list:
96+
if _image is not None:
97+
image_content_type: str = ImageEditRequestUtils.get_image_content_type(
98+
_image
10999
)
100+
if isinstance(_image, BufferedReader):
101+
files_list.append(
102+
("image", (_image.name, _image, image_content_type))
103+
)
104+
else:
105+
files_list.append(
106+
("image", ("image.png", _image, image_content_type))
107+
)
110108

111109
# Handle mask parameter if provided
112110
if _mask is not None:

tests/test_litellm/llms/openai/test_o_series_transformation.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from litellm.llms.openai.chat.o_series_transformation import OpenAIOSeriesConfig
44

5-
65
@pytest.mark.parametrize(
76
"model_name,expected",
87
[
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
from io import BufferedReader, BytesIO
2+
from typing import Dict
3+
import pytest
4+
from litellm import image_edit
5+
from litellm.llms.openai.image_edit.transformation import OpenAIImageEditConfig
6+
from litellm.types.router import GenericLiteLLMParams
7+
8+
9+
@pytest.fixture
10+
def image_edit_config() -> OpenAIImageEditConfig:
11+
return OpenAIImageEditConfig()
12+
13+
14+
def test_transform_image_edit_request_basic(image_edit_config: OpenAIImageEditConfig):
15+
"""Test basic image edit request transformation with image and prompt"""
16+
model = "dall-e-2"
17+
prompt = "Make the background blue"
18+
image = b"fake_image_data"
19+
image_edit_optional_request_params = {}
20+
litellm_params = GenericLiteLLMParams()
21+
headers = {}
22+
23+
data, files = image_edit_config.transform_image_edit_request(
24+
model=model,
25+
prompt=prompt,
26+
image=image,
27+
image_edit_optional_request_params=image_edit_optional_request_params,
28+
litellm_params=litellm_params,
29+
headers=headers,
30+
)
31+
32+
# Check that data contains model and prompt but not image
33+
assert data["model"] == model
34+
assert data["prompt"] == prompt
35+
assert "image" not in data
36+
assert "mask" not in data
37+
38+
# Check that files contains the image
39+
assert len(files) == 1
40+
assert files[0][0] == "image" # field name
41+
assert files[0][1][0] == "image.png" # filename
42+
assert files[0][1][1] == image # image data
43+
assert "image/png" in files[0][1][2] # content type
44+
45+
46+
def test_transform_image_edit_request_with_mask(image_edit_config: OpenAIImageEditConfig):
47+
"""Test transformation with mask parameter"""
48+
model = "dall-e-2"
49+
prompt = "Make the background blue"
50+
image = b"fake_image_data"
51+
mask = b"fake_mask_data"
52+
image_edit_optional_request_params = {"mask": mask, "size": "1024x1024"}
53+
litellm_params = GenericLiteLLMParams()
54+
headers = {}
55+
56+
data, files = image_edit_config.transform_image_edit_request(
57+
model=model,
58+
prompt=prompt,
59+
image=image,
60+
image_edit_optional_request_params=image_edit_optional_request_params,
61+
litellm_params=litellm_params,
62+
headers=headers,
63+
)
64+
65+
# Check that data contains model, prompt, and size but not image or mask
66+
assert data["model"] == model
67+
assert data["prompt"] == prompt
68+
assert data["size"] == "1024x1024"
69+
assert "image" not in data
70+
assert "mask" not in data
71+
72+
# Check that files contains both image and mask
73+
assert len(files) == 2
74+
75+
# Find image and mask in files
76+
image_file = next(f for f in files if f[0] == "image")
77+
mask_file = next(f for f in files if f[0] == "mask")
78+
79+
assert image_file[1][0] == "image.png"
80+
assert image_file[1][1] == image
81+
assert "image/png" in image_file[1][2]
82+
83+
assert mask_file[1][0] == "mask.png"
84+
assert mask_file[1][1] == mask
85+
assert "image/png" in mask_file[1][2]
86+
87+
88+
def test_transform_image_edit_request_with_buffered_reader(image_edit_config: OpenAIImageEditConfig):
89+
"""Test transformation with BufferedReader as image input"""
90+
import tempfile
91+
import os
92+
93+
model = "dall-e-2"
94+
prompt = "Make the background blue"
95+
96+
# Create a real file to get a proper BufferedReader
97+
image_data = b"fake_image_data"
98+
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as temp_file:
99+
temp_file.write(image_data)
100+
temp_file_path = temp_file.name
101+
102+
try:
103+
# Open the file as BufferedReader
104+
with open(temp_file_path, 'rb') as image_buffer:
105+
image_edit_optional_request_params = {}
106+
litellm_params = GenericLiteLLMParams()
107+
headers = {}
108+
109+
data, files = image_edit_config.transform_image_edit_request(
110+
model=model,
111+
prompt=prompt,
112+
image=image_buffer,
113+
image_edit_optional_request_params=image_edit_optional_request_params,
114+
litellm_params=litellm_params,
115+
headers=headers,
116+
)
117+
118+
# Check that data contains model and prompt but not image
119+
assert data["model"] == model
120+
assert data["prompt"] == prompt
121+
assert "image" not in data
122+
123+
# Check that files contains the image with the original filename
124+
assert len(files) == 1
125+
assert files[0][0] == "image"
126+
# Should use the buffer's name (full path from the BufferedReader.name)
127+
assert files[0][1][0] == temp_file_path # Uses full path from buffer.name
128+
assert files[0][1][1] == image_buffer # Should be the buffer object
129+
# Content type detection defaults to PNG for fake data without image headers
130+
assert files[0][1][2].startswith("image/") # Should detect some image type
131+
finally:
132+
# Clean up the temp file
133+
os.unlink(temp_file_path)
134+
135+
136+
def test_transform_image_edit_request_with_optional_params(image_edit_config: OpenAIImageEditConfig):
137+
"""Test transformation with optional parameters like size, quality, etc."""
138+
model = "dall-e-2"
139+
prompt = "Make the background blue"
140+
image = b"fake_image_data"
141+
image_edit_optional_request_params = {
142+
"size": "512x512",
143+
"response_format": "b64_json",
144+
"n": 2,
145+
"user": "test_user"
146+
}
147+
litellm_params = GenericLiteLLMParams()
148+
headers = {}
149+
150+
data, files = image_edit_config.transform_image_edit_request(
151+
model=model,
152+
prompt=prompt,
153+
image=image,
154+
image_edit_optional_request_params=image_edit_optional_request_params,
155+
litellm_params=litellm_params,
156+
headers=headers,
157+
)
158+
159+
# Check that data contains all the optional parameters
160+
assert data["model"] == model
161+
assert data["prompt"] == prompt
162+
assert data["size"] == "512x512"
163+
assert data["response_format"] == "b64_json"
164+
assert data["n"] == 2
165+
assert data["user"] == "test_user"
166+
assert "image" not in data
167+
assert "mask" not in data
168+
169+
# Check that files contains only the image
170+
assert len(files) == 1
171+
assert files[0][0] == "image"
172+
assert files[0][1][1] == image
173+
174+
175+
def test_transform_image_edit_request_with_multiple_images(image_edit_config: OpenAIImageEditConfig):
176+
"""Test transformation with multiple images and no mask"""
177+
model = "dall-e-2"
178+
prompt = "Make the background blue"
179+
image1 = b"fake_image_data_1"
180+
image2 = b"fake_image_data_2"
181+
image3 = b"fake_image_data_3"
182+
images = [image1, image2, image3]
183+
image_edit_optional_request_params = {"size": "1024x1024", "n": 1}
184+
litellm_params = GenericLiteLLMParams()
185+
headers = {}
186+
187+
data, files = image_edit_config.transform_image_edit_request(
188+
model=model,
189+
prompt=prompt,
190+
image=images,
191+
image_edit_optional_request_params=image_edit_optional_request_params,
192+
litellm_params=litellm_params,
193+
headers=headers,
194+
)
195+
196+
# Check that data contains model, prompt, and optional params but not image or mask
197+
assert data["model"] == model
198+
assert data["prompt"] == prompt
199+
assert data["size"] == "1024x1024"
200+
assert data["n"] == 1
201+
assert "image" not in data
202+
assert "mask" not in data
203+
204+
# Check that files contains all three images and no mask
205+
assert len(files) == 3
206+
207+
# All files should be image entries
208+
image_files = [f for f in files if f[0] == "image"]
209+
assert len(image_files) == 3
210+
211+
# Check that all image data is present
212+
image_data_in_files = [f[1][1] for f in image_files]
213+
assert image1 in image_data_in_files
214+
assert image2 in image_data_in_files
215+
assert image3 in image_data_in_files
216+
217+
# Check that all files have proper content type
218+
for file_entry in image_files:
219+
assert file_entry[1][0] == "image.png" # filename
220+
assert file_entry[1][2].startswith("image/") # content type
221+
222+
223+
def test_transform_image_edit_request_with_mask_list(image_edit_config: OpenAIImageEditConfig):
224+
"""Test transformation with mask as list (should take first element)"""
225+
model = "dall-e-2"
226+
prompt = "Make the background blue"
227+
image = b"fake_image_data"
228+
mask1 = b"fake_mask_data_1"
229+
mask2 = b"fake_mask_data_2"
230+
image_edit_optional_request_params = {"mask": [mask1, mask2]}
231+
litellm_params = GenericLiteLLMParams()
232+
headers = {}
233+
234+
data, files = image_edit_config.transform_image_edit_request(
235+
model=model,
236+
prompt=prompt,
237+
image=image,
238+
image_edit_optional_request_params=image_edit_optional_request_params,
239+
litellm_params=litellm_params,
240+
headers=headers,
241+
)
242+
243+
# Check that data contains model and prompt but not image or mask
244+
assert data["model"] == model
245+
assert data["prompt"] == prompt
246+
assert "image" not in data
247+
assert "mask" not in data
248+
249+
# Check that files contains image and only the first mask
250+
assert len(files) == 2
251+
252+
mask_file = next(f for f in files if f[0] == "mask")
253+
assert mask_file[1][1] == mask1 # Should be the first mask, not the second
254+

0 commit comments

Comments
 (0)