diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index a4cc4a62009..ca0ae59b403 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -703,6 +703,29 @@ def clean_headers( return clean_headers +def _is_transmittable_header(name: object, value: object) -> bool: + """ + Whether a header name/value pair can be forwarded over HTTP. + + httpx (and Python's http.client) encode header values with a single-byte + codec, so a non-ASCII value such as CJK text raises UnicodeEncodeError deep + in the request stack and surfaces to the client as a 500. See + https://github.com/BerriAI/litellm/issues/34633 + """ + if not isinstance(name, str): + return False + if isinstance(value, bytes): + return True + if not isinstance(value, str): + return False + try: + name.encode("ascii") + value.encode("ascii") + except UnicodeEncodeError: + return False + return True + + class LiteLLMProxyRequestSetup: @staticmethod def _get_timeout_from_request(headers: dict) -> Optional[float]: @@ -875,7 +898,18 @@ class LiteLLMProxyRequestSetup: else: returned_headers["x-litellm-{}".format(k)] = str(v) - return returned_headers + transmittable_headers = { + name: value for name, value in returned_headers.items() if _is_transmittable_header(name, value) + } + dropped_headers = returned_headers.keys() - transmittable_headers.keys() + if dropped_headers: + verbose_logger.warning( + "Skipping non-ASCII request header(s) %s when forwarding to the LLM provider; " + "HTTP header values must be ASCII-encodable (see issue #34633)", + sorted(dropped_headers), + ) + + return transmittable_headers @staticmethod def add_headers_to_llm_call_by_model_group(data: dict, headers: dict, user_api_key_dict: UserAPIKeyAuth) -> dict: diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 1437899f561..da2c21ead42 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -27,6 +27,7 @@ from litellm.proxy.litellm_pre_call_utils import ( add_litellm_data_to_request, check_if_token_is_service_account, clean_headers, + _is_transmittable_header, ) from litellm.types.utils import CredentialItem @@ -5402,6 +5403,52 @@ async def test_overwrite_user_with_key_hash_stamps_master_key_alias(monkeypatch) assert updated_data["user"] == LITELLM_PROXY_MASTER_KEY_ALIAS +@pytest.mark.parametrize( + "name, value, expected", + [ + ("x-project", "acme", True), + ("x-project", b"\xe4\xb8\xad", True), + ("x-project", "中文", False), + ("x-project", "café", False), + ("x-中文", "acme", False), + ("x-count", 5, False), + ], +) +def test_is_transmittable_header(name, value, expected): + assert _is_transmittable_header(name, value) is expected + + +def test_add_headers_to_llm_call_drops_non_ascii_header_values(): + """ + Regression for https://github.com/BerriAI/litellm/issues/34633 + + A forwarded header whose value contains non-ASCII characters must not reach + httpx/http.client (which encode header values with a single-byte codec and + raise UnicodeEncodeError -> 500). ASCII headers alongside it must survive. + """ + import httpx + + non_ascii_wire_value = "中文".encode("utf-8") + headers = Headers( + raw=[ + (b"x-keep", b"plain-ascii"), + (b"x-non-ascii", non_ascii_wire_value), + (b"anthropic-beta", b"prompt-caching-2024-07-31"), + ] + ) + user_api_key_dict = UserAPIKeyAuth(api_key="sk-test") + + result = LiteLLMProxyRequestSetup.add_headers_to_llm_call( + headers, user_api_key_dict + ) + + assert result == { + "x-keep": "plain-ascii", + "anthropic-beta": "prompt-caching-2024-07-31", + } + httpx.Request("POST", "http://localhost:4000", headers=result) + + @pytest.mark.asyncio async def test_overwrite_user_with_key_hash_rejects_alias_without_marker(monkeypatch): from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS