mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge bf6b89b1cb into e768ad55ce
This commit is contained in:
commit
a5318939f3
8 changed files with 108 additions and 15 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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