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
This commit is contained in:
Pawan-Shahane 2026-09-21 08:47:37 +05:30
parent 946da34260
commit bf6b89b1cb
8 changed files with 108 additions and 15 deletions

View file

@ -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,

View file

@ -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.")

View file

@ -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)

View file

@ -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()

View file

@ -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 []

View file

@ -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)

View file

@ -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.

View file

@ -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,