From 0847efaf38c5a79d1830e7959fdcbe6afd1024d7 Mon Sep 17 00:00:00 2001 From: "feng.tsai" Date: Thu, 3 Sep 2026 01:46:25 +0800 Subject: [PATCH] fix(proxy): percent-encode non-latin1 values in response headers A model name or deployment string outside latin-1 (e.g. Chinese) crashed every response with UnicodeEncodeError when FastAPI encoded x-litellm-model-name/x-litellm-model-group. get_custom_headers now percent-encodes any header value that cannot be latin-1 encoded. Fixes #39284 --- litellm/proxy/common_request_processing.py | 11 ++++++-- .../add_retry_fallback_headers.py | 16 +++++++++++ .../proxy/test_common_request_processing.py | 28 +++++++++++++++++++ .../test_add_retry_fallback_headers.py | 13 +++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 05ddef822f1..de1be2a3af9 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -65,7 +65,10 @@ from litellm.proxy.dd_span_tagger import DDSpanTagger from litellm.proxy.route_llm_request import route_request from litellm.proxy.utils import ProxyLogging, _check_and_merge_model_level_guardrails from litellm.router import Router -from litellm.router_utils.add_retry_fallback_headers import get_hidden_params_dict +from litellm.router_utils.add_retry_fallback_headers import ( + get_hidden_params_dict, + safe_header_value, +) from litellm.router_utils.common_utils import resolve_model_group_alias from litellm.types.guardrails import GuardrailEventHooks from litellm.types.router import RouterRateLimitError @@ -1648,7 +1651,11 @@ class ProxyBaseLLMRequestProcessing: headers.update(logging_caching_headers) try: - return {key: str(value) for key, value in headers.items() if value not in exclude_values} + return { + key: safe_header_value(str(value)) + for key, value in headers.items() + if value not in exclude_values + } except Exception as e: verbose_proxy_logger.error("Error setting custom headers: %s", e) return {} diff --git a/litellm/router_utils/add_retry_fallback_headers.py b/litellm/router_utils/add_retry_fallback_headers.py index 3ec92ad226a..af37505d0b9 100644 --- a/litellm/router_utils/add_retry_fallback_headers.py +++ b/litellm/router_utils/add_retry_fallback_headers.py @@ -1,5 +1,6 @@ import json from typing import Any, Final, Protocol, TypedDict, cast +from urllib.parse import quote from pydantic import BaseModel @@ -50,6 +51,21 @@ def prepare_response_for_header_attachment(response: object) -> object | None: return response +def safe_header_value(value: str) -> str: + """ + HTTP header values must be latin-1 encodable (RFC 7230). Values derived from + user-configured strings (e.g. a model name) can contain non-latin-1 + characters, which crashes response header assembly with a UnicodeEncodeError. + Percent-encode such values so they stay ASCII-safe and reversible via + ``urllib.parse.unquote``, instead of dropping them or crashing the request. + """ + try: + value.encode("latin-1") + return value + except UnicodeEncodeError: + return quote(value, safe="") + + def ensure_response_additional_headers(response: object) -> dict[str, object]: hidden_params: Final = get_hidden_params_dict(response, create=isinstance(response, dict)) _write_hidden_params(response, hidden_params) diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index df14224af5c..6ff08baa46c 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -5,6 +5,7 @@ import json from types import SimpleNamespace from typing import AsyncGenerator, Callable, Final, Optional from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import unquote import httpx import pytest @@ -800,6 +801,33 @@ class TestProxyBaseLLMRequestProcessing: assert "x-litellm-response-cost-original" not in headers assert "x-litellm-response-cost-discount-amount" not in headers + def test_get_custom_headers_percent_encodes_non_latin1_model_name(self): + """ + Regression test for https://github.com/BerriAI/litellm/issues/39284: + a deployment or model-group name outside latin-1 (e.g. Chinese) used to + crash header assembly with UnicodeEncodeError when FastAPI/Starlette + encoded the response headers. + """ + mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) + mock_user_api_key_dict.tpm_limit = None + mock_user_api_key_dict.rpm_limit = None + mock_user_api_key_dict.max_budget = None + mock_user_api_key_dict.spend = 0 + + headers = ProxyBaseLLMRequestProcessing.get_custom_headers( + user_api_key_dict=mock_user_api_key_dict, + call_id="test-call-id", + model_id="中文模型", + ) + + encoded_model_id = headers["x-litellm-model-id"] + assert encoded_model_id.encode("latin-1") + assert unquote(encoded_model_id) == "中文模型" + + # Would raise UnicodeEncodeError before the fix. + response = Response(headers=headers) + assert response.headers["x-litellm-model-id"] == encoded_model_id + def test_get_custom_headers_with_margin_info(self): """ Test that margin headers are included when margin is applied. diff --git a/tests/test_litellm/router_utils/test_add_retry_fallback_headers.py b/tests/test_litellm/router_utils/test_add_retry_fallback_headers.py index 3a0deeb13d8..39164923cbc 100644 --- a/tests/test_litellm/router_utils/test_add_retry_fallback_headers.py +++ b/tests/test_litellm/router_utils/test_add_retry_fallback_headers.py @@ -1,4 +1,5 @@ import json +from urllib.parse import unquote from pydantic import BaseModel @@ -7,6 +8,7 @@ from litellm.router_utils.add_retry_fallback_headers import ( add_retry_headers_to_response, get_fallback_errors_from_headers, get_hidden_params_dict, + safe_header_value, ) @@ -162,3 +164,14 @@ def test_add_fallback_headers_to_dict_response(): assert result is response assert response["_hidden_params"]["additional_headers"]["x-litellm-attempted-fallbacks"] == 1 + + +def test_safe_header_value_passes_through_latin1_values(): + assert safe_header_value("azure/gpt-4o") == "azure/gpt-4o" + + +def test_safe_header_value_percent_encodes_non_latin1_values(): + result = safe_header_value("azure/中文模型名稱") + + assert result.encode("latin-1") + assert unquote(result) == "azure/中文模型名稱"