feat: multiple images in openai images/edits endpoint

This commit is contained in:
mubashir1osmani 2025-08-23 19:13:32 -04:00
parent 3e764ec268
commit 2fa8f971e0
5 changed files with 460 additions and 60 deletions

View file

@ -4,7 +4,7 @@ import TabItem from '@theme/TabItem';
# /images/edits
LiteLLM provides image editing functionality that maps to OpenAI's `/images/edits` API endpoint.
LiteLLM provides image editing functionality that maps to OpenAI's `/images/edits` API endpoint. Now supports both single and multiple image editing.
| Feature | Supported | Notes |
|---------|-----------|--------|
@ -13,7 +13,7 @@ LiteLLM provides image editing functionality that maps to OpenAI's `/images/edit
| End-user Tracking | ✅ | |
| Fallbacks | ✅ | Works between supported models |
| Loadbalancing | ✅ | Works between supported models |
| Supported operations | Create image edits | |
| Supported operations | Create image edits | Single and multiple images supported |
| Supported LiteLLM SDK Versions | 1.63.8+ | |
| Supported LiteLLM Proxy Versions | 1.71.1+ | |
| Supported LLM providers | **OpenAI** | Currently only `openai` is supported |
@ -41,6 +41,26 @@ response = litellm.image_edit(
print(response)
```
#### Multiple Images Edit
```python showLineNumbers title="OpenAI Multiple Images Edit"
import litellm
# Edit multiple images with a prompt
response = litellm.image_edit(
model="gpt-image-1",
image=[
open("image1.png", "rb"),
open("image2.png", "rb"),
open("image3.png", "rb")
],
prompt="Apply vintage filter to all images",
n=1,
size="1024x1024"
)
print(response)
```
#### Image Edit with Mask
```python showLineNumbers title="OpenAI Image Edit with Mask"
import litellm
@ -80,6 +100,30 @@ response = asyncio.run(edit_image())
print(response)
```
#### Async Multiple Images Edit
```python showLineNumbers title="Async OpenAI Multiple Images Edit"
import litellm
import asyncio
async def edit_multiple_images():
response = await litellm.aimage_edit(
model="gpt-image-1",
image=[
open("portrait1.png", "rb"),
open("portrait2.png", "rb")
],
prompt="Add professional lighting to the portraits",
n=1,
size="1024x1024",
response_format="url"
)
return response
# Run the async function
response = asyncio.run(edit_multiple_images())
print(response)
```
#### Image Edit with Custom Parameters
```python showLineNumbers title="OpenAI Image Edit with Custom Parameters"
import litellm
@ -163,6 +207,20 @@ curl -X POST "http://localhost:4000/v1/images/edits" \
-F "response_format=url"
```
#### cURL Multiple Images Example
```bash showLineNumbers title="cURL Multiple Images Edit Request"
curl -X POST "http://localhost:4000/v1/images/edits" \
-H "Authorization: Bearer your-api-key" \
-F "model=gpt-image-1" \
-F "image=@image1.png" \
-F "image=@image2.png" \
-F "image=@image3.png" \
-F "prompt=Apply artistic filter to all images" \
-F "n=1" \
-F "size=1024x1024" \
-F "response_format=url"
```
</TabItem>
</Tabs>

View file

@ -1,7 +1,7 @@
import asyncio
import contextvars
from functools import partial
from typing import Any, Coroutine, Dict, Literal, Optional, Union, cast, overload
from typing import Any, Coroutine, Dict, Literal, Optional, Union, cast, overload, List
import httpx
@ -675,7 +675,7 @@ def image_variation(
@client
def image_edit(
image: FileTypes,
image: Union[FileTypes, List[FileTypes]],
prompt: str,
model: Optional[str] = None,
mask: Optional[str] = None,
@ -703,6 +703,9 @@ def image_edit(
litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None)
_is_async = kwargs.pop("async_call", False) is True
#add images / or return a single image
images = image if isinstance(image, list) else [image]
# get llm provider logic
litellm_params = GenericLiteLLMParams(**kwargs)
model, custom_llm_provider, _, _ = get_llm_provider(
@ -751,7 +754,7 @@ def image_edit(
# Call the handler with _is_async flag instead of directly calling the async handler
return base_llm_http_handler.image_edit_handler(
model=model,
image=image,
image=images,
prompt=prompt,
image_edit_provider_config=image_edit_provider_config,
image_edit_optional_request_params=image_edit_request_params,
@ -777,7 +780,7 @@ def image_edit(
@client
async def aimage_edit(
image: FileTypes,
image: Union[FileTypes, List[FileTypes]],
model: str,
prompt: str,
mask: Optional[str] = None,
@ -817,9 +820,11 @@ async def aimage_edit(
model=model, api_base=local_vars.get("base_url", None)
)
images = image if isinstance(image, list) else [image]
func = partial(
image_edit,
image=image,
image=images,
prompt=prompt,
mask=mask,
model=model,

View file

@ -19,6 +19,9 @@ from litellm.utils import ImageResponse
from litellm.integrations.custom_logger import CustomLogger
from litellm.types.utils import StandardLoggingPayload
# Configure pytest marks to avoid warnings
pytestmark = pytest.mark.asyncio
class TestCustomLogger(CustomLogger):
def __init__(self):
self.standard_logging_payload: Optional[StandardLoggingPayload] = None
@ -35,6 +38,8 @@ TEST_IMAGES = [
open(os.path.join(pwd, "litellm_site.png"), "rb"),
]
SINGLE_TEST_IMAGE = open(os.path.join(pwd, "ishaan_github.png"), "rb")
def get_test_images_as_bytesio():
"""Helper function to get test images as BytesIO objects"""
bytesio_images = []
@ -501,3 +506,278 @@ def test_recraft_image_edit_config():
assert files[0][0] == "image" # Field name (not image[] like OpenAI)
assert files[0][1][1] == mock_image # Image data
assert files[0][1][2] == "image/png" # Content type
@pytest.mark.parametrize("sync_mode", [True, False])
@pytest.mark.flaky(retries=3, delay=2)
@pytest.mark.asyncio
async def test_multiple_vs_single_image_edit(sync_mode):
"""Test that both single and multiple image editing work correctly"""
from litellm import image_edit, aimage_edit
litellm._turn_on_debug()
try:
prompt = "Add a soft blue tint to the image(s)"
# Test single image
if sync_mode:
single_result = image_edit(
prompt=prompt,
model="gpt-image-1",
image=SINGLE_TEST_IMAGE,
)
else:
single_result = await aimage_edit(
prompt=prompt,
model="gpt-image-1",
image=SINGLE_TEST_IMAGE,
)
print("Single image result:", single_result)
ImageResponse.model_validate(single_result)
# Test multiple images
if sync_mode:
multiple_result = image_edit(
prompt=prompt,
model="gpt-image-1",
image=TEST_IMAGES,
)
else:
multiple_result = await aimage_edit(
prompt=prompt,
model="gpt-image-1",
image=TEST_IMAGES,
)
print("Multiple images result:", multiple_result)
ImageResponse.model_validate(multiple_result)
# Both should return valid responses
assert single_result is not None
assert multiple_result is not None
assert single_result.data is not None
assert multiple_result.data is not None
assert len(single_result.data) > 0
assert len(multiple_result.data) > 0
except litellm.ContentPolicyViolationError as e:
pytest.skip(f"Content policy violation: {e}")
@pytest.mark.flaky(retries=3, delay=2)
@pytest.mark.asyncio
async def test_multiple_image_edit_with_different_formats():
"""Test multiple images editing with different file formats and types"""
from litellm import aimage_edit
litellm._turn_on_debug()
try:
prompt = "Create a cohesive artistic style across all images"
# Test with mixed BytesIO and file objects
mixed_images = [
SINGLE_TEST_IMAGE, # File object
get_test_images_as_bytesio()[1] # BytesIO object
]
result = await aimage_edit(
prompt=prompt,
model="gpt-image-1",
image=mixed_images,
)
print("Mixed format images result:", result)
ImageResponse.model_validate(result)
assert result is not None
assert result.data is not None
assert len(result.data) > 0
# Save result if available
if result.data and result.data[0].b64_json:
image_bytes = base64.b64decode(result.data[0].b64_json)
with open("test_multiple_image_edit_mixed.png", "wb") as f:
f.write(image_bytes)
except litellm.ContentPolicyViolationError as e:
pytest.skip(f"Content policy violation: {e}")
@pytest.mark.flaky(retries=3, delay=2)
@pytest.mark.asyncio
async def test_image_edit_array_handling():
"""Test that the image parameter correctly handles both single items and arrays"""
from litellm import aimage_edit
# Mock response
mock_response = {
"created": 1589478378,
"data": [
{
"b64_json": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
}
]
}
class MockResponse:
def __init__(self, json_data, status_code):
self._json_data = json_data
self.status_code = status_code
self.text = json.dumps(json_data)
def json(self):
return self._json_data
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
new_callable=AsyncMock,
) as mock_post:
mock_post.return_value = MockResponse(mock_response, 200)
prompt = "Test prompt"
# Test 1: Single image (should be converted to list internally)
result1 = await aimage_edit(
prompt=prompt,
model="gpt-image-1",
image=SINGLE_TEST_IMAGE,
)
# Test 2: Multiple images (already a list)
result2 = await aimage_edit(
prompt=prompt,
model="gpt-image-1",
image=TEST_IMAGES,
)
# Test 3: Empty list (should fail validation)
with pytest.raises(Exception):
await aimage_edit(
prompt=prompt,
model="gpt-image-1",
image=[],
)
# Both valid calls should succeed
ImageResponse.model_validate(result1)
ImageResponse.model_validate(result2)
# Verify that both calls were made to the API
assert mock_post.call_count == 2
@pytest.mark.asyncio
async def test_openai_transformation_handles_multiple_images():
"""Test that OpenAI transformation correctly handles multiple images in request"""
from litellm.llms.openai.image_edit.transformation import OpenAIImageEditConfig
from litellm.types.router import GenericLiteLLMParams
config = OpenAIImageEditConfig()
# Test with multiple images
prompt = "Edit these images"
images = [b"fake_image_1", b"fake_image_2", b"fake_image_3"]
litellm_params = GenericLiteLLMParams(api_key="test_key")
data, files = config.transform_image_edit_request(
model="gpt-image-1",
prompt=prompt,
image=images,
image_edit_optional_request_params={"n": 1},
litellm_params=litellm_params,
headers={}
)
# Check that data contains the prompt and parameters
assert data["prompt"] == prompt
assert data["model"] == "gpt-image-1"
assert data["n"] == 1
# Check that files contains all images with correct field names
assert len(files) == len(images)
for i, file_entry in enumerate(files):
assert file_entry[0] == "image[]" # OpenAI uses image[] for multiple files
assert file_entry[1][1] == images[i] # Image data
assert file_entry[1][2] == "image/png" # Content type
print(f"Successfully processed {len(images)} images in transformation")
@pytest.mark.asyncio
async def test_multiple_image_edit_parameter_validation():
"""Test parameter validation with multiple images"""
from litellm import aimage_edit
# Mock response
mock_response = {
"created": 1589478378,
"data": [
{
"b64_json": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
}
]
}
class MockResponse:
def __init__(self, json_data, status_code):
self._json_data = json_data
self.status_code = status_code
self.text = json.dumps(json_data)
def json(self):
return self._json_data
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
new_callable=AsyncMock,
) as mock_post:
mock_post.return_value = MockResponse(mock_response, 200)
# Test with valid parameters
result = await aimage_edit(
prompt="Test prompt",
model="gpt-image-1",
image=TEST_IMAGES,
n=1,
size="1024x1024",
response_format="b64_json"
)
ImageResponse.model_validate(result)
# Verify the request was made with correct parameters
mock_post.assert_called_once()
call_args = mock_post.call_args
# Check that the request contains the expected data
if 'data' in call_args.kwargs:
form_data = call_args.kwargs['data']
assert 'model' in form_data
assert 'prompt' in form_data
assert 'n' in form_data
assert form_data['n'] == 1 # Could be int or string depending on implementation print("Parameter validation passed for multiple image edit")
@pytest.mark.asyncio
async def test_multiple_image_edit_error_handling():
"""Test error handling with multiple images"""
from litellm import aimage_edit
# Test with None image (should raise error)
with pytest.raises(Exception):
await aimage_edit(
prompt="Test prompt",
model="gpt-image-1",
image=None,
)
# Test with invalid model (should raise error)
with pytest.raises(Exception):
await aimage_edit(
prompt="Test prompt",
model="invalid-model",
image=TEST_IMAGES,
)
print("Error handling tests passed for multiple image edit")

View file

@ -170,8 +170,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
const saved = sessionStorage.getItem('useApiSessionManagement');
return saved ? JSON.parse(saved) : true; // Default to API session management
});
const [uploadedImage, setUploadedImage] = useState<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
const [uploadedImages, setUploadedImages] = useState<File[]>([]);
const [imagePreviewUrls, setImagePreviewUrls] = useState<string[]>([]);
const [responsesUploadedImage, setResponsesUploadedImage] = useState<File | null>(null);
const [responsesImagePreviewUrl, setResponsesImagePreviewUrl] = useState<string | null>(null);
const [chatUploadedImage, setChatUploadedImage] = useState<File | null>(null);
@ -468,18 +468,26 @@ const ChatUI: React.FC<ChatUIProps> = ({
};
const handleImageUpload = (file: File) => {
setUploadedImage(file);
setUploadedImages(prev => [...prev, file]);
const previewUrl = URL.createObjectURL(file);
setImagePreviewUrl(previewUrl);
setImagePreviewUrls(prev => [...prev, previewUrl]);
return false; // Prevent default upload behavior
};
const handleRemoveImage = () => {
if (imagePreviewUrl) {
URL.revokeObjectURL(imagePreviewUrl);
const handleRemoveImage = (index: number) => {
if (imagePreviewUrls[index]) {
URL.revokeObjectURL(imagePreviewUrls[index]);
}
setUploadedImage(null);
setImagePreviewUrl(null);
setUploadedImages(prev => prev.filter((_, i) => i !== index));
setImagePreviewUrls(prev => prev.filter((_, i) => i !== index));
};
const handleRemoveAllImages = () => {
imagePreviewUrls.forEach(url => {
URL.revokeObjectURL(url);
});
setUploadedImages([]);
setImagePreviewUrls([]);
};
const handleResponsesImageUpload = (file: File): false => {
@ -516,8 +524,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
if (inputMessage.trim() === "") return;
// For image edits, require both image and prompt
if (endpointType === EndpointType.IMAGE_EDITS && !uploadedImage) {
NotificationsManager.fromBackend("Please upload an image for editing");
if (endpointType === EndpointType.IMAGE_EDITS && uploadedImages.length === 0) {
NotificationsManager.fromBackend("Please upload at least one image for editing");
return;
}
@ -617,9 +625,9 @@ const ChatUI: React.FC<ChatUIProps> = ({
);
} else if (endpointType === EndpointType.IMAGE_EDITS) {
// For image edits
if (uploadedImage) {
if (uploadedImages.length > 0) {
await makeOpenAIImageEditsRequest(
uploadedImage,
uploadedImages.length === 1 ? uploadedImages[0] : uploadedImages,
inputMessage,
(imageUrl, model) => updateImageUI(imageUrl, model),
selectedModel,
@ -689,7 +697,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
abortControllerRef.current = null;
// Clear image after successful request for image edits
if (endpointType === EndpointType.IMAGE_EDITS) {
handleRemoveImage();
handleRemoveAllImages();
}
// Clear image after successful request for responses API
if (endpointType === EndpointType.RESPONSES && responsesUploadedImage) {
@ -708,7 +716,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
setChatHistory([]);
setMessageTraceId(null);
setResponsesSessionId(null); // Clear responses session ID
handleRemoveImage(); // Clear any uploaded images for image edits
handleRemoveAllImages(); // Clear any uploaded images for image edits
handleRemoveResponsesImage(); // Clear any uploaded images for responses
handleRemoveChatImage(); // Clear any uploaded images for chat completions
sessionStorage.removeItem('chatHistory');
@ -1075,7 +1083,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
{/* Image Upload Section for Image Edits */}
{endpointType === EndpointType.IMAGE_EDITS && (
<div className="mb-4">
{!uploadedImage ? (
{uploadedImages.length === 0 ? (
<Dragger
beforeUpload={handleImageUpload}
accept="image/*"
@ -1085,24 +1093,47 @@ const ChatUI: React.FC<ChatUIProps> = ({
<p className="ant-upload-drag-icon">
<PictureOutlined style={{ fontSize: '24px', color: '#666' }} />
</p>
<p className="ant-upload-text text-sm">Click or drag image to upload</p>
<p className="ant-upload-text text-sm">Click or drag images to upload</p>
<p className="ant-upload-hint text-xs text-gray-500">
Support for PNG, JPG, JPEG formats
Support for PNG, JPG, JPEG formats. Multiple images supported.
</p>
</Dragger>
) : (
<div className="relative inline-block">
<img
src={imagePreviewUrl || ''}
alt="Upload preview"
className="max-w-32 max-h-32 rounded-md border border-gray-200 object-cover"
/>
<button
className="absolute top-1 right-1 bg-white shadow-sm border border-gray-200 rounded px-1 py-1 text-red-500 hover:bg-red-50 text-xs"
onClick={handleRemoveImage}
>
<DeleteOutlined />
</button>
<div className="flex flex-wrap gap-2">
{uploadedImages.map((file, index) => (
<div key={index} className="relative inline-block">
<img
src={imagePreviewUrls[index] || ''}
alt={`Upload preview ${index + 1}`}
className="max-w-32 max-h-32 rounded-md border border-gray-200 object-cover"
/>
<button
className="absolute top-1 right-1 bg-white shadow-sm border border-gray-200 rounded px-1 py-1 text-red-500 hover:bg-red-50 text-xs"
onClick={() => handleRemoveImage(index)}
>
<DeleteOutlined />
</button>
</div>
))}
{/* Add more images button */}
<div className="flex items-center justify-center w-32 h-32 border-2 border-dashed border-gray-300 rounded-md hover:border-gray-400 cursor-pointer"
onClick={() => document.getElementById('additional-image-upload')?.click()}>
<div className="text-center">
<PictureOutlined style={{ fontSize: '24px', color: '#666' }} />
<p className="text-xs text-gray-500 mt-1">Add more</p>
</div>
<input
id="additional-image-upload"
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={(e) => {
const files = Array.from(e.target.files || []);
files.forEach(file => handleImageUpload(file));
}}
/>
</div>
</div>
)}
</div>

View file

@ -4,7 +4,7 @@ import { getProxyBaseUrl } from "@/components/networking";
import NotificationManager from "@/components/molecules/notifications_manager";
export async function makeOpenAIImageEditsRequest(
imageFile: File,
imageFiles: File | File[],
prompt: string,
updateUI: (imageUrl: string, model: string) => void,
selectedModel: string,
@ -28,34 +28,60 @@ export async function makeOpenAIImageEditsRequest(
});
try {
const response = await client.images.edit({
model: selectedModel,
image: imageFile,
prompt: prompt,
}, { signal });
console.log(response.data);
// handle single and multiple images
const imagesToProcess = Array.isArray(imageFiles) ? imageFiles : [imageFiles];
if (response.data && response.data[0]) {
// Handle either URL or base64 data from response
if (response.data[0].url) {
// Use the URL directly
updateUI(response.data[0].url, selectedModel);
} else if (response.data[0].b64_json) {
// Convert base64 to data URL format
const base64Data = response.data[0].b64_json;
updateUI(`data:image/png;base64,${base64Data}`, selectedModel);
} else {
throw new Error("No image data found in response");
// For multiple images, we'll make separate API calls for each image
// since OpenAI's edit endpoint processes one image at a time
const results = [];
for (let i = 0; i < imagesToProcess.length; i++) {
const image = imagesToProcess[i];
console.log(`Processing image ${i + 1} of ${imagesToProcess.length}`);
const response = await client.images.edit({
model: selectedModel,
image: image,
prompt: prompt,
}, { signal });
console.log(`Response for image ${i + 1}:`, response.data);
if (response.data && response.data[0]) {
// Handle either URL or base64 data from response
if (response.data[0].url) {
// Use the URL directly
updateUI(response.data[0].url, selectedModel);
results.push(response.data[0].url);
} else if (response.data[0].b64_json) {
// Convert base64 to data URL format
const base64Data = response.data[0].b64_json;
const dataUrl = `data:image/png;base64,${base64Data}`;
updateUI(dataUrl, selectedModel);
results.push(dataUrl);
}
}
} else {
throw new Error("Invalid response format");
}
} catch (error) {
if (results.length > 1) {
NotificationManager.success(`Successfully processed ${results.length} images`);
}
} catch (error: any) {
console.error("Error making image edit request:", error);
if (signal?.aborted) {
console.log("Image edits request was cancelled");
} else {
NotificationManager.fromBackend(`Error occurred while editing image. Please try again. Error: ${error}`);
let errorMessage = "Failed to edit image(s)";
if (error?.error?.message) {
errorMessage = error.error.message;
} else if (error?.message) {
errorMessage = error.message;
}
NotificationManager.fromBackend(`Image edit failed: ${errorMessage}`);
}
throw error; // Re-throw to allow the caller to handle the error
}