From bf6b89b1cbb2d0ac2916c4c7c98f1fbb1cc666a1 Mon Sep 17 00:00:00 2001 From: Pawan-Shahane Date: Mon, 21 Sep 2026 08:47:37 +0530 Subject: [PATCH] fix(images): return 400 when /v1/images/edits has no image part aimage_edit required image positionally while image_edit defaults it to None, so a request with no image part died on a raw TypeError that the proxy served as a 500. A 500 tells OpenAI-SDK clients to retry a request that can never succeed, so four failed calls become twelve. The async signature now matches the sync one and both share _as_image_list, so a missing image stays an empty list rather than becoming [None]. Each provider config already decides whether it needs an image, since image is optional for some models, and those checks now raise BadRequestError like the dalle2 path already did instead of a bare ValueError that mapped to 500. Fixes #42185 --- litellm/images/main.py | 24 +++++++-- .../image_edit/flux2_transformation.py | 12 ++++- ...n_nova_canvas_image_edit_transformation.py | 13 +++-- .../llms/gemini/image_edit/transformation.py | 7 ++- .../vertex_gemini_transformation.py | 6 ++- .../images/test_image_edit_missing_image.py | 51 +++++++++++++++++++ .../test_gemini_image_edit_transformation.py | 6 ++- ...est_vertex_ai_image_edit_transformation.py | 4 +- 8 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 tests/test_litellm/images/test_image_edit_missing_image.py diff --git a/litellm/images/main.py b/litellm/images/main.py index 81547a153c3..99d890fff21 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -712,6 +712,13 @@ def image_variation( return response +def _as_image_list(image: FileTypes | list[FileTypes] | None) -> list[FileTypes]: + """A missing image stays an empty list so each provider config can reject it on its own terms.""" + if isinstance(image, list): + return image + return [] if image is None else [image] + + @client def image_edit( image: FileTypes | list[FileTypes] | None = None, @@ -768,7 +775,7 @@ def image_edit( _is_async: Final = kwargs.pop("async_call", False) is True # add images / or return a single image - images: Final = image if isinstance(image, list) else ([image] if image is not None else []) + images: Final = _as_image_list(image) headers_from_kwargs: Final = kwargs.get("headers") merged_extra_headers: Final[dict[str, object]] = {} @@ -970,9 +977,9 @@ def image_edit( @client async def aimage_edit( - image: FileTypes | list[FileTypes], - model: str, - prompt: str, + image: FileTypes | list[FileTypes] | None = None, + model: str | None = None, + prompt: str | None = None, mask: str | None = None, n: int | None = None, quality: str | ImageGenerationRequestQuality | None = None, @@ -1004,13 +1011,20 @@ async def aimage_edit( loop: Final = asyncio.get_event_loop() kwargs["async_call"] = True + if model is None: + raise litellm.BadRequestError( + message="model is required for image edits", + model="", + llm_provider=custom_llm_provider or "", + ) + # get custom llm provider so we can use this for mapping exceptions if custom_llm_provider is None: _, custom_llm_provider, _, _ = litellm.get_llm_provider( model=model, api_base=local_vars.get("base_url", None) ) - images: Final = image if isinstance(image, list) else [image] + images: Final = _as_image_list(image) func: Final = partial( image_edit, diff --git a/litellm/llms/azure_ai/image_edit/flux2_transformation.py b/litellm/llms/azure_ai/image_edit/flux2_transformation.py index f91a87ba0f4..cc41dd9becf 100644 --- a/litellm/llms/azure_ai/image_edit/flux2_transformation.py +++ b/litellm/llms/azure_ai/image_edit/flux2_transformation.py @@ -98,11 +98,19 @@ class AzureFoundryFlux2ImageEditConfig(OpenAIImageEditConfig): raise ValueError("FLUX 2 image edit requires a prompt.") if image is None: - raise ValueError("FLUX 2 image edit requires an image.") + raise litellm.BadRequestError( + message="FLUX 2 image edit requires an image.", + model=model, + llm_provider="azure_ai", + ) images: Final = tuple(image) if isinstance(image, list) else (image,) if not images: - raise ValueError("FLUX 2 image edit requires at least one image.") + raise litellm.BadRequestError( + message="FLUX 2 image edit requires at least one image.", + model=model, + llm_provider="azure_ai", + ) max_reference_images: Final = 10 if "flex" in model.lower() else 8 if len(images) > max_reference_images: raise ValueError(f"{model} supports at most {max_reference_images} reference images.") diff --git a/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py b/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py index acb0cc8dcb7..3d1c860991e 100644 --- a/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py +++ b/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py @@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any, Final import httpx from litellm._logging import verbose_logger +from litellm.exceptions import BadRequestError from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.llms.bedrock.common_utils import BedrockError from litellm.types.images.main import ImageEditOptionalRequestParams @@ -129,10 +130,14 @@ def _nova_canvas_task_body( } -def _file_types_to_b64(image: FileTypes | None) -> str: +def _file_types_to_b64(image: FileTypes | None, model: str) -> str: """Encode OpenAI image input to base64 string for Nova Canvas.""" if image is None: - raise ValueError("Nova Canvas image edit requires an image input") + raise BadRequestError( + message="Nova Canvas image edit requires an image input", + model=model, + llm_provider="bedrock", + ) if hasattr(image, "read") and callable(getattr(image, "read", None)): if hasattr(image, "seek"): image.seek(0) @@ -314,12 +319,12 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): headers: dict, ) -> tuple[dict, Any]: op: Final = dict(image_edit_optional_request_params) - image_b64: Final = _file_types_to_b64(image) + image_b64: Final = _file_types_to_b64(image, model=model) mask_raw: Final = op.pop("mask", None) mask_b64: str | None = None if mask_raw is not None: - mask_b64 = _file_types_to_b64(mask_raw) + mask_b64 = _file_types_to_b64(mask_raw, model=model) _size: Final = op.pop("size", None) width = op.pop("width", None) diff --git a/litellm/llms/gemini/image_edit/transformation.py b/litellm/llms/gemini/image_edit/transformation.py index e6c22dc60b4..67aa957e96b 100644 --- a/litellm/llms/gemini/image_edit/transformation.py +++ b/litellm/llms/gemini/image_edit/transformation.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, Final, cast import httpx from httpx._types import RequestFiles +from litellm.exceptions import BadRequestError from litellm.images.utils import ImageEditRequestUtils from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.llms.gemini.common_utils import ( @@ -93,7 +94,11 @@ class GeminiImageEditConfig(BaseImageEditConfig): ) -> tuple[dict[str, Any], RequestFiles | None]: inline_parts: Final = self._prepare_inline_image_parts(image) if image else [] if not inline_parts: - raise ValueError("Gemini image edit requires at least one image.") + raise BadRequestError( + message="Gemini image edit requires at least one image.", + model=model, + llm_provider="gemini", + ) # Build parts list with image and prompt (if provided) parts: Final = inline_parts.copy() diff --git a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py index 725a7f39917..4c3510d2d88 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py @@ -166,7 +166,11 @@ class VertexAIGeminiImageEditConfig(BaseImageEditConfig, VertexLLM): ) -> tuple[dict[str, object], RequestFiles | None]: inline_parts: Final = self._prepare_inline_image_parts(image) if image else [] if not inline_parts: - raise ValueError("Vertex AI Gemini image edit requires at least one image.") + raise litellm.BadRequestError( + message="Vertex AI Gemini image edit requires at least one image.", + model=model, + llm_provider="vertex_ai", + ) # Build parts list with image and prompt (if provided) text_parts: Final[list[HttpxPartType]] = [{"text": prompt}] if prompt is not None and prompt != "" else [] diff --git a/tests/test_litellm/images/test_image_edit_missing_image.py b/tests/test_litellm/images/test_image_edit_missing_image.py new file mode 100644 index 00000000000..dea0f24ee9d --- /dev/null +++ b/tests/test_litellm/images/test_image_edit_missing_image.py @@ -0,0 +1,51 @@ +""" +Regression tests for https://github.com/BerriAI/litellm/issues/42185 + +/v1/images/edits with no `image` part raised a raw TypeError out of aimage_edit, +which the proxy surfaced as a 500. A 500 tells OpenAI-SDK clients to retry a +request that can never succeed, so a missing image has to read as a 400. +""" + +import httpx +import pytest + +import litellm + +PNG_BYTES: bytes = b"\x89PNG\r\n\x1a\nfakepng" + + +@pytest.mark.asyncio +async def test_aimage_edit_without_image_is_a_bad_request_not_a_type_error(): + with pytest.raises(litellm.BadRequestError) as exc_info: + await litellm.aimage_edit( + model="gemini/gemini-3.1-flash-image", + prompt="make it blue", + api_key="fake-key-never-used", + ) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_aimage_edit_still_reaches_the_provider_when_an_image_is_supplied(monkeypatch): + sent: dict = {} + + async def fake_post(self, *args, **kwargs): + sent["json"] = kwargs.get("json") + return httpx.Response( + 200, + json={"candidates": [{"content": {"parts": [{"inlineData": {"mimeType": "image/png", "data": "aW1n"}}]}}]}, + request=httpx.Request("POST", "https://generativelanguage.googleapis.com"), + ) + + monkeypatch.setattr("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", fake_post) + + await litellm.aimage_edit( + model="gemini/gemini-3.1-flash-image", + prompt="make it blue", + image=PNG_BYTES, + api_key="fake-key-never-used", + ) + + parts = sent["json"]["contents"][0]["parts"] + assert any("inlineData" in part for part in parts) diff --git a/tests/unit/llms/gemini/image_edit/test_gemini_image_edit_transformation.py b/tests/unit/llms/gemini/image_edit/test_gemini_image_edit_transformation.py index bd9b7006e58..caa4f688132 100644 --- a/tests/unit/llms/gemini/image_edit/test_gemini_image_edit_transformation.py +++ b/tests/unit/llms/gemini/image_edit/test_gemini_image_edit_transformation.py @@ -244,7 +244,9 @@ class TestGeminiImageEditTransformation: def test_transform_image_edit_request_without_image_raises(self) -> None: optional_params = {} - with pytest.raises(ValueError, match='Gemini image edit requires at least one image\\.'): + with pytest.raises( + litellm.BadRequestError, match='Gemini image edit requires at least one image\\.' + ) as exc_info: self.config.transform_image_edit_request( model=self.model, prompt=self.prompt, @@ -254,6 +256,8 @@ class TestGeminiImageEditTransformation: headers={}, ) + assert exc_info.value.status_code == 400 + def test_use_multipart_form_data_returns_false(self) -> None: """ Gemini uses JSON requests, not multipart/form-data. diff --git a/tests/unit/llms/vertex_ai/image_edit/test_vertex_ai_image_edit_transformation.py b/tests/unit/llms/vertex_ai/image_edit/test_vertex_ai_image_edit_transformation.py index d3e94e5aa29..91cb50c6837 100644 --- a/tests/unit/llms/vertex_ai/image_edit/test_vertex_ai_image_edit_transformation.py +++ b/tests/unit/llms/vertex_ai/image_edit/test_vertex_ai_image_edit_transformation.py @@ -8,6 +8,8 @@ from unittest.mock import MagicMock, patch import httpx import pytest +import litellm + from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import ( VertexAIGeminiImageEditConfig, ) @@ -132,7 +134,7 @@ class TestVertexAIGeminiImageEditTransformation: """Test that missing image raises ValueError""" optional_params = {} - with pytest.raises(ValueError, match="requires at least one image"): + with pytest.raises(litellm.BadRequestError, match="requires at least one image"): self.config.transform_image_edit_request( model=self.model, prompt=self.prompt,