diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 944cf58df1c6..aa5da40a02a9 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -927,6 +927,13 @@ def responses( _is_async = kwargs.pop("aresponses", False) is True use_chat_completions_api = _pop_use_chat_completions_api_kw(kwargs) + client_headers = kwargs.get("headers") + extra_headers = ResponsesAPIRequestUtils.merge_client_forwarded_headers( + extra_headers=extra_headers, + client_headers=client_headers if isinstance(client_headers, dict) else None, + ) + local_vars["extra_headers"] = extra_headers + # Convert text_format to text parameter if provided text = ResponsesAPIRequestUtils.convert_text_format_to_text_param(text_format=text_format, text=text) if text is not None: diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 12c890ec91d9..25a355edbc16 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -89,6 +89,28 @@ def merge_prompt_management_input( ) return [*merged_input] + @staticmethod + def merge_client_forwarded_headers( + extra_headers: dict[str, Any] | None, + client_headers: dict[str, str] | None, + ) -> dict[str, Any] | None: + """ + Merge headers forwarded by the proxy (`headers` kwarg, set when + `forward_client_headers_to_llm_api` is enabled) into `extra_headers`. + + `extra_headers` wins on conflicts, since it is set explicitly by the caller. + Header names are compared case-insensitively, as HTTP defines them. + """ + if not client_headers: + return extra_headers + if not extra_headers: + return dict(client_headers) + explicit_names = frozenset(name.lower() for name in extra_headers) + return { + **{name: value for name, value in client_headers.items() if name.lower() not in explicit_names}, + **extra_headers, + } + @staticmethod def _check_valid_arg( supported_params: Optional[List[str]], diff --git a/tests/test_litellm/responses/test_responses_api_request_body.py b/tests/test_litellm/responses/test_responses_api_request_body.py index 44dfa240d423..1c217d1a67f9 100644 --- a/tests/test_litellm/responses/test_responses_api_request_body.py +++ b/tests/test_litellm/responses/test_responses_api_request_body.py @@ -259,3 +259,63 @@ async def test_aresponses_bedrock_mantle_service_tier_raises_without_drop_params mock_post.assert_not_called() assert "drop_params" in str(excinfo.value) assert "priority" in str(excinfo.value) + + +async def _aresponses_and_get_request_headers(**request_kwargs) -> dict: + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = MockResponse(_minimal_responses_api_payload("resp_headers_test", "gpt-4o"), 200) + + await litellm.aresponses( + model="openai/gpt-4o", + api_key="fake-api-key", + input="hi", + **request_kwargs, + ) + + mock_post.assert_called_once() + return dict(mock_post.call_args.kwargs["headers"]) + + +@pytest.mark.asyncio +async def test_aresponses_forwards_client_headers_kwarg_to_provider(): + """ + The proxy passes client headers it forwards (`forward_client_headers_to_llm_api`) + as a `headers` kwarg; those must reach the provider request. + """ + request_headers = await _aresponses_and_get_request_headers(headers={"x-my-new-header": "hello-from-client"}) + + assert request_headers["x-my-new-header"] == "hello-from-client" + + +@pytest.mark.asyncio +async def test_aresponses_merges_client_headers_with_extra_headers(): + """ + A `headers` kwarg and an explicit `extra_headers` are merged, with + `extra_headers` winning on conflicts. + """ + request_headers = await _aresponses_and_get_request_headers( + headers={"x-my-new-header": "hello-from-client", "x-shared": "from-client"}, + extra_headers={"x-explicit": "from-caller", "x-shared": "from-caller"}, + ) + + assert request_headers["x-my-new-header"] == "hello-from-client" + assert request_headers["x-explicit"] == "from-caller" + assert request_headers["x-shared"] == "from-caller" + + +@pytest.mark.asyncio +async def test_aresponses_client_header_conflict_is_case_insensitive(): + """ + HTTP header names are case-insensitive, so a differently cased client header + must not survive alongside the explicit `extra_headers` value. + """ + request_headers = await _aresponses_and_get_request_headers( + headers={"X-Shared": "from-client"}, + extra_headers={"x-shared": "from-caller"}, + ) + + assert [name for name in request_headers if name.lower() == "x-shared"] == ["x-shared"] + assert request_headers["x-shared"] == "from-caller"