diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 3a6fa5f51da..6b794b43fdf 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -2,6 +2,7 @@ import asyncio import concurrent.futures import inspect import os +import re import socket import ssl import sys @@ -272,6 +273,18 @@ _STREAMING_ERROR_BODY_READ_EXECUTOR: Final = concurrent.futures.ThreadPoolExecut ) +_SURROGATE_RE: Final = re.compile(r"[\ud800-\udfff]") + + +def _repair_surrogates(value: str) -> str: + """Repair malformed UTF-16 text without copying ordinary Unicode strings.""" + if not _SURROGATE_RE.search(value): + return value + # Preserve valid surrogate pairs as their scalar value; replace only lone + # surrogates. Byte payloads and caller-owned mappings are never rewritten. + return value.encode("utf-16-le", errors="surrogatepass").decode("utf-16-le", errors="replace") + + def _prepare_request_data_and_content( data: dict | str | bytes | None = None, content: _RequestContent | None = None, @@ -309,7 +322,7 @@ def _prepare_request_data_and_content( # dict/Mapping stays in data= parameter request_data = data - return request_data, request_content + return request_data, _repair_surrogates(request_content) if isinstance(request_content, str) else request_content # Cache for SSL contexts to avoid creating duplicate contexts with the same configuration diff --git a/tests/unit/llms/custom_httpx/test_raw_body_surrogates.py b/tests/unit/llms/custom_httpx/test_raw_body_surrogates.py new file mode 100644 index 00000000000..75d7aa53599 --- /dev/null +++ b/tests/unit/llms/custom_httpx/test_raw_body_surrogates.py @@ -0,0 +1,31 @@ +import httpx +import pytest + +from litellm.llms.custom_httpx.http_handler import _prepare_request_data_and_content + + +@pytest.mark.parametrize("argument", ["data", "content"]) +@pytest.mark.parametrize( + ("raw", "expected"), + [("hi\ud800!", "hi\ufffd!"), ("\udc00", "\ufffd"), ("\ud83d\ude00", "😀"), ("漢字😀", "漢字😀")], +) +def test_raw_text_can_be_encoded_as_utf8(argument, raw, expected): + data, content = _prepare_request_data_and_content(**{argument: raw}) + request = httpx.Request("POST", "http://localhost/", data=data, content=content) + assert request.content == expected.encode() + + +def test_valid_text_and_bytes_keep_identity(): + text = "valid 😀" + payload = b"\xed\xa0\x80" + assert _prepare_request_data_and_content(data=text) == (None, text) + assert _prepare_request_data_and_content(content=text)[1] is text + assert _prepare_request_data_and_content(data=payload)[1] is payload + + +def test_explicit_content_wins_without_mutating_form_fields(): + fields = {"field": "value"} + data, content = _prepare_request_data_and_content(data=fields, content="\ud800") + assert data is fields + assert content == "\ufffd" + assert _prepare_request_data_and_content(data="ignored\ud800", content=b"chosen") == (None, b"chosen")