diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 216b36aabd..7610cebd09 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -455,7 +455,7 @@ def _custom_auth( def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx.Headers: custom_headers = options.headers or {} - headers_dict = _merge_mappings({**self._auth_headers(options.security), **self.default_headers}, custom_headers) + headers_dict = _merge_headers(self._auth_headers(options.security), self.default_headers, custom_headers) self._validate_headers(headers_dict, custom_headers) # headers are case-insensitive while dictionaries are not. @@ -2212,3 +2212,17 @@ def _merge_mappings( """ merged = {**obj1, **obj2} return {key: value for key, value in merged.items() if not isinstance(value, Omit)} + + +def _merge_headers(*mappings: Headers) -> dict[str, str]: + """Merge headers case-insensitively, with later mappings taking precedence.""" + merged: dict[str, tuple[str, str]] = {} + for mapping in mappings: + for name, value in mapping.items(): + normalized_name = name.lower() + if isinstance(value, Omit): + merged.pop(normalized_name, None) + else: + merged[normalized_name] = (name, value) + + return {name: value for name, value in merged.values()} diff --git a/src/openai/resources/beta/realtime/realtime.py b/src/openai/resources/beta/realtime/realtime.py index 4fa35963b6..a350f6b134 100644 --- a/src/openai/resources/beta/realtime/realtime.py +++ b/src/openai/resources/beta/realtime/realtime.py @@ -31,7 +31,7 @@ from ...._models import construct_type_unchecked from ...._resource import SyncAPIResource, AsyncAPIResource from ...._exceptions import OpenAIError -from ...._base_client import _merge_mappings +from ...._base_client import _merge_headers from ....types.beta.realtime import ( session_update_event_param, response_create_event_param, @@ -378,7 +378,7 @@ async def __aenter__(self) -> AsyncRealtimeConnection: await connect( str(url), user_agent_header=self.__client.user_agent, - additional_headers=_merge_mappings( + additional_headers=_merge_headers( { **auth_headers, "OpenAI-Beta": "realtime=v1", @@ -561,7 +561,7 @@ def __enter__(self) -> RealtimeConnection: connect( str(url), user_agent_header=self.__client.user_agent, - additional_headers=_merge_mappings( + additional_headers=_merge_headers( { **auth_headers, "OpenAI-Beta": "realtime=v1", diff --git a/src/openai/resources/beta/responses/responses.py b/src/openai/resources/beta/responses/responses.py index 5ac018f744..6b519394fe 100644 --- a/src/openai/resources/beta/responses/responses.py +++ b/src/openai/resources/beta/responses/responses.py @@ -45,7 +45,7 @@ ) from ...._exceptions import OpenAIError, WebSocketConnectionClosedError from ...._send_queue import SendQueue -from ...._base_client import _merge_mappings, make_request_options +from ...._base_client import _merge_headers, make_request_options from ...._event_handler import EventHandlerRegistry from ....types.beta.beta_response import BetaResponse from ....types.beta.beta_tool_param import BetaToolParam @@ -4384,7 +4384,7 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async return await connect( str(url), user_agent_header=self.__client.user_agent, - additional_headers=_merge_mappings( + additional_headers=_merge_headers( { **self.__client.auth_headers, }, @@ -4829,7 +4829,7 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo return connect( str(url), user_agent_header=self.__client.user_agent, - additional_headers=_merge_mappings( + additional_headers=_merge_headers( { **self.__client.auth_headers, }, diff --git a/src/openai/resources/realtime/realtime.py b/src/openai/resources/realtime/realtime.py index e4c5bd8163..9bf722672b 100644 --- a/src/openai/resources/realtime/realtime.py +++ b/src/openai/resources/realtime/realtime.py @@ -34,7 +34,7 @@ from ..._resource import SyncAPIResource, AsyncAPIResource from ..._exceptions import OpenAIError, WebSocketConnectionClosedError from ..._send_queue import SendQueue -from ..._base_client import _merge_mappings +from ..._base_client import _merge_headers from .client_secrets import ( ClientSecrets, AsyncClientSecrets, @@ -710,7 +710,7 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async return await connect( str(url), user_agent_header=self.__client.user_agent, - additional_headers=_merge_mappings( + additional_headers=_merge_headers( { **auth_headers, }, @@ -1178,7 +1178,7 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo return connect( str(url), user_agent_header=self.__client.user_agent, - additional_headers=_merge_mappings( + additional_headers=_merge_headers( { **auth_headers, }, diff --git a/src/openai/resources/responses/responses.py b/src/openai/resources/responses/responses.py index 8131b57ca2..77cc200fb1 100644 --- a/src/openai/resources/responses/responses.py +++ b/src/openai/resources/responses/responses.py @@ -55,7 +55,7 @@ ) from ..._exceptions import OpenAIError, WebSocketConnectionClosedError from ..._send_queue import SendQueue -from ..._base_client import _merge_mappings, make_request_options +from ..._base_client import _merge_headers, make_request_options from ..._event_handler import EventHandlerRegistry from ...types.responses import ( response_create_params, @@ -4335,7 +4335,7 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async return await connect( str(url), user_agent_header=self.__client.user_agent, - additional_headers=_merge_mappings( + additional_headers=_merge_headers( { **self.__client.auth_headers, }, @@ -4780,7 +4780,7 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo return connect( str(url), user_agent_header=self.__client.user_agent, - additional_headers=_merge_mappings( + additional_headers=_merge_headers( { **self.__client.auth_headers, }, diff --git a/tests/test_client.py b/tests/test_client.py index 2d8955a58e..c990ec1549 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -35,6 +35,7 @@ DefaultHttpxClient, DefaultAsyncHttpxClient, get_platform, + _merge_headers, make_request_options, ) @@ -124,6 +125,14 @@ def _get_open_connections(client: OpenAI | AsyncOpenAI) -> int: class TestOpenAI: + def test_merge_headers_case_insensitively(self) -> None: + headers = _merge_headers( + {"Authorization": "Bearer real", "X-Remove": "value"}, + {"authorization": "Bearer custom", "x-remove": Omit()}, + ) + + assert headers == {"authorization": "Bearer custom"} + @pytest.mark.respx(base_url=base_url) def test_raw_response(self, respx_mock: MockRouter, client: OpenAI) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) @@ -454,6 +463,16 @@ def test_validate_headers(self) -> None: assert request.headers.get("Authorization") == f"Bearer {api_key}" + lowercase_auth_request = client._build_request( + FinalRequestOptions(method="get", url="/foo", headers={"authorization": "Bearer custom"}) + ) + assert lowercase_auth_request.headers.get_list("Authorization") == ["Bearer custom"] + + omitted_auth_request = client._build_request( + FinalRequestOptions(method="get", url="/foo", headers={"authorization": Omit()}) + ) + assert "Authorization" not in omitted_auth_request.headers + admin_request = client._build_request( FinalRequestOptions( method="get", @@ -1718,6 +1737,16 @@ async def test_validate_headers(self) -> None: request = client._build_request(options) assert request.headers.get("Authorization") == f"Bearer {api_key}" + lowercase_auth_request = client._build_request( + FinalRequestOptions(method="get", url="/foo", headers={"authorization": "Bearer custom"}) + ) + assert lowercase_auth_request.headers.get_list("Authorization") == ["Bearer custom"] + + omitted_auth_request = client._build_request( + FinalRequestOptions(method="get", url="/foo", headers={"authorization": Omit()}) + ) + assert "Authorization" not in omitted_auth_request.headers + admin_request = client._build_request( FinalRequestOptions( method="get",