mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
946da34260
commit
bf6b89b1cb
8 changed files with 108 additions and 15 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
51
tests/test_litellm/images/test_image_edit_missing_image.py
Normal file
51
tests/test_litellm/images/test_image_edit_missing_image.py
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue