diff --git a/docs/my-website/docs/image_edits.md b/docs/my-website/docs/image_edits.md index f0254032964..246e1c70f0e 100644 --- a/docs/my-website/docs/image_edits.md +++ b/docs/my-website/docs/image_edits.md @@ -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" +``` + diff --git a/litellm/images/main.py b/litellm/images/main.py index 70d9eb41ddd..d6405904c5d 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -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, diff --git a/tests/image_gen_tests/test_image_edits.py b/tests/image_gen_tests/test_image_edits.py index c69dc635131..745993499d2 100644 --- a/tests/image_gen_tests/test_image_edits.py +++ b/tests/image_gen_tests/test_image_edits.py @@ -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") diff --git a/ui/litellm-dashboard/src/components/chat_ui.tsx b/ui/litellm-dashboard/src/components/chat_ui.tsx index 19029a01f13..b478336f9df 100644 --- a/ui/litellm-dashboard/src/components/chat_ui.tsx +++ b/ui/litellm-dashboard/src/components/chat_ui.tsx @@ -170,8 +170,8 @@ const ChatUI: React.FC = ({ const saved = sessionStorage.getItem('useApiSessionManagement'); return saved ? JSON.parse(saved) : true; // Default to API session management }); - const [uploadedImage, setUploadedImage] = useState(null); - const [imagePreviewUrl, setImagePreviewUrl] = useState(null); + const [uploadedImages, setUploadedImages] = useState([]); + const [imagePreviewUrls, setImagePreviewUrls] = useState([]); const [responsesUploadedImage, setResponsesUploadedImage] = useState(null); const [responsesImagePreviewUrl, setResponsesImagePreviewUrl] = useState(null); const [chatUploadedImage, setChatUploadedImage] = useState(null); @@ -468,18 +468,26 @@ const ChatUI: React.FC = ({ }; 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 = ({ 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 = ({ ); } 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 = ({ 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 = ({ 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 = ({ {/* Image Upload Section for Image Edits */} {endpointType === EndpointType.IMAGE_EDITS && (
- {!uploadedImage ? ( + {uploadedImages.length === 0 ? ( = ({

-

Click or drag image to upload

+

Click or drag images to upload

- Support for PNG, JPG, JPEG formats + Support for PNG, JPG, JPEG formats. Multiple images supported.

) : ( -
- Upload preview - +
+ {uploadedImages.map((file, index) => ( +
+ {`Upload + +
+ ))} + {/* Add more images button */} +
document.getElementById('additional-image-upload')?.click()}> +
+ +

Add more

+
+ { + const files = Array.from(e.target.files || []); + files.forEach(file => handleImageUpload(file)); + }} + /> +
)}
diff --git a/ui/litellm-dashboard/src/components/chat_ui/llm_calls/image_edits.tsx b/ui/litellm-dashboard/src/components/chat_ui/llm_calls/image_edits.tsx index dd3cf96c3ec..2b100d71b6b 100644 --- a/ui/litellm-dashboard/src/components/chat_ui/llm_calls/image_edits.tsx +++ b/ui/litellm-dashboard/src/components/chat_ui/llm_calls/image_edits.tsx @@ -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 }