From 0530ebee46ba673d7da869bd1a541f5c994d85f1 Mon Sep 17 00:00:00 2001 From: Jaeyeon Kim Date: Sun, 1 Nov 2026 21:58:53 +0000 Subject: [PATCH 1/2] fix(httpx): repair surrogate code points in raw text request bodies Signed-off-by: Jaeyeon Kim --- litellm/llms/custom_httpx/http_handler.py | 16 ++++++++++ .../custom_httpx/test_raw_body_surrogates.py | 31 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/unit/llms/custom_httpx/test_raw_body_surrogates.py diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 3a6fa5f51da..bd49f4054f6 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,6 +322,9 @@ def _prepare_request_data_and_content( # dict/Mapping stays in data= parameter request_data = data + if isinstance(request_content, str): + request_content = _repair_surrogates(request_content) + return request_data, request_content 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") From 8c59d24d78f56311d7ad67249c2c0ee134e6aacc Mon Sep 17 00:00:00 2001 From: Jaeyeon Kim Date: Sun, 1 Nov 2026 22:43:41 +0000 Subject: [PATCH 2/2] fix(httpx): return repaired content without reassignment Signed-off-by: Jaeyeon Kim --- litellm/llms/custom_httpx/http_handler.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index bd49f4054f6..6b794b43fdf 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -322,10 +322,7 @@ def _prepare_request_data_and_content( # dict/Mapping stays in data= parameter request_data = data - if isinstance(request_content, str): - request_content = _repair_surrogates(request_content) - - 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