Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions litellm/responses/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions litellm/responses/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]],
Expand Down
60 changes: 60 additions & 0 deletions tests/test_litellm/responses/test_responses_api_request_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading