From 45336b0179c7e24505c9035ebb0b70962dc70a82 Mon Sep 17 00:00:00 2001 From: Yancey Zhou Date: Sun, 26 Apr 2026 21:01:30 +0800 Subject: [PATCH] fix(custom_httpx): preserve non-ascii response headers --- .../llms/custom_httpx/aiohttp_transport.py | 2 +- litellm/llms/custom_httpx/http_handler.py | 10 ++-- .../custom_httpx/test_aiohttp_transport.py | 48 +++++++++++++++++++ .../test_credential_leak_prevention.py | 18 +++++++ .../test_streaming_connection_cleanup.py | 2 + 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/litellm/llms/custom_httpx/aiohttp_transport.py b/litellm/llms/custom_httpx/aiohttp_transport.py index 132191c946c..724fedd9388 100644 --- a/litellm/llms/custom_httpx/aiohttp_transport.py +++ b/litellm/llms/custom_httpx/aiohttp_transport.py @@ -341,7 +341,7 @@ class LiteLLMAiohttpTransport(AiohttpTransport): return httpx.Response( status_code=response.status, - headers=response.headers, + headers=response.raw_headers, stream=AiohttpResponseStream(response), request=request, ) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 03d2af72329..8887f700772 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -381,11 +381,11 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError): except Exception: response_content = b"" - response_headers = { - k: v - for k, v in original_error.response.headers.items() - if k.lower() not in ("content-encoding", "content-length") - } + response_headers = [ + (k, v) + for k, v in original_error.response.headers.raw + if k.lower() not in (b"content-encoding", b"content-length") + ] masked_request = httpx.Request( method=original_error.request.method, diff --git a/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py b/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py index 0817d92d6b2..34cc4cf31ba 100644 --- a/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py +++ b/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py @@ -238,6 +238,7 @@ async def test_handle_async_request_uses_env_proxy(monkeypatch): class Resp: status = 200 headers = {} + raw_headers = () async def __aenter__(self): return self @@ -262,6 +263,51 @@ async def test_handle_async_request_uses_env_proxy(monkeypatch): assert captured["proxy"] == proxy_url +@pytest.mark.asyncio +async def test_handle_async_request_preserves_non_ascii_response_headers(): + """Aiohttp transport should pass raw response header bytes through to httpx.""" + header_value = "本地化消息" + + class FakeSession: + closed = False + + def __init__(self): + try: + self._loop = asyncio.get_running_loop() + except RuntimeError: + self._loop = None + + def request(self, *args, **kwargs): + class Resp: + status = 200 + headers = {"X-Localized-Message": header_value} + raw_headers = ((b"X-Localized-Message", header_value.encode("utf-8")),) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + pass + + @property + def content(self): + class C: + async def iter_chunked(self, size): + yield b"ok" + + return C() + + return Resp() + + transport = LiteLLMAiohttpTransport(client=lambda: FakeSession()) # type: ignore + response = await transport.handle_async_request( + httpx.Request("GET", "http://example.com/asset") + ) + + assert response.status_code == 200 + assert response.headers.get("x-localized-message") == header_value + + @pytest.mark.asyncio async def test_handle_async_request_uses_env_proxy_per_url(monkeypatch): """Aiohttp transport should honor HTTP(S)_PROXY env vars unless NO_PROXY matches""" @@ -295,6 +341,7 @@ async def test_handle_async_request_uses_env_proxy_per_url(monkeypatch): class Resp: status = 200 headers = {} + raw_headers = () async def __aenter__(self): return self @@ -353,6 +400,7 @@ def _make_mock_response(should_fail=False, fail_count={"count": 0}): class MockResp: status = 200 headers = {} + raw_headers = () async def __aenter__(self): if should_fail and fail_count["count"] < 1: diff --git a/tests/test_litellm/llms/custom_httpx/test_credential_leak_prevention.py b/tests/test_litellm/llms/custom_httpx/test_credential_leak_prevention.py index 72b4da7b38b..b353db9d153 100644 --- a/tests/test_litellm/llms/custom_httpx/test_credential_leak_prevention.py +++ b/tests/test_litellm/llms/custom_httpx/test_credential_leak_prevention.py @@ -135,6 +135,24 @@ 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_preserves_non_ascii_response_headers(self): + header_value = "本地化错误消息" + request = httpx.Request("GET", "https://api.example.com?key=SECRET") + response = httpx.Response( + status_code=400, + content=b"bad request", + headers=[ + (b"x-localized-message", header_value.encode("utf-8")), + ], + request=request, + ) + orig = httpx.HTTPStatusError("400", request=request, response=response) + + masked = MaskedHTTPStatusError(orig) + + assert masked.response.headers.get("x-localized-message") == header_value + assert "SECRET" not in str(masked.response.request.url) + class TestSafeResponseHelpers: def test_safe_get_response_text_normal(self): diff --git a/tests/test_litellm/test_streaming_connection_cleanup.py b/tests/test_litellm/test_streaming_connection_cleanup.py index 5a81a3ffb17..a747a2fcc62 100644 --- a/tests/test_litellm/test_streaming_connection_cleanup.py +++ b/tests/test_litellm/test_streaming_connection_cleanup.py @@ -40,6 +40,7 @@ async def test_aiohttp_transport_response_uses_stream_not_content(): class Resp: status = 200 headers = {} + raw_headers = () async def __aenter__(self): return self @@ -73,6 +74,7 @@ async def test_aiohttp_response_stream_aclose_releases_connection(): class MockResponse: status = 200 headers = {} + raw_headers = () @property def content(self):