fix(fallbacks): ignore preserve_litellm_internal_headers for raw httpx.Headers inputs

This commit is contained in:
Varshith 2026-05-22 09:52:03 -05:00
parent 068e4ade1b
commit 3fa2bd34bb
2 changed files with 37 additions and 7 deletions

View file

@ -247,15 +247,25 @@ def process_response_headers(
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`).
`preserve_litellm_internal_headers` must only be True when the input is a
LiteLLM-owned dict (e.g. `_hidden_params["additional_headers"]` that has
already been through one round of processing). For raw upstream provider
headers — whether passed as `httpx.Headers` or a plain dict — it must
remain False, otherwise a malicious provider returning `x-litellm-*` could
spoof LiteLLM-internal markers (e.g. `x-litellm-attempted-fallbacks`).
When the input is an `httpx.Headers` object the flag is always treated as
False regardless of what the caller requested, because `httpx.Headers` is
always a raw provider response and can never be LiteLLM-owned.
"""
from litellm.types.utils import OPENAI_RESPONSE_HEADERS
# Raw httpx.Headers objects come directly from provider HTTP responses and
# must never be treated as LiteLLM-owned, regardless of caller intent.
_preserve = preserve_litellm_internal_headers and isinstance(
response_headers, dict
)
openai_headers = {}
processed_headers = {}
additional_headers = {}
@ -267,7 +277,7 @@ def process_response_headers(
"llm_provider-"
): # return raw provider headers (incl. openai-compatible ones)
processed_headers[k] = v
elif preserve_litellm_internal_headers and k.startswith("x-litellm-"):
elif _preserve 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

View file

@ -1,6 +1,7 @@
"""Tests for litellm.litellm_core_utils.fallback_utils."""
import pytest
import httpx
import litellm
from litellm.litellm_core_utils.core_helpers import process_response_headers
@ -147,3 +148,22 @@ def test_process_response_headers_prefixes_x_litellm_from_raw_provider():
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"
def test_process_response_headers_ignores_preserve_flag_for_httpx_headers():
"""
Some providers store raw httpx.Headers directly in _hidden_params["additional_headers"]
without a prior normalization pass. If preserve_litellm_internal_headers=True were
honored for httpx.Headers inputs, a provider returning x-litellm-attempted-fallbacks
could spoof it as a bare LiteLLM-internal marker and make the proxy skip
stamping the correct response model. The flag must be ignored for httpx.Headers.
"""
raw = httpx.Headers(
{
"x-litellm-attempted-fallbacks": "1",
"content-type": "application/json",
}
)
result = process_response_headers(raw, preserve_litellm_internal_headers=True)
assert "x-litellm-attempted-fallbacks" not in result
assert result["llm_provider-x-litellm-attempted-fallbacks"] == "1"