From 0847efaf38c5a79d1830e7959fdcbe6afd1024d7 Mon Sep 17 00:00:00 2001 From: "feng.tsai" Date: Thu, 3 Sep 2026 01:46:25 +0800 Subject: [PATCH 1/4] 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/中文模型名稱" From 45e102785e7f4279a251c6280003cb297eee4577 Mon Sep 17 00:00:00 2001 From: "feng.tsai" Date: Thu, 3 Sep 2026 02:15:07 +0800 Subject: [PATCH 2/4] style: apply ruff format to get_custom_headers return --- litellm/proxy/common_request_processing.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index de1be2a3af9..03356623c6e 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -1651,11 +1651,7 @@ class ProxyBaseLLMRequestProcessing: headers.update(logging_caching_headers) try: - return { - key: safe_header_value(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 {} From 6cad2291793a4e12b1cbe45edd4d53a45c012722 Mon Sep 17 00:00:00 2001 From: "feng.tsai" Date: Thu, 3 Sep 2026 02:54:40 +0800 Subject: [PATCH 3/4] fix(proxy): also percent-encode a literal % in header values Greptile flagged that a latin-1-safe value containing a literal "%" (e.g. "model%20name") passed through unchanged, making it indistinguishable from a genuinely percent-encoded value once a caller starts unquoting these headers unconditionally. --- .../add_retry_fallback_headers.py | 16 ++++++++-- .../test_add_retry_fallback_headers.py | 32 ++++++++++--------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/litellm/router_utils/add_retry_fallback_headers.py b/litellm/router_utils/add_retry_fallback_headers.py index af37505d0b9..60519000a1b 100644 --- a/litellm/router_utils/add_retry_fallback_headers.py +++ b/litellm/router_utils/add_retry_fallback_headers.py @@ -58,12 +58,24 @@ def safe_header_value(value: str) -> str: 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. + + A value that is already latin-1 safe but contains a literal ``%`` is also + percent-encoded: left alone, it would be indistinguishable from a genuinely + encoded value once mixed with the encoded case, so a caller applying + ``unquote`` unconditionally would silently decode the wrong string. """ + is_latin1_safe: Final = _is_latin1_safe(value) + if is_latin1_safe and "%" not in value: + return value + return quote(value, safe="") + + +def _is_latin1_safe(value: str) -> bool: try: value.encode("latin-1") - return value + return True except UnicodeEncodeError: - return quote(value, safe="") + return False def ensure_response_additional_headers(response: object) -> dict[str, object]: 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 39164923cbc..92f280dc36c 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 @@ -50,15 +50,8 @@ def test_add_fallback_headers_serializes_fallback_errors(): ) assert result is response - assert response._hidden_params["additional_headers"][ - "x-litellm-attempted-fallbacks" - ] == 1 - assert ( - json.loads( - response._hidden_params["additional_headers"]["x-litellm-fallback-errors"] - ) - == fallback_errors - ) + assert response._hidden_params["additional_headers"]["x-litellm-attempted-fallbacks"] == 1 + assert json.loads(response._hidden_params["additional_headers"]["x-litellm-fallback-errors"]) == fallback_errors def test_add_retry_headers_to_streaming_wrapper(): @@ -84,9 +77,7 @@ def test_get_hidden_params_dict_with_pydantic_model_hidden_params(): class Response: def __init__(self): - self._hidden_params = InnerHiddenParams( - additional_headers={"x-custom": "value"} - ) + self._hidden_params = InnerHiddenParams(additional_headers={"x-custom": "value"}) result = get_hidden_params_dict(Response()) assert result == {"additional_headers": {"x-custom": "value"}} @@ -133,9 +124,7 @@ def test_get_fallback_errors_from_headers_existing_list_passthrough(): def test_get_fallback_errors_from_headers_invalid_json_returns_empty(): - result = get_fallback_errors_from_headers( - {"x-litellm-fallback-errors": "not-valid-json-{"} - ) + result = get_fallback_errors_from_headers({"x-litellm-fallback-errors": "not-valid-json-{"}) assert result == [] @@ -175,3 +164,16 @@ def test_safe_header_value_percent_encodes_non_latin1_values(): assert result.encode("latin-1") assert unquote(result) == "azure/中文模型名稱" + + +def test_safe_header_value_escapes_a_literal_percent_sign_too(): + """ + A latin-1-safe value that happens to contain a literal "%" (e.g. + "model%20name") must not pass through unchanged: it would then be + indistinguishable from a genuinely percent-encoded value, and a caller + that always unquotes would silently decode it into the wrong string. + """ + result = safe_header_value("model%20name") + + assert "%" not in result.replace("%25", "") + assert unquote(result) == "model%20name" From 0ae5d2c39b0c8e477d12a615a21cc3476bb6e8a3 Mon Sep 17 00:00:00 2001 From: "feng.tsai" Date: Mon, 14 Sep 2026 18:17:03 +0800 Subject: [PATCH 4/4] chore(ui): regenerate schema.d.ts after the soft_budget docstring removal b9ba80897f dropped the soft_budget line from the user endpoint docstrings without regenerating the dashboard types, so the UI API types check fails on every PR that touches litellm/proxy. Regenerated with npm run gen:api. --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index f5a1733d76b..fe38ca2a81a 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16781,7 +16781,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) @@ -16918,7 +16917,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)