fix(images): stop forwarding the raw image[] and mask[] form keys

The /v1/images/edits handler binds the documented image[] and mask[] aliases
into their canonical parameters, then re-reads the multipart body, so the raw
bracketed keys rode along to the provider next to the values already built from
them. OpenAI rejected both: image[] as "Invalid type for 'image[0]'" and mask[]
as "Invalid parameter: 'mask'". Drop both aliases from what gets forwarded.
This commit is contained in:
mateo-berri 2026-09-03 00:44:53 -07:00
parent 066d5f0694
commit 06fb9dc1ca
2 changed files with 86 additions and 3 deletions

View file

@ -21,6 +21,10 @@ from litellm.types.llms.openai import ChatCompletionUserMessage
router: Final = APIRouter()
IMAGE_ARRAY_FIELD: Final = "image[]"
MASK_ARRAY_FIELD: Final = "mask[]"
BRACKETED_FILE_FIELDS: Final = frozenset({IMAGE_ARRAY_FIELD, MASK_ARRAY_FIELD})
async def uploadfile_to_bytesio(upload: UploadFile) -> io.BytesIO:
"""
@ -229,9 +233,9 @@ async def image_edit_api(
fastapi_response: Response,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
image: list[UploadFile] | None = File(None),
image_array: list[UploadFile] | None = File(None, alias="image[]"),
image_array: list[UploadFile] | None = File(None, alias=IMAGE_ARRAY_FIELD),
mask: list[UploadFile] | None = File(None),
mask_array: list[UploadFile] | None = File(None, alias="mask[]"),
mask_array: list[UploadFile] | None = File(None, alias=MASK_ARRAY_FIELD),
model: str | None = None,
):
"""
@ -279,7 +283,11 @@ async def image_edit_api(
#########################################################
# Read request body and convert UploadFiles to BytesIO
#########################################################
data: Final = await _read_request_body(request=request)
data: Final = {
key: value
for key, value in (await _read_request_body(request=request)).items()
if key not in BRACKETED_FILE_FIELDS
}
image_files: Final = await batch_to_bytesio(image)
mask_files: Final = await batch_to_bytesio(mask)
if image_files:

View file

@ -5,10 +5,13 @@ from typing import Any, Dict
import orjson
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.requests import Request
from starlette.responses import Response
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.image_endpoints import endpoints
@ -115,3 +118,75 @@ async def test_image_generation_prompt_rerouting(monkeypatch):
assert captured_route_request_data["prompt"] == "sanitized prompt"
assert "messages" not in captured_route_request_data
assert response.headers.get("x-callback-test") == "value"
def _image_edit_client(monkeypatch, captured: Dict[str, Any]) -> TestClient:
class CaptureProcessing:
def __init__(self, data: Dict[str, Any]) -> None:
captured.update(data)
async def base_process_llm_request(self, **_: Any) -> Dict[str, Any]:
return {"data": [{"b64_json": "aGk="}]}
monkeypatch.setattr(endpoints, "ProxyBaseLLMRequestProcessing", CaptureProcessing)
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", None)
app = FastAPI()
app.include_router(endpoints.router)
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth()
return TestClient(app)
def test_image_edit_image_array_alias_is_not_forwarded(monkeypatch):
"""The documented `image[]` alias must reach the provider only as `image`."""
captured: Dict[str, Any] = {}
response = _image_edit_client(monkeypatch, captured).post(
"/v1/images/edits",
files={"image[]": ("tree.png", b"\x89PNG\r\n\x1a\ntree", "image/png")},
data={"model": "gpt-image-1", "prompt": "add a hat"},
)
assert response.status_code == 200
assert "image[]" not in captured
assert [buffer.getvalue() for buffer in captured["image"]] == [b"\x89PNG\r\n\x1a\ntree"]
assert [buffer.name for buffer in captured["image"]] == ["tree.png"]
def test_image_edit_mask_array_alias_is_not_forwarded(monkeypatch):
"""`mask[]` has the same shape as `image[]` and must be dropped the same way."""
captured: Dict[str, Any] = {}
response = _image_edit_client(monkeypatch, captured).post(
"/v1/images/edits",
files={
"image": ("tree.png", b"\x89PNG\r\n\x1a\ntree", "image/png"),
"mask[]": ("mask.png", b"\x89PNG\r\n\x1a\nmask", "image/png"),
},
data={"model": "gpt-image-1", "prompt": "add a hat"},
)
assert response.status_code == 200
assert "mask[]" not in captured
assert [buffer.getvalue() for buffer in captured["mask"]] == [b"\x89PNG\r\n\x1a\nmask"]
assert [buffer.getvalue() for buffer in captured["image"]] == [b"\x89PNG\r\n\x1a\ntree"]
def test_image_edit_canonical_file_fields_still_reach_the_provider(monkeypatch):
"""Dropping the bracketed aliases must not touch the canonical fields."""
captured: Dict[str, Any] = {}
response = _image_edit_client(monkeypatch, captured).post(
"/v1/images/edits",
files={
"image": ("tree.png", b"\x89PNG\r\n\x1a\ntree", "image/png"),
"mask": ("mask.png", b"\x89PNG\r\n\x1a\nmask", "image/png"),
},
data={"model": "gpt-image-1", "prompt": "add a hat"},
)
assert response.status_code == 200
assert [buffer.getvalue() for buffer in captured["image"]] == [b"\x89PNG\r\n\x1a\ntree"]
assert [buffer.getvalue() for buffer in captured["mask"]] == [b"\x89PNG\r\n\x1a\nmask"]
assert captured["prompt"] == "add a hat"