diff --git a/litellm/images/main.py b/litellm/images/main.py index 7dc68dafecc..e78e346b23b 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -708,6 +708,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, @@ -760,7 +767,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]] = {} @@ -962,9 +969,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, @@ -996,13 +1003,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 31d3963c70c..64bb1877403 100644 --- a/litellm/llms/gemini/image_edit/transformation.py +++ b/litellm/llms/gemini/image_edit/transformation.py @@ -6,6 +6,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 ( @@ -94,7 +95,11 @@ class GeminiImageEditConfig(BaseImageEditConfig): ) -> tuple[dict[str, object], 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,