Skip to content

Commit 85bb536

Browse files
feat(api): keep callable API keys scoped to each request (#4216)
## Summary Keeps each callable API-key result with its own HTTP request or WebSocket connection, including Azure API-key authentication. ## Changes - Keep overlapping requests from using another request's callable API-key result. - Preserve explicit authentication headers, request-preparation hooks, retries, and cached key visibility. - Pass the resolved Azure API key into each Realtime connection. Public signatures, header precedence, retries, and cached key visibility stay compatible. Private `_auth_headers` overrides may need updates because prepared credentials take precedence. Overrides of `_configure_realtime` must accept the new `api_key` keyword. This change targets a minor release under the README's [internal-interface policy](https://github.com/openai/openai-python#versioning). Validation includes 55 credential and runtime-binding regression tests under both Pydantic 1 and 2, plus 28 release and mixed-version wire checks. The wire checks include the generated caller with the v3.41.0 runtime. Canonical lint passes. The generated source change is limited to Realtime; no workflows change. Public CI passes: 17 checks passed and 9 were skipped. SDK-owner approval and the exact merge-conflict check are complete. All 109 required compiler targets have passing results; one TypeScript shard passed on rerun after its process was killed. Co-authored-by: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com>
1 parent bd61e84 commit 85bb536

7 files changed

Lines changed: 593 additions & 30 deletions

File tree

‎.castiron.stats.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
schema_version: 1
2-
generation_id: 7b7feee1-6807-4c20-98e0-610aab05b2da
2+
generation_id: 65a0f13e-756e-4f10-b075-12ae5d3d27a9
33
openapi_spec_hash: 51b199ef7fd5542bc4910765f9becd60
44
openapi_transformed_spec_hash: 73822d924c15a6ed58dcfe8f616bd4be
55
config_hash: b707b209c892562880c415693d111ad6
6-
codegen_sha: e1054c07fb03a8025411f0ff6ee0a5ff17e09109
7-
codegen_hash: 73c66bd2bff47ee2bbd2d04a174184dec767ebe300a3bd41443938d914013ef0
6+
codegen_sha: 52c5bafacb27ede81a3d66b62b29d946df4ed2cd
7+
codegen_hash: 536c408e94523f4af5a3ddf0c2f8f0764014bad6b9287cacdb9b8e3eeb60ba7d

‎scripts/castiron/generated-files.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,7 @@
599599
},
600600
"src/openai/resources/realtime/realtime.py": {
601601
"mode": "100644",
602-
"sha256": "d45ef5f2d1e25e6f8761592a9fb5739d7a344c6e64a4e7a4006418c16f05a540"
602+
"sha256": "9cb69e1f54c6e21d9111ddfb59a2a4de18466a1a86487fc65a0e17224b72475e"
603603
},
604604
"src/openai/resources/realtime/translations/__init__.py": {
605605
"mode": "100644",

‎src/openai/lib/_client_auth.py‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from ..auth import WorkloadIdentity, X509WorkloadIdentity
1212
from .._types import (
13+
Omit,
1314
Headers,
1415
Timeout,
1516
NotGiven,
@@ -18,7 +19,7 @@
1819
)
1920
from .._models import SecurityOptions, FinalRequestOptions
2021
from .._provider import _Provider, _ProviderRuntime
21-
from .._base_client import DEFAULT_MAX_RETRIES
22+
from .._base_client import DEFAULT_MAX_RETRIES, _merge_headers
2223
from .._data_residency import DataResidency
2324

2425
if TYPE_CHECKING:
@@ -33,14 +34,42 @@
3334
__all__ = ["_has_header", "_has_omitted_header"]
3435

3536

37+
class _MissingAPIKey(Omit):
38+
"""Suppress shared credentials without opting out of missing-auth validation."""
39+
40+
3641
def _has_header(headers: Headers, header: str) -> bool:
3742
header = header.lower()
3843
return any((key.lower() == header for key in headers))
3944

4045

4146
def _has_omitted_header(headers: Headers, header: str) -> bool:
4247
header = header.lower()
43-
return any((key.lower() == header and isinstance(value, _sdk_client.Omit) for key, value in headers.items()))
48+
return any(
49+
key.lower() == header and isinstance(value, _sdk_client.Omit) and not isinstance(value, _MissingAPIKey)
50+
for key, value in headers.items()
51+
)
52+
53+
54+
def _set_request_api_key(
55+
self: _sdk_client.OpenAI | _sdk_client.AsyncOpenAI, options: FinalRequestOptions, api_key: str
56+
) -> None:
57+
custom_headers = options.headers if not isinstance(options.headers, NotGiven) else {}
58+
# Keep explicit overrides (including Omit and differently cased duplicates) intact.
59+
if _has_header(custom_headers, "Authorization"):
60+
return
61+
62+
auth_headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
63+
if not api_key and options.security.get("admin_api_key_auth", False):
64+
auth_headers = self._admin_api_key_auth
65+
headers = _merge_headers(auth_headers, self.default_headers, custom_headers)
66+
authorization = next((value for name, value in headers.items() if name.lower() == "authorization"), None)
67+
# A later refresh may change self.api_key; this request must keep its own result.
68+
# Replace the mapping so retry inputs and caller-owned headers remain untouched.
69+
options.headers = {
70+
"Authorization": authorization if authorization is not None else _MissingAPIKey(),
71+
**custom_headers,
72+
}
4473

4574

4675
def syncbase_url(self: _sdk_client.OpenAI) -> httpx2.URL:
@@ -343,7 +372,7 @@ def sync_prepare_options(
343372
if self._provider_runtime.transform_request is not None:
344373
options = self._provider_runtime.transform_request(options)
345374
elif self._api_key_provider is not None and options.security.get("bearer_auth", False):
346-
self._refresh_api_key()
375+
_set_request_api_key(self, options, self._refresh_api_key())
347376
return super(_owner, self)._prepare_options(options)
348377

349378

@@ -364,7 +393,9 @@ def sync_custom_auth(
364393

365394
def sync_refresh_api_key(self: _sdk_client.OpenAI) -> str:
366395
if self._api_key_provider is not None:
367-
self.api_key = self._api_key_provider()
396+
api_key = self._api_key_provider()
397+
self.api_key = api_key
398+
return api_key
368399
return self.api_key
369400

370401

@@ -812,7 +843,7 @@ async def async_prepare_options(
812843
elif self._provider_runtime.transform_request is not None:
813844
options = self._provider_runtime.transform_request(options)
814845
elif self._api_key_provider is not None and options.security.get("bearer_auth", False):
815-
await self._refresh_api_key()
846+
_set_request_api_key(self, options, await self._refresh_api_key())
816847
return await super(_owner, self)._prepare_options(options)
817848

818849

@@ -835,7 +866,9 @@ def asynccustom_auth(_owner: type[_sdk_client.AsyncOpenAI], /, self: _sdk_client
835866

836867
async def async_refresh_api_key(self: _sdk_client.AsyncOpenAI) -> str:
837868
if self._api_key_provider is not None:
838-
self.api_key = await self._api_key_provider()
869+
api_key = await self._api_key_provider()
870+
self.api_key = api_key
871+
return api_key
839872
return self.api_key
840873

841874

‎src/openai/lib/azure.py‎

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
from .._streaming import Stream, AsyncStream
1919
from ..auth._x509 import is_x509_workload_identity
2020
from .._exceptions import OpenAIError
21-
from .._base_client import DEFAULT_MAX_RETRIES, BaseClient
21+
from ._client_auth import _MissingAPIKey
22+
from .._base_client import DEFAULT_MAX_RETRIES, BaseClient, _merge_headers
2223
from .._data_residency import DataResidency
2324

2425
_deployments_endpoints = set(
@@ -53,7 +54,10 @@ def _has_header(headers: Headers, header: str) -> bool:
5354

5455

5556
def _has_auth_header(headers: Headers) -> bool:
56-
return _has_header(headers, "Authorization") or _has_header(headers, "api-key")
57+
return any(
58+
name.lower() in ("authorization", "api-key") and not isinstance(value, _MissingAPIKey)
59+
for name, value in headers.items()
60+
)
5761

5862

5963
_AZURE_AUTH_ORIGIN = "openai.azure_auth_origin"
@@ -489,8 +493,7 @@ def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None:
489493

490494
@override
491495
def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
492-
if self._api_key_provider is not None:
493-
self._refresh_api_key()
496+
api_key = self._refresh_api_key() if self._api_key_provider is not None else self.api_key
494497

495498
headers: dict[str, str | Omit] = {**options.headers} if is_given(options.headers) else {}
496499

@@ -501,26 +504,43 @@ def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
501504
if azure_ad_token is not None:
502505
if not _has_header(headers, "Authorization"):
503506
headers["Authorization"] = f"Bearer {azure_ad_token}"
504-
elif self.api_key and self.api_key != API_KEY_SENTINEL:
507+
elif api_key and api_key != API_KEY_SENTINEL:
505508
if not _has_header(headers, "api-key"):
506-
headers["api-key"] = self.api_key
509+
headers["api-key"] = api_key
507510
elif _has_auth_header(headers) or _has_auth_header(self.default_headers):
508511
pass
509512
else:
510513
# should never be hit
511514
raise ValueError("Unable to handle auth")
512515

516+
if self._api_key_provider is not None and not _has_header(headers, "api-key"):
517+
# An empty callable result must not inherit another request's key.
518+
default_api_key = next(
519+
(value for name, value in _merge_headers(self.default_headers).items() if name.lower() == "api-key"),
520+
None,
521+
)
522+
headers["api-key"] = default_api_key if default_api_key is not None else _MissingAPIKey()
523+
513524
return options
514525

515-
def _configure_realtime(self, model: str, extra_query: Query) -> tuple[httpx2.URL, dict[str, str]]:
526+
def _configure_realtime_with_api_key(
527+
self, model: str, extra_query: Query, *, api_key: str
528+
) -> tuple[httpx2.URL, dict[str, str]]:
529+
return self._configure_realtime(model, extra_query, api_key=api_key)
530+
531+
def _configure_realtime(
532+
self, model: str, extra_query: Query, *, api_key: str | NotGiven = NOT_GIVEN
533+
) -> tuple[httpx2.URL, dict[str, str]]:
534+
if not is_given(api_key):
535+
api_key = self.api_key
516536
auth_headers = {}
517537
query = {
518538
**extra_query,
519539
"api-version": self._api_version,
520540
"deployment": self._azure_deployment or model,
521541
}
522-
if self.api_key and self.api_key != "<missing API key>":
523-
auth_headers = {"api-key": self.api_key}
542+
if api_key and api_key != "<missing API key>":
543+
auth_headers = {"api-key": api_key}
524544
else:
525545
token = self._get_azure_ad_token()
526546
if token:
@@ -852,8 +872,7 @@ def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None:
852872

853873
@override
854874
async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
855-
if self._api_key_provider is not None:
856-
await self._refresh_api_key()
875+
api_key = await self._refresh_api_key() if self._api_key_provider is not None else self.api_key
857876

858877
headers: dict[str, str | Omit] = {**options.headers} if is_given(options.headers) else {}
859878

@@ -864,26 +883,43 @@ async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOp
864883
if azure_ad_token is not None:
865884
if not _has_header(headers, "Authorization"):
866885
headers["Authorization"] = f"Bearer {azure_ad_token}"
867-
elif self.api_key and self.api_key != API_KEY_SENTINEL:
886+
elif api_key and api_key != API_KEY_SENTINEL:
868887
if not _has_header(headers, "api-key"):
869-
headers["api-key"] = self.api_key
888+
headers["api-key"] = api_key
870889
elif _has_auth_header(headers) or _has_auth_header(self.default_headers):
871890
pass
872891
else:
873892
# should never be hit
874893
raise ValueError("Unable to handle auth")
875894

895+
if self._api_key_provider is not None and not _has_header(headers, "api-key"):
896+
# An empty callable result must not inherit another request's key.
897+
default_api_key = next(
898+
(value for name, value in _merge_headers(self.default_headers).items() if name.lower() == "api-key"),
899+
None,
900+
)
901+
headers["api-key"] = default_api_key if default_api_key is not None else _MissingAPIKey()
902+
876903
return options
877904

878-
async def _configure_realtime(self, model: str, extra_query: Query) -> tuple[httpx2.URL, dict[str, str]]:
905+
async def _configure_realtime_with_api_key(
906+
self, model: str, extra_query: Query, *, api_key: str
907+
) -> tuple[httpx2.URL, dict[str, str]]:
908+
return await self._configure_realtime(model, extra_query, api_key=api_key)
909+
910+
async def _configure_realtime(
911+
self, model: str, extra_query: Query, *, api_key: str | NotGiven = NOT_GIVEN
912+
) -> tuple[httpx2.URL, dict[str, str]]:
913+
if not is_given(api_key):
914+
api_key = self.api_key
879915
auth_headers = {}
880916
query = {
881917
**extra_query,
882918
"api-version": self._api_version,
883919
"deployment": self._azure_deployment or model,
884920
}
885-
if self.api_key and self.api_key != "<missing API key>":
886-
auth_headers = {"api-key": self.api_key}
921+
if api_key and api_key != "<missing API key>":
922+
auth_headers = {"api-key": api_key}
887923
else:
888924
token = await self._get_azure_ad_token()
889925
if token:

‎src/openai/resources/realtime/realtime.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -668,14 +668,18 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async
668668
if self.__call_id is not omit:
669669
extra_query = {**extra_query, "call_id": self.__call_id}
670670
if is_async_azure_client(self.__client):
671-
await self.__client._refresh_api_key()
671+
api_key = await self.__client._refresh_api_key()
672672
from ...lib._azure_websocket import _AzureWebSocketConnect as connect
673673

674674
model = self.__model
675675
if not model:
676676
raise OpenAIError("`model` is required for Azure Realtime API")
677677
else:
678-
url, auth_headers = await self.__client._configure_realtime(model, extra_query)
678+
configure = getattr(self.__client, "_configure_realtime_with_api_key", None)
679+
if configure is not None:
680+
url, auth_headers = await configure(model, extra_query, api_key=api_key)
681+
else:
682+
url, auth_headers = await self.__client._configure_realtime(model, extra_query)
679683
prepared_headers: Headers = extra_headers
680684
else:
681685
url = self._prepare_url()
@@ -1123,12 +1127,16 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo
11231127
if self.__call_id is not omit:
11241128
extra_query = {**extra_query, "call_id": self.__call_id}
11251129
if is_azure_client(self.__client):
1126-
self.__client._refresh_api_key()
1130+
api_key = self.__client._refresh_api_key()
11271131
model = self.__model
11281132
if not model:
11291133
raise OpenAIError("`model` is required for Azure Realtime API")
11301134
else:
1131-
url, auth_headers = self.__client._configure_realtime(model, extra_query)
1135+
configure = getattr(self.__client, "_configure_realtime_with_api_key", None)
1136+
if configure is not None:
1137+
url, auth_headers = configure(model, extra_query, api_key=api_key)
1138+
else:
1139+
url, auth_headers = self.__client._configure_realtime(model, extra_query)
11321140
prepared_headers: Headers = extra_headers
11331141
else:
11341142
url = self._prepare_url()

0 commit comments

Comments
 (0)