diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 47392621ae3..536ac309f42 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -242,7 +242,18 @@ def _get_parent_otel_span_from_kwargs( return None -def process_response_headers(response_headers: Union[httpx.Headers, dict]) -> dict: +def process_response_headers( + response_headers: Union[httpx.Headers, dict], + preserve_litellm_internal_headers: bool = False, +) -> dict: + """ + `preserve_litellm_internal_headers` must only be True when the input dict is + a LiteLLM-owned structure (e.g. `_hidden_params["additional_headers"]` that + has already been through one round of processing). For raw upstream + provider headers it must remain False, otherwise a malicious provider + returning `x-litellm-*` could spoof LiteLLM-internal markers + (e.g. `x-litellm-attempted-fallbacks`). + """ from litellm.types.utils import OPENAI_RESPONSE_HEADERS openai_headers = {} @@ -256,7 +267,7 @@ def process_response_headers(response_headers: Union[httpx.Headers, dict]) -> di "llm_provider-" ): # return raw provider headers (incl. openai-compatible ones) processed_headers[k] = v - elif k.startswith("x-litellm-"): + elif preserve_litellm_internal_headers and k.startswith("x-litellm-"): # LiteLLM's own internal headers (e.g. x-litellm-attempted-fallbacks, # x-litellm-model-group) are not LLM provider headers and must not be # prefixed. Downstream consumers (proxy override, callers checking diff --git a/litellm/litellm_core_utils/llm_response_utils/response_metadata.py b/litellm/litellm_core_utils/llm_response_utils/response_metadata.py index 06933a6fbcb..ba870eb9459 100644 --- a/litellm/litellm_core_utils/llm_response_utils/response_metadata.py +++ b/litellm/litellm_core_utils/llm_response_utils/response_metadata.py @@ -49,7 +49,8 @@ class ResponseMetadata: result=self.result, litellm_model_name=model, router_model_id=model_id ), "additional_headers": process_response_headers( - self._get_value_from_hidden_params("additional_headers") or {} + self._get_value_from_hidden_params("additional_headers") or {}, + preserve_litellm_internal_headers=True, ), "litellm_model_name": model, } diff --git a/tests/test_litellm/litellm_core_utils/test_fallback_utils.py b/tests/test_litellm/litellm_core_utils/test_fallback_utils.py index 8c73f5cc864..142602ccb27 100644 --- a/tests/test_litellm/litellm_core_utils/test_fallback_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_fallback_utils.py @@ -109,20 +109,41 @@ async def test_async_completion_with_fallbacks_header_is_zero_when_primary_succe assert response.choices[0].message.content == "primary-resp" -def test_process_response_headers_preserves_x_litellm_headers(): +def test_process_response_headers_preserves_x_litellm_headers_when_internal(): """ `process_response_headers` must not add the `llm_provider-` prefix to - LiteLLM's own internal headers (anything starting with `x-litellm-`). - These are markers set by LiteLLM (e.g. fallback / retry headers); the - proxy and other callers look up the bare key. + LiteLLM's own internal headers (anything starting with `x-litellm-`) when + the caller has marked the input as LiteLLM-owned. These are markers set by + LiteLLM (e.g. fallback / retry headers); the proxy and other callers look + up the bare key. """ result = process_response_headers( { "x-litellm-attempted-fallbacks": 1, "x-litellm-model-group": "gpt-4", "x-stainless-arch": "arm64", - } + }, + preserve_litellm_internal_headers=True, ) assert result["x-litellm-attempted-fallbacks"] == 1 assert result["x-litellm-model-group"] == "gpt-4" assert result["llm_provider-x-stainless-arch"] == "arm64" + + +def test_process_response_headers_prefixes_x_litellm_from_raw_provider(): + """ + On raw upstream-provider headers (default `preserve_litellm_internal_headers=False`), + a header whose name starts with `x-litellm-` MUST still get the + `llm_provider-` prefix. Otherwise a malicious provider could return + `x-litellm-attempted-fallbacks` and spoof a LiteLLM-internal marker, + bypassing the proxy model-override guard. + """ + result = process_response_headers( + { + "x-litellm-attempted-fallbacks": 99, + "x-stainless-arch": "arm64", + } + ) + assert "x-litellm-attempted-fallbacks" not in result + assert result["llm_provider-x-litellm-attempted-fallbacks"] == 99 + assert result["llm_provider-x-stainless-arch"] == "arm64"