mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
fix(httpx): sanitize unread multipart headers in masked errors
This commit is contained in:
parent
e24a9146e3
commit
1259240167
4 changed files with 112 additions and 14 deletions
|
|
@ -432,6 +432,25 @@ def _safe_read_response(response: httpx.Response, timeout: float | None = None)
|
|||
return b""
|
||||
|
||||
|
||||
def _build_masked_request(original_request: httpx.Request, masked_url: str) -> httpx.Request:
|
||||
"""Build a masked request without forcing unread streaming bodies into memory."""
|
||||
headers = httpx.Headers(original_request.headers)
|
||||
try:
|
||||
request_content = original_request.content
|
||||
except httpx.RequestNotRead:
|
||||
request_content = b""
|
||||
headers.pop("content-length", None)
|
||||
headers.pop("transfer-encoding", None)
|
||||
headers.pop("content-type", None)
|
||||
|
||||
return httpx.Request(
|
||||
method=original_request.method,
|
||||
url=masked_url,
|
||||
headers=headers,
|
||||
content=request_content,
|
||||
)
|
||||
|
||||
|
||||
def _raise_masked_sync_error(e: httpx.HTTPStatusError, stream: bool) -> None:
|
||||
"""Raise a MaskedHTTPStatusError for sync HTTP handlers."""
|
||||
if stream:
|
||||
|
|
@ -494,17 +513,7 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError):
|
|||
if k.lower() not in ("content-encoding", "content-length")
|
||||
}
|
||||
|
||||
try:
|
||||
request_content = original_error.request.content
|
||||
except httpx.RequestNotRead:
|
||||
request_content = b""
|
||||
|
||||
masked_request: Final = httpx.Request(
|
||||
method=original_error.request.method,
|
||||
url=masked_url,
|
||||
headers=original_error.request.headers,
|
||||
content=request_content,
|
||||
)
|
||||
masked_request: Final = _build_masked_request(original_error.request, masked_url)
|
||||
|
||||
super().__init__(
|
||||
message=masked_original_message,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ Covers:
|
|||
|
||||
import os
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
|
@ -160,6 +161,31 @@ class TestMaskedHTTPStatusError:
|
|||
# Content-Encoding must have been stripped from the rebuilt headers.
|
||||
assert "content-encoding" not in {k.lower() for k in masked.response.headers}
|
||||
|
||||
def test_handles_unread_multipart_request_content(self):
|
||||
"""Multipart file requests can have unread streaming request bodies."""
|
||||
request = httpx.Request(
|
||||
"POST",
|
||||
"https://api.example.com/v1/images/edits?key=SECRET",
|
||||
data={"model": "gpt-image-1"},
|
||||
files=[
|
||||
("image[]", ("image.png", BytesIO(b"image"), "image/png")),
|
||||
("mask", ("mask.png", BytesIO(b"mask"), "image/png")),
|
||||
],
|
||||
)
|
||||
response = httpx.Response(
|
||||
status_code=500,
|
||||
content=b'{"error": "upstream error"}',
|
||||
request=request,
|
||||
)
|
||||
orig = httpx.HTTPStatusError("500", request=request, response=response)
|
||||
|
||||
masked = MaskedHTTPStatusError(orig)
|
||||
|
||||
assert "SECRET" not in str(masked.request.url)
|
||||
assert masked.request.content == b""
|
||||
assert masked.request.headers.get("content-length") == "0"
|
||||
assert "content-type" not in masked.request.headers
|
||||
|
||||
|
||||
class TestSafeResponseHelpers:
|
||||
def test_safe_get_response_text_normal(self):
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
from io import BufferedReader, BytesIO
|
||||
from typing import Dict
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm import image_edit
|
||||
from litellm.images.utils import ImageEditRequestUtils
|
||||
from litellm.llms.openai.image_edit.transformation import OpenAIImageEditConfig
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
|
@ -266,6 +264,40 @@ def test_transform_image_edit_request_with_mask_list(
|
|||
assert mask_file[1][1] == mask1 # Should be the first mask, not the second
|
||||
|
||||
|
||||
def test_transform_image_edit_request_with_bytesio_mask_list(
|
||||
image_edit_config: OpenAIImageEditConfig,
|
||||
):
|
||||
"""Test transformation with proxy-style BytesIO mask list."""
|
||||
model = "gpt-image-1"
|
||||
prompt = "Make the background blue"
|
||||
image = BytesIO(b"fake_image_data")
|
||||
image.name = "image.png"
|
||||
mask = BytesIO(b"fake_mask_data")
|
||||
mask.name = "mask.png"
|
||||
image_edit_optional_request_params = {"mask": [mask]}
|
||||
litellm_params = GenericLiteLLMParams()
|
||||
headers = {}
|
||||
|
||||
data, files = image_edit_config.transform_image_edit_request(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
image=[image],
|
||||
image_edit_optional_request_params=image_edit_optional_request_params,
|
||||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert data["model"] == model
|
||||
assert data["prompt"] == prompt
|
||||
assert "image" not in data
|
||||
assert "mask" not in data
|
||||
image_file = next(f for f in files if f[0] == "image[]")
|
||||
mask_file = next(f for f in files if f[0] == "mask")
|
||||
assert image_file[1][1] is image
|
||||
assert mask_file[1][0] == "mask.png"
|
||||
assert mask_file[1][1] is mask
|
||||
|
||||
|
||||
def test_transform_image_edit_request_with_input_fidelity(
|
||||
image_edit_config: OpenAIImageEditConfig,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -145,3 +145,34 @@ def test_azure_image_generation_route_allows_ordinary_path_model(client_no_auth)
|
|||
|
||||
assert response.status_code == 200
|
||||
mock_aimage_generation.assert_called_once()
|
||||
|
||||
|
||||
def test_image_edit_route_with_mask(client_no_auth):
|
||||
client, _, mock_aimage_edit = client_no_auth
|
||||
image_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../../image_gen_tests/test_image.png",
|
||||
)
|
||||
with open(image_path, "rb") as image_file, open(image_path, "rb") as mask_file:
|
||||
files = {
|
||||
"image": ("test_image.png", image_file, "image/png"),
|
||||
"mask": ("mask.png", mask_file, "image/png"),
|
||||
}
|
||||
data = {
|
||||
"model": "dall-e-3",
|
||||
"prompt": "A cute baby sea otter",
|
||||
"response_format": "b64_json",
|
||||
"size": "1024x1024",
|
||||
}
|
||||
response = client.post("/v1/images/edits", files=files, data=data)
|
||||
|
||||
mock_aimage_edit.assert_called_once()
|
||||
called_kwargs = mock_aimage_edit.call_args.kwargs
|
||||
assert "dall-e-3" in called_kwargs["model"]
|
||||
assert called_kwargs["prompt"] == "A cute baby sea otter"
|
||||
assert called_kwargs["response_format"] == "b64_json"
|
||||
assert called_kwargs["size"] == "1024x1024"
|
||||
assert called_kwargs["image"][0].name == "test_image.png"
|
||||
assert called_kwargs["mask"][0].name == "mask.png"
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue