diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 339523603c8..028edea8c3f 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -61,7 +61,7 @@ def create_request_copy(request: Request): return { "method": request.method, "url": str(request.url), - "headers": _safe_get_request_headers(request), + "headers": _safe_get_request_headers(request).copy(), "cookies": request.cookies, "query_params": dict(request.query_params), } diff --git a/litellm/proxy/vertex_ai_endpoints/langfuse_endpoints.py b/litellm/proxy/vertex_ai_endpoints/langfuse_endpoints.py index bff770b7ea3..627618387d5 100644 --- a/litellm/proxy/vertex_ai_endpoints/langfuse_endpoints.py +++ b/litellm/proxy/vertex_ai_endpoints/langfuse_endpoints.py @@ -33,7 +33,7 @@ def create_request_copy(request: Request): return { "method": request.method, "url": str(request.url), - "headers": _safe_get_request_headers(request), + "headers": _safe_get_request_headers(request).copy(), "cookies": request.cookies, "query_params": dict(request.query_params), } diff --git a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py index af366b082a0..05d2ab5d796 100644 --- a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py @@ -761,3 +761,49 @@ async def test_request_body_with_html_script_tags(): f"Message content with HTML was modified during parsing: " f"expected={msg['content']!r}, got={result['messages'][2]['content']!r}" ) + + +def test_safe_get_request_headers_caches_on_request_state(): + """ + Test that _safe_get_request_headers caches the result on request.state + and returns the same object on subsequent calls. + """ + mock_request = MagicMock() + mock_request.headers = {"content-type": "application/json", "authorization": "Bearer sk-123"} + mock_request.state = MagicMock(spec=[]) # empty spec so getattr returns default + + # First call — should create and cache + result1 = _safe_get_request_headers(mock_request) + assert result1 == {"content-type": "application/json", "authorization": "Bearer sk-123"} + assert mock_request.state._cached_headers is result1 + + # Second call — should return the cached object (same identity) + result2 = _safe_get_request_headers(mock_request) + assert result2 is result1 + + +def test_safe_get_request_headers_none_request(): + """ + Test that _safe_get_request_headers returns empty dict for None request. + """ + result = _safe_get_request_headers(None) + assert result == {} + + +def test_safe_get_request_headers_copy_protects_cache(): + """ + Test that callers using .copy() before mutation do not corrupt the cache. + """ + mock_request = MagicMock() + mock_request.headers = {"authorization": "Bearer sk-123", "host": "localhost"} + mock_request.state = MagicMock(spec=[]) + + original = _safe_get_request_headers(mock_request) + + # Simulate what mutation call sites do: copy then pop + mutable = _safe_get_request_headers(mock_request).copy() + mutable.pop("authorization", None) + + # Cache must be unaffected + assert "authorization" in _safe_get_request_headers(mock_request) + assert _safe_get_request_headers(mock_request) is original