diff --git a/.castiron.stats.yml b/.castiron.stats.yml index ac5d1917c3..f7808a54e5 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,7 +1,7 @@ schema_version: 1 -generation_id: 7b7feee1-6807-4c20-98e0-610aab05b2da +generation_id: 65a0f13e-756e-4f10-b075-12ae5d3d27a9 openapi_spec_hash: 51b199ef7fd5542bc4910765f9becd60 openapi_transformed_spec_hash: 73822d924c15a6ed58dcfe8f616bd4be config_hash: b707b209c892562880c415693d111ad6 -codegen_sha: e1054c07fb03a8025411f0ff6ee0a5ff17e09109 -codegen_hash: 73c66bd2bff47ee2bbd2d04a174184dec767ebe300a3bd41443938d914013ef0 +codegen_sha: 52c5bafacb27ede81a3d66b62b29d946df4ed2cd +codegen_hash: 536c408e94523f4af5a3ddf0c2f8f0764014bad6b9287cacdb9b8e3eeb60ba7d diff --git a/scripts/castiron/generated-files.json b/scripts/castiron/generated-files.json index b336368297..665807274b 100644 --- a/scripts/castiron/generated-files.json +++ b/scripts/castiron/generated-files.json @@ -599,7 +599,7 @@ }, "src/openai/resources/realtime/realtime.py": { "mode": "100644", - "sha256": "d45ef5f2d1e25e6f8761592a9fb5739d7a344c6e64a4e7a4006418c16f05a540" + "sha256": "9cb69e1f54c6e21d9111ddfb59a2a4de18466a1a86487fc65a0e17224b72475e" }, "src/openai/resources/realtime/translations/__init__.py": { "mode": "100644", diff --git a/src/openai/lib/_client_auth.py b/src/openai/lib/_client_auth.py index c5f3f92208..81e0c071b1 100644 --- a/src/openai/lib/_client_auth.py +++ b/src/openai/lib/_client_auth.py @@ -10,6 +10,7 @@ from ..auth import WorkloadIdentity, X509WorkloadIdentity from .._types import ( + Omit, Headers, Timeout, NotGiven, @@ -18,7 +19,7 @@ ) from .._models import SecurityOptions, FinalRequestOptions from .._provider import _Provider, _ProviderRuntime -from .._base_client import DEFAULT_MAX_RETRIES +from .._base_client import DEFAULT_MAX_RETRIES, _merge_headers from .._data_residency import DataResidency if TYPE_CHECKING: @@ -33,6 +34,10 @@ __all__ = ["_has_header", "_has_omitted_header"] +class _MissingAPIKey(Omit): + """Suppress shared credentials without opting out of missing-auth validation.""" + + def _has_header(headers: Headers, header: str) -> bool: header = header.lower() return any((key.lower() == header for key in headers)) @@ -40,7 +45,31 @@ def _has_header(headers: Headers, header: str) -> bool: def _has_omitted_header(headers: Headers, header: str) -> bool: header = header.lower() - return any((key.lower() == header and isinstance(value, _sdk_client.Omit) for key, value in headers.items())) + return any( + key.lower() == header and isinstance(value, _sdk_client.Omit) and not isinstance(value, _MissingAPIKey) + for key, value in headers.items() + ) + + +def _set_request_api_key( + self: _sdk_client.OpenAI | _sdk_client.AsyncOpenAI, options: FinalRequestOptions, api_key: str +) -> None: + custom_headers = options.headers if not isinstance(options.headers, NotGiven) else {} + # Keep explicit overrides (including Omit and differently cased duplicates) intact. + if _has_header(custom_headers, "Authorization"): + return + + auth_headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + if not api_key and options.security.get("admin_api_key_auth", False): + auth_headers = self._admin_api_key_auth + headers = _merge_headers(auth_headers, self.default_headers, custom_headers) + authorization = next((value for name, value in headers.items() if name.lower() == "authorization"), None) + # A later refresh may change self.api_key; this request must keep its own result. + # Replace the mapping so retry inputs and caller-owned headers remain untouched. + options.headers = { + "Authorization": authorization if authorization is not None else _MissingAPIKey(), + **custom_headers, + } def syncbase_url(self: _sdk_client.OpenAI) -> httpx2.URL: @@ -343,7 +372,7 @@ def sync_prepare_options( if self._provider_runtime.transform_request is not None: options = self._provider_runtime.transform_request(options) elif self._api_key_provider is not None and options.security.get("bearer_auth", False): - self._refresh_api_key() + _set_request_api_key(self, options, self._refresh_api_key()) return super(_owner, self)._prepare_options(options) @@ -364,7 +393,9 @@ def sync_custom_auth( def sync_refresh_api_key(self: _sdk_client.OpenAI) -> str: if self._api_key_provider is not None: - self.api_key = self._api_key_provider() + api_key = self._api_key_provider() + self.api_key = api_key + return api_key return self.api_key @@ -812,7 +843,7 @@ async def async_prepare_options( elif self._provider_runtime.transform_request is not None: options = self._provider_runtime.transform_request(options) elif self._api_key_provider is not None and options.security.get("bearer_auth", False): - await self._refresh_api_key() + _set_request_api_key(self, options, await self._refresh_api_key()) return await super(_owner, self)._prepare_options(options) @@ -835,7 +866,9 @@ def asynccustom_auth(_owner: type[_sdk_client.AsyncOpenAI], /, self: _sdk_client async def async_refresh_api_key(self: _sdk_client.AsyncOpenAI) -> str: if self._api_key_provider is not None: - self.api_key = await self._api_key_provider() + api_key = await self._api_key_provider() + self.api_key = api_key + return api_key return self.api_key diff --git a/src/openai/lib/azure.py b/src/openai/lib/azure.py index 4752505de5..02432a952f 100644 --- a/src/openai/lib/azure.py +++ b/src/openai/lib/azure.py @@ -18,7 +18,8 @@ from .._streaming import Stream, AsyncStream from ..auth._x509 import is_x509_workload_identity from .._exceptions import OpenAIError -from .._base_client import DEFAULT_MAX_RETRIES, BaseClient +from ._client_auth import _MissingAPIKey +from .._base_client import DEFAULT_MAX_RETRIES, BaseClient, _merge_headers from .._data_residency import DataResidency _deployments_endpoints = set( @@ -53,7 +54,10 @@ def _has_header(headers: Headers, header: str) -> bool: def _has_auth_header(headers: Headers) -> bool: - return _has_header(headers, "Authorization") or _has_header(headers, "api-key") + return any( + name.lower() in ("authorization", "api-key") and not isinstance(value, _MissingAPIKey) + for name, value in headers.items() + ) _AZURE_AUTH_ORIGIN = "openai.azure_auth_origin" @@ -489,8 +493,7 @@ def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None: @override def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: - if self._api_key_provider is not None: - self._refresh_api_key() + api_key = self._refresh_api_key() if self._api_key_provider is not None else self.api_key headers: dict[str, str | Omit] = {**options.headers} if is_given(options.headers) else {} @@ -501,26 +504,43 @@ def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: if azure_ad_token is not None: if not _has_header(headers, "Authorization"): headers["Authorization"] = f"Bearer {azure_ad_token}" - elif self.api_key and self.api_key != API_KEY_SENTINEL: + elif api_key and api_key != API_KEY_SENTINEL: if not _has_header(headers, "api-key"): - headers["api-key"] = self.api_key + headers["api-key"] = api_key elif _has_auth_header(headers) or _has_auth_header(self.default_headers): pass else: # should never be hit raise ValueError("Unable to handle auth") + if self._api_key_provider is not None and not _has_header(headers, "api-key"): + # An empty callable result must not inherit another request's key. + default_api_key = next( + (value for name, value in _merge_headers(self.default_headers).items() if name.lower() == "api-key"), + None, + ) + headers["api-key"] = default_api_key if default_api_key is not None else _MissingAPIKey() + return options - def _configure_realtime(self, model: str, extra_query: Query) -> tuple[httpx2.URL, dict[str, str]]: + def _configure_realtime_with_api_key( + self, model: str, extra_query: Query, *, api_key: str + ) -> tuple[httpx2.URL, dict[str, str]]: + return self._configure_realtime(model, extra_query, api_key=api_key) + + def _configure_realtime( + self, model: str, extra_query: Query, *, api_key: str | NotGiven = NOT_GIVEN + ) -> tuple[httpx2.URL, dict[str, str]]: + if not is_given(api_key): + api_key = self.api_key auth_headers = {} query = { **extra_query, "api-version": self._api_version, "deployment": self._azure_deployment or model, } - if self.api_key and self.api_key != "": - auth_headers = {"api-key": self.api_key} + if api_key and api_key != "": + auth_headers = {"api-key": api_key} else: token = self._get_azure_ad_token() if token: @@ -852,8 +872,7 @@ def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None: @override async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: - if self._api_key_provider is not None: - await self._refresh_api_key() + api_key = await self._refresh_api_key() if self._api_key_provider is not None else self.api_key headers: dict[str, str | Omit] = {**options.headers} if is_given(options.headers) else {} @@ -864,26 +883,43 @@ async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOp if azure_ad_token is not None: if not _has_header(headers, "Authorization"): headers["Authorization"] = f"Bearer {azure_ad_token}" - elif self.api_key and self.api_key != API_KEY_SENTINEL: + elif api_key and api_key != API_KEY_SENTINEL: if not _has_header(headers, "api-key"): - headers["api-key"] = self.api_key + headers["api-key"] = api_key elif _has_auth_header(headers) or _has_auth_header(self.default_headers): pass else: # should never be hit raise ValueError("Unable to handle auth") + if self._api_key_provider is not None and not _has_header(headers, "api-key"): + # An empty callable result must not inherit another request's key. + default_api_key = next( + (value for name, value in _merge_headers(self.default_headers).items() if name.lower() == "api-key"), + None, + ) + headers["api-key"] = default_api_key if default_api_key is not None else _MissingAPIKey() + return options - async def _configure_realtime(self, model: str, extra_query: Query) -> tuple[httpx2.URL, dict[str, str]]: + async def _configure_realtime_with_api_key( + self, model: str, extra_query: Query, *, api_key: str + ) -> tuple[httpx2.URL, dict[str, str]]: + return await self._configure_realtime(model, extra_query, api_key=api_key) + + async def _configure_realtime( + self, model: str, extra_query: Query, *, api_key: str | NotGiven = NOT_GIVEN + ) -> tuple[httpx2.URL, dict[str, str]]: + if not is_given(api_key): + api_key = self.api_key auth_headers = {} query = { **extra_query, "api-version": self._api_version, "deployment": self._azure_deployment or model, } - if self.api_key and self.api_key != "": - auth_headers = {"api-key": self.api_key} + if api_key and api_key != "": + auth_headers = {"api-key": api_key} else: token = await self._get_azure_ad_token() if token: diff --git a/src/openai/resources/realtime/realtime.py b/src/openai/resources/realtime/realtime.py index 4180fc06af..13c7b0063c 100644 --- a/src/openai/resources/realtime/realtime.py +++ b/src/openai/resources/realtime/realtime.py @@ -668,14 +668,18 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async if self.__call_id is not omit: extra_query = {**extra_query, "call_id": self.__call_id} if is_async_azure_client(self.__client): - await self.__client._refresh_api_key() + api_key = await self.__client._refresh_api_key() from ...lib._azure_websocket import _AzureWebSocketConnect as connect model = self.__model if not model: raise OpenAIError("`model` is required for Azure Realtime API") else: - url, auth_headers = await self.__client._configure_realtime(model, extra_query) + configure = getattr(self.__client, "_configure_realtime_with_api_key", None) + if configure is not None: + url, auth_headers = await configure(model, extra_query, api_key=api_key) + else: + url, auth_headers = await self.__client._configure_realtime(model, extra_query) prepared_headers: Headers = extra_headers else: url = self._prepare_url() @@ -1123,12 +1127,16 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo if self.__call_id is not omit: extra_query = {**extra_query, "call_id": self.__call_id} if is_azure_client(self.__client): - self.__client._refresh_api_key() + api_key = self.__client._refresh_api_key() model = self.__model if not model: raise OpenAIError("`model` is required for Azure Realtime API") else: - url, auth_headers = self.__client._configure_realtime(model, extra_query) + configure = getattr(self.__client, "_configure_realtime_with_api_key", None) + if configure is not None: + url, auth_headers = configure(model, extra_query, api_key=api_key) + else: + url, auth_headers = self.__client._configure_realtime(model, extra_query) prepared_headers: Headers = extra_headers else: url = self._prepare_url() diff --git a/tests/lib/test_azure_request_credentials.py b/tests/lib/test_azure_request_credentials.py new file mode 100644 index 0000000000..ae9ac04a78 --- /dev/null +++ b/tests/lib/test_azure_request_credentials.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import asyncio +import threading +from typing import cast +from contextlib import nullcontext +from contextvars import ContextVar +from typing_extensions import override + +import httpx2 +import pytest +from websockets.sync.server import ServerConnection + +from openai import AzureOpenAI, AsyncAzureOpenAI +from openai._types import Omit + +from .responses.test_websocket_session import script_server + + +@pytest.fixture(autouse=True) +def bypass_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + # WebSocket traffic must remain on the test's loopback server. + for scheme in ("all", "http", "https", "ws", "wss", "socks"): + monkeypatch.delenv(f"{scheme}_proxy", raising=False) + monkeypatch.delenv(f"{scheme.upper()}_PROXY", raising=False) + monkeypatch.setenv("NO_PROXY", "*") + monkeypatch.setenv("no_proxy", "*") + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("route", ["http", "realtime"]) +async def test_azure_overlapping_requests_keep_their_callable_keys(mode: str, route: str) -> None: + await check_azure_overlapping_requests(mode, route, first_key="fake-azure-key-A") + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +async def test_empty_azure_key_never_borrows_another_request_key(mode: str) -> None: + await check_azure_overlapping_requests( + mode, "http", first_key="", default_headers={"Authorization": "Bearer fake-default"} + ) + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +async def test_empty_azure_realtime_key_never_borrows_another_request_key(mode: str) -> None: + await check_azure_overlapping_requests(mode, "realtime", first_key="") + + +async def check_azure_overlapping_requests( + mode: str, route: str, *, first_key: str, default_headers: dict[str, str] | None = None +) -> None: + caller: ContextVar[str] = ContextVar("azure_credential_caller", default="") + a_refreshed = threading.Event() + b_finished = threading.Event() + provider_calls: list[str] = [] + received: dict[str, tuple[list[str], list[str]]] = {} + + def key() -> str: + label = caller.get() + provider_calls.append(label) + return first_key if label == "A" else "fake-azure-key-B" + + async def async_key() -> str: + await asyncio.sleep(0) + return key() + + # Schedule B after A has obtained its key but before A builds HTTP or + # WebSocket headers. Neither hook replaces the SDK's value or state. + class SyncClient(AzureOpenAI): + @override + def _refresh_api_key(self) -> str: + resolved = super()._refresh_api_key() + if caller.get() == "A": + a_refreshed.set() + assert b_finished.wait(5), "B did not finish before A resumed" + return resolved + + class AsyncClient(AsyncAzureOpenAI): + @override + async def _refresh_api_key(self) -> str: + resolved = await super()._refresh_api_key() + if caller.get() == "A": + a_refreshed.set() + assert await asyncio.to_thread(b_finished.wait, 5), "B did not finish before A resumed" + return resolved + + def script(socket: ServerConnection) -> None: + assert socket.request is not None + headers = socket.request.headers + received[headers["X-Test-Caller"]] = (headers.get_all("api-key"), headers.get_all("Authorization")) + socket.send('{"type":"audit.future.event"}') + + def http_handler(request: httpx2.Request) -> httpx2.Response: + headers = request.headers + received[headers["X-Test-Caller"]] = (headers.get_list("api-key"), headers.get_list("Authorization")) + return httpx2.Response(200, json={"object": "list", "data": [], "has_more": False}) + + def call_sync(client: AzureOpenAI, label: str) -> None: + headers = {"X-Test-Caller": label} + if route == "http": + client.models.list(extra_headers=headers) + else: + with client.realtime.connect(model="fake-deployment", extra_headers=headers) as connection: + assert connection.recv().type == "audit.future.event" + + async def call_async(client: AsyncAzureOpenAI, label: str) -> None: + headers = {"X-Test-Caller": label} + if route == "http": + await client.models.list(extra_headers=headers) + else: + async with client.realtime.connect(model="fake-deployment", extra_headers=headers) as connection: + assert (await connection.recv()).type == "audit.future.event" + + async def run_calls(client: AzureOpenAI | AsyncAzureOpenAI) -> None: + async def call(label: str) -> None: + token = caller.set(label) + try: + if isinstance(client, AsyncAzureOpenAI): + await call_async(client, label) + else: + await asyncio.to_thread(call_sync, client, label) + finally: + caller.reset(token) + if label == "B": + b_finished.set() + + first = asyncio.create_task(call("A")) + try: + assert await asyncio.to_thread(a_refreshed.wait, 5), "A never resolved its callable key" + await asyncio.wait_for(call("B"), 8) + await asyncio.wait_for(first, 8) + finally: + b_finished.set() + if not first.done(): + await asyncio.wait_for(first, 8) + + # Only WebSocket cases open a server. HTTP uses MockTransport without DNS + # resolution. The Azure endpoint and every credential are fake. + server = nullcontext("ws://127.0.0.1:1") if route == "http" else script_server(script, expected_connections=2) + with server as url: + transport = httpx2.MockTransport(http_handler) + ws_url = httpx2.URL(url).copy_with(scheme="ws") + if mode == "sync": + with SyncClient( + azure_endpoint="https://azure.test", + api_version="2024-02-01", + api_key=key, + default_headers=default_headers, + websocket_base_url=ws_url, + max_retries=0, + http_client=httpx2.Client(transport=transport, trust_env=False), + ) as client: + await run_calls(client) + else: + async with AsyncClient( + azure_endpoint="https://azure.test", + api_version="2024-02-01", + api_key=async_key, + default_headers=default_headers, + websocket_base_url=ws_url, + max_retries=0, + http_client=httpx2.AsyncClient(transport=transport, trust_env=False), + ) as async_client: + await run_calls(async_client) + + assert provider_calls == ["A", "B"] + authorization = [] if default_headers is None else ["Bearer fake-default"] + assert received == { + "A": ([first_key] if first_key else [], authorization), + "B": (["fake-azure-key-B"], authorization), + } + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("omitted_header", ["Authorization", "api-key"]) +async def test_empty_azure_key_with_omitted_default_still_validates_credentials(mode: str, omitted_header: str) -> None: + calls = 0 + + def key() -> str: + nonlocal calls + calls += 1 + return "" + + async def async_key() -> str: + return key() + + def unexpected_request(_: httpx2.Request) -> httpx2.Response: + pytest.fail("Azure must validate missing credentials before sending the HTTP request") + + transport = httpx2.MockTransport(unexpected_request) + # Omit in client defaults is deliberately runtime-only, outside the public + # Mapping[str, str] signature. Preserve its existing credential validation. + default_headers = cast("dict[str, str]", {omitted_header: Omit()}) + if mode == "sync": + with AzureOpenAI( + azure_endpoint="https://azure.test", + api_version="2024-02-01", + api_key=key, + default_headers=default_headers, + max_retries=0, + http_client=httpx2.Client(transport=transport, trust_env=False), + ) as client: + with pytest.raises(TypeError, match="Could not resolve authentication"): + client.models.list() + else: + async with AsyncAzureOpenAI( + azure_endpoint="https://azure.test", + api_version="2024-02-01", + api_key=async_key, + default_headers=default_headers, + max_retries=0, + http_client=httpx2.AsyncClient(transport=transport, trust_env=False), + ) as async_client: + with pytest.raises(TypeError, match="Could not resolve authentication"): + await async_client.models.list() + + assert calls == 1 diff --git a/tests/lib/test_request_credentials.py b/tests/lib/test_request_credentials.py new file mode 100644 index 0000000000..84de2a9a50 --- /dev/null +++ b/tests/lib/test_request_credentials.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +import asyncio +import threading +from contextlib import nullcontext +from contextvars import ContextVar +from typing_extensions import override + +import httpx2 +import pytest +from websockets.sync.server import ServerConnection + +from openai import OpenAI, AsyncOpenAI +from openai._models import FinalRequestOptions +from tests.api_resources._websocket_resources import WEBSOCKET_RESOURCES + +from .test_websocket_redirects import options, resource +from .responses.test_websocket_session import script_server + + +@pytest.fixture(autouse=True) +def bypass_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NO_PROXY", "127.0.0.1") + monkeypatch.setenv("no_proxy", "127.0.0.1") + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("name", ["http", *WEBSOCKET_RESOURCES]) +async def test_request_keeps_its_callable_credential(mode: str, name: str) -> None: + await check_two_requests(mode, name, first_key="fake-key-A") + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("name", ["http", "responses"]) +async def test_empty_credential_never_borrows_another_request_key(mode: str, name: str) -> None: + await check_two_requests(mode, name, first_key="") + + +async def check_two_requests(mode: str, name: str, *, first_key: str) -> None: + # The prepare hook models another request finishing after refresh and before + # this one builds its headers. Async hooks may legitimately yield here too. + caller = ContextVar("credential_test_caller", default="") + a_prepared = threading.Event() + b_finished = threading.Event() + provider_calls: list[str] = [] + received: dict[str, list[str]] = {} + + def provider() -> str: + label = caller.get() + provider_calls.append(label) + return first_key if label == "A" else "fake-key-B" + + async def async_provider() -> str: + await asyncio.sleep(0) + return provider() + + class PreparedClient(OpenAI): + @override + def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: + prepared = super()._prepare_options(options) + if caller.get() == "A": + a_prepared.set() + assert b_finished.wait(5), "B did not finish while A was paused" + return prepared + + class AsyncPreparedClient(AsyncOpenAI): + @override + async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: + prepared = await super()._prepare_options(options) + if caller.get() == "A": + a_prepared.set() + assert await asyncio.to_thread(b_finished.wait, 5), "B did not finish while A was paused" + return prepared + + def script(socket: ServerConnection) -> None: + assert socket.request is not None + headers = socket.request.headers + received[headers["X-Test-Caller"]] = headers.get_all("Authorization") + # Receipt before recv completes ensures B reached the actual server. + socket.send('{"type":"response.future"}') + + def http_handler(request: httpx2.Request) -> httpx2.Response: + received[request.headers["X-Test-Caller"]] = request.headers.get_list("Authorization") + return httpx2.Response(200, json={"object": "list", "data": [], "has_more": False}) + + def request_sync(client: OpenAI, label: str) -> None: + headers = {"X-Test-Caller": label} + if name == "http": + if label == "A" and not first_key: + with pytest.raises(TypeError, match="Could not resolve authentication"): + client.models.list(extra_headers=headers) + else: + client.models.list(extra_headers=headers) + else: + connect_options = options(client, name) + connect_options["extra_headers"] = headers + with resource(client, name).connect(**connect_options) as connection: + assert connection.recv().type == "response.future" + + async def request_async(client: AsyncOpenAI, label: str) -> None: + headers = {"X-Test-Caller": label} + if name == "http": + if label == "A" and not first_key: + with pytest.raises(TypeError, match="Could not resolve authentication"): + await client.models.list(extra_headers=headers) + else: + await client.models.list(extra_headers=headers) + else: + connect_options = options(client, name) + connect_options["extra_headers"] = headers + async with resource(client, name).connect(**connect_options) as connection: + assert (await connection.recv()).type == "response.future" + + async def run_requests(client: OpenAI | AsyncOpenAI) -> None: + async def call(label: str) -> None: + token = caller.set(label) + try: + if isinstance(client, AsyncOpenAI): + await request_async(client, label) + else: + await asyncio.to_thread(request_sync, client, label) + finally: + caller.reset(token) + if label == "B": + b_finished.set() + else: + # An empty HTTP credential may be rejected in super() + # before the hook reaches its pause. That is valid too. + a_prepared.set() + + first = asyncio.create_task(call("A")) + try: + assert await asyncio.to_thread(a_prepared.wait, 5), "A never prepared its request" + await asyncio.wait_for(call("B"), 8) + await asyncio.wait_for(first, 8) + finally: + b_finished.set() + if not first.done(): + await asyncio.wait_for(first, 8) + + server = nullcontext("http://127.0.0.1:1/v1") if name == "http" else script_server(script, expected_connections=2) + with server as url: + transport = httpx2.MockTransport(http_handler) + if mode == "sync": + with PreparedClient( + api_key=provider, + base_url=url, + http_client=httpx2.Client(transport=transport, trust_env=False), + ) as client: + await run_requests(client) + else: + async with AsyncPreparedClient( + api_key=async_provider, + base_url=url, + http_client=httpx2.AsyncClient(transport=transport, trust_env=False), + ) as async_client: + await run_requests(async_client) + + assert provider_calls == ["A", "B"] + if first_key: + assert received == {"A": [f"Bearer {first_key}"], "B": ["Bearer fake-key-B"]} + elif name == "http": + assert received == {"B": ["Bearer fake-key-B"]} + else: + assert received == {"A": [], "B": ["Bearer fake-key-B"]} + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("name", ["http", "responses"]) +@pytest.mark.parametrize("position", ["client", "request"]) +@pytest.mark.parametrize( + "keys", + [("authorization", "Authorization"), ("Authorization", "authorization")], + ids=["lower-then-title", "title-then-lower"], +) +async def test_callable_preserves_explicit_case_order( + mode: str, name: str, position: str, keys: tuple[str, str] +) -> None: + received: list[list[str]] = [] + # HTTP header names are case-insensitive; keep the normal last-wins order + # even when bearer authentication is supplied by a callable. + explicit = {keys[0]: "Bearer fake-first", keys[1]: "Bearer fake-last"} + defaults = explicit if position == "client" else {} + per_request = explicit if position == "request" else {} + + def script(socket: ServerConnection) -> None: + assert socket.request is not None + received.append(socket.request.headers.get_all("Authorization")) + socket.send('{"type":"response.future"}') + + def http_handler(request: httpx2.Request) -> httpx2.Response: + received.append(request.headers.get_list("Authorization")) + return httpx2.Response(200, json={"object": "list", "data": [], "has_more": False}) + + async def async_provider() -> str: + return "fake-provider" + + server = nullcontext("http://127.0.0.1:1/v1") if name == "http" else script_server(script) + with server as url: + transport = httpx2.MockTransport(http_handler) + if mode == "sync": + with OpenAI( + api_key=lambda: "fake-provider", + base_url=url, + default_headers=defaults, + http_client=httpx2.Client(transport=transport, trust_env=False), + ) as client: + if name == "http": + client.models.list(extra_headers=per_request) + else: + with client.responses.connect(extra_headers=per_request) as connection: + assert connection.recv().type == "response.future" + else: + async with AsyncOpenAI( + api_key=async_provider, + base_url=url, + default_headers=defaults, + http_client=httpx2.AsyncClient(transport=transport, trust_env=False), + ) as async_client: + if name == "http": + await async_client.models.list(extra_headers=per_request) + else: + async with async_client.responses.connect(extra_headers=per_request) as async_connection: + assert (await async_connection.recv()).type == "response.future" + + assert received == [["Bearer fake-last"]] + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +async def test_prepare_hook_can_supply_missing_callable_auth(mode: str) -> None: + received: list[list[str]] = [] + + def http_handler(request: httpx2.Request) -> httpx2.Response: + received.append(request.headers.get_list("Authorization")) + return httpx2.Response(200, json={"object": "list", "data": [], "has_more": False}) + + class PreparedClient(OpenAI): + @override + def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: + options = super()._prepare_options(options) + options.headers = {"Authorization": "Bearer fake-hook-value"} + return options + + class AsyncPreparedClient(AsyncOpenAI): + @override + async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: + options = await super()._prepare_options(options) + options.headers = {"Authorization": "Bearer fake-hook-value"} + return options + + async def async_provider() -> str: + return "" + + transport = httpx2.MockTransport(http_handler) + if mode == "sync": + with PreparedClient( + api_key=lambda: "", + base_url="http://127.0.0.1:1/v1", + http_client=httpx2.Client(transport=transport, trust_env=False), + ) as client: + client.models.list() + else: + async with AsyncPreparedClient( + api_key=async_provider, + base_url="http://127.0.0.1:1/v1", + http_client=httpx2.AsyncClient(transport=transport, trust_env=False), + ) as async_client: + await async_client.models.list() + + assert received == [["Bearer fake-hook-value"]]