From 2a898125c0baaa600fbf6cf4eaed0a08d2b27e1b Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:19:11 +0100 Subject: [PATCH 01/17] fix(voice): honor OpenAI client config for streamed STT --- src/agents/voice/models/openai_stt.py | 32 ++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index cf504d8892..ed46231774 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from typing import Any, cast +import httpx2 from openai import AsyncOpenAI from ... import _debug @@ -58,6 +59,30 @@ def _audio_buffer_to_base64(buffer: npt.NDArray[np.int16 | np.float32]) -> str: return base64.b64encode(buffer.tobytes()).decode("utf-8") +def _prepare_websocket_url(client: AsyncOpenAI) -> str: + websocket_base_url = client.websocket_base_url + base_value = websocket_base_url if websocket_base_url is not None else client.base_url + base_url = httpx2.URL(str(base_value)) + ws_scheme = {"http": "ws", "https": "wss"}.get(base_url.scheme, base_url.scheme) + params = dict(base_url.params) + params["intent"] = "transcription" + path = base_url.path.rstrip("/") + "/realtime" + return str(base_url.copy_with(scheme=ws_scheme, path=path, params=params)) + + +def _prepare_websocket_headers(client: AsyncOpenAI) -> dict[str, str]: + headers: dict[str, str] = {} + for source in (client.auth_headers, client.default_headers): + for key, value in source.items(): + header_key = str(key) + for existing_key in list(headers): + if existing_key.lower() == header_key.lower(): + del headers[existing_key] + headers[header_key] = str(value) + headers["OpenAI-Log-Session"] = "1" + return headers + + async def _wait_for_event( event_queue: asyncio.Queue[dict[str, Any] | ErrorSentinel], expected_types: list[str], @@ -304,11 +329,8 @@ async def _stream_audio( async def _process_websocket_connection(self) -> None: try: async with websockets.connect( - "wss://api.openai.com/v1/realtime?intent=transcription", - additional_headers={ - "Authorization": f"Bearer {self._client.api_key}", - "OpenAI-Log-Session": "1", - }, + _prepare_websocket_url(self._client), + additional_headers=_prepare_websocket_headers(self._client), ) as ws: await self._setup_connection(ws) self._process_events_task = asyncio.create_task(self._handle_events()) From fff641ca5bd1698c3987547f1bc3d7ca5ec0f629 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:19:34 +0100 Subject: [PATCH 02/17] test(voice): cover streamed STT client configuration --- tests/voice/test_openai_stt_client_config.py | 59 ++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/voice/test_openai_stt_client_config.py diff --git a/tests/voice/test_openai_stt_client_config.py b/tests/voice/test_openai_stt_client_config.py new file mode 100644 index 0000000000..ba8875d00e --- /dev/null +++ b/tests/voice/test_openai_stt_client_config.py @@ -0,0 +1,59 @@ +from typing import cast +from unittest.mock import MagicMock + +import httpx2 +from openai import AsyncOpenAI + +from agents.voice.models.openai_stt import ( + _prepare_websocket_headers, + _prepare_websocket_url, +) + + +def _mock_client() -> AsyncOpenAI: + return cast(AsyncOpenAI, MagicMock()) + + +def test_streaming_stt_websocket_url_uses_client_base_url() -> None: + client = _mock_client() + client.websocket_base_url = None + client.base_url = httpx2.URL("https://voice-proxy.example.test/v1/") + + url = httpx2.URL(_prepare_websocket_url(client)) + + assert url.scheme == "wss" + assert url.host == "voice-proxy.example.test" + assert url.path == "/v1/realtime" + assert url.params["intent"] == "transcription" + + +def test_streaming_stt_websocket_url_prefers_websocket_base_url() -> None: + client = _mock_client() + client.websocket_base_url = "https://voice-ws.example.test/custom/?tenant=one" + client.base_url = httpx2.URL("https://ignored.example.test/v1/") + + url = httpx2.URL(_prepare_websocket_url(client)) + + assert url.scheme == "wss" + assert url.host == "voice-ws.example.test" + assert url.path == "/custom/realtime" + assert url.params["tenant"] == "one" + assert url.params["intent"] == "transcription" + + +def test_streaming_stt_websocket_headers_use_client_configuration() -> None: + client = _mock_client() + client.auth_headers = {"Authorization": "Bearer sk-client"} + client.default_headers = { + "OpenAI-Organization": "org-client", + "OpenAI-Project": "proj-client", + "X-Proxy-Token": "proxy-token", + } + + headers = _prepare_websocket_headers(client) + + assert headers["Authorization"] == "Bearer sk-client" + assert headers["OpenAI-Organization"] == "org-client" + assert headers["OpenAI-Project"] == "proj-client" + assert headers["X-Proxy-Token"] == "proxy-token" + assert headers["OpenAI-Log-Session"] == "1" From 0d40f222a9cba945559d7cd3f93e10361de4f397 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:20:08 +0100 Subject: [PATCH 03/17] test(voice): avoid assigning client properties --- tests/voice/test_openai_stt_client_config.py | 33 +++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/tests/voice/test_openai_stt_client_config.py b/tests/voice/test_openai_stt_client_config.py index ba8875d00e..41dfde3fbe 100644 --- a/tests/voice/test_openai_stt_client_config.py +++ b/tests/voice/test_openai_stt_client_config.py @@ -10,14 +10,15 @@ ) -def _mock_client() -> AsyncOpenAI: - return cast(AsyncOpenAI, MagicMock()) +def _mock_client(**attributes: object) -> AsyncOpenAI: + return cast(AsyncOpenAI, MagicMock(**attributes)) def test_streaming_stt_websocket_url_uses_client_base_url() -> None: - client = _mock_client() - client.websocket_base_url = None - client.base_url = httpx2.URL("https://voice-proxy.example.test/v1/") + client = _mock_client( + websocket_base_url=None, + base_url=httpx2.URL("https://voice-proxy.example.test/v1/"), + ) url = httpx2.URL(_prepare_websocket_url(client)) @@ -28,9 +29,10 @@ def test_streaming_stt_websocket_url_uses_client_base_url() -> None: def test_streaming_stt_websocket_url_prefers_websocket_base_url() -> None: - client = _mock_client() - client.websocket_base_url = "https://voice-ws.example.test/custom/?tenant=one" - client.base_url = httpx2.URL("https://ignored.example.test/v1/") + client = _mock_client( + websocket_base_url="https://voice-ws.example.test/custom/?tenant=one", + base_url=httpx2.URL("https://ignored.example.test/v1/"), + ) url = httpx2.URL(_prepare_websocket_url(client)) @@ -42,13 +44,14 @@ def test_streaming_stt_websocket_url_prefers_websocket_base_url() -> None: def test_streaming_stt_websocket_headers_use_client_configuration() -> None: - client = _mock_client() - client.auth_headers = {"Authorization": "Bearer sk-client"} - client.default_headers = { - "OpenAI-Organization": "org-client", - "OpenAI-Project": "proj-client", - "X-Proxy-Token": "proxy-token", - } + client = _mock_client( + auth_headers={"Authorization": "Bearer sk-client"}, + default_headers={ + "OpenAI-Organization": "org-client", + "OpenAI-Project": "proj-client", + "X-Proxy-Token": "proxy-token", + }, + ) headers = _prepare_websocket_headers(client) From 9a18176ebff3a528c0d2c9d8ae1a879ef8d2f7f1 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:43:40 +0100 Subject: [PATCH 04/17] test(voice): cover client query and omitted headers --- tests/voice/test_openai_stt_client_config.py | 42 +++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/voice/test_openai_stt_client_config.py b/tests/voice/test_openai_stt_client_config.py index 41dfde3fbe..22845bf725 100644 --- a/tests/voice/test_openai_stt_client_config.py +++ b/tests/voice/test_openai_stt_client_config.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock import httpx2 -from openai import AsyncOpenAI +from openai import NOT_GIVEN, AsyncOpenAI, omit from agents.voice.models.openai_stt import ( _prepare_websocket_headers, @@ -11,6 +11,7 @@ def _mock_client(**attributes: object) -> AsyncOpenAI: + attributes.setdefault("default_query", {}) return cast(AsyncOpenAI, MagicMock(**attributes)) @@ -43,6 +44,26 @@ def test_streaming_stt_websocket_url_prefers_websocket_base_url() -> None: assert url.params["intent"] == "transcription" +def test_streaming_stt_websocket_url_merges_client_default_query() -> None: + client = _mock_client( + websocket_base_url="wss://voice-ws.example.test/custom/?tenant=one&remove=base", + base_url=httpx2.URL("https://ignored.example.test/v1/"), + default_query={ + "api-version": "2026-08-01-preview", + "remove": omit, + "skip": NOT_GIVEN, + }, + ) + + url = httpx2.URL(_prepare_websocket_url(client)) + + assert url.params["tenant"] == "one" + assert url.params["api-version"] == "2026-08-01-preview" + assert url.params["intent"] == "transcription" + assert "remove" not in url.params + assert "skip" not in url.params + + def test_streaming_stt_websocket_headers_use_client_configuration() -> None: client = _mock_client( auth_headers={"Authorization": "Bearer sk-client"}, @@ -60,3 +81,22 @@ def test_streaming_stt_websocket_headers_use_client_configuration() -> None: assert headers["OpenAI-Project"] == "proj-client" assert headers["X-Proxy-Token"] == "proxy-token" assert headers["OpenAI-Log-Session"] == "1" + + +def test_streaming_stt_websocket_headers_skip_openai_omission_sentinels() -> None: + client = _mock_client( + auth_headers={"Authorization": "Bearer sk-client"}, + default_headers={ + "OpenAI-Organization": omit, + "OpenAI-Project": NOT_GIVEN, + "X-Proxy-Token": "proxy-token", + }, + ) + + headers = _prepare_websocket_headers(client) + + assert headers["Authorization"] == "Bearer sk-client" + assert headers["X-Proxy-Token"] == "proxy-token" + assert "OpenAI-Organization" not in headers + assert "OpenAI-Project" not in headers + assert headers["OpenAI-Log-Session"] == "1" From 8b6b1a35db47567180cd5a4e6f3f328d9199b46e Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:44:22 +0100 Subject: [PATCH 05/17] fix(voice): honor client query and omission sentinels --- src/agents/voice/models/openai_stt.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index ed46231774..7e22d91bc3 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -9,7 +9,7 @@ from typing import Any, cast import httpx2 -from openai import AsyncOpenAI +from openai import AsyncOpenAI, NotGiven, Omit from ... import _debug from ...exceptions import AgentsException, UserError @@ -59,12 +59,26 @@ def _audio_buffer_to_base64(buffer: npt.NDArray[np.int16 | np.float32]) -> str: return base64.b64encode(buffer.tobytes()).decode("utf-8") +def _is_openai_omitted_value(value: Any) -> bool: + return isinstance(value, Omit | NotGiven) + + def _prepare_websocket_url(client: AsyncOpenAI) -> str: websocket_base_url = client.websocket_base_url base_value = websocket_base_url if websocket_base_url is not None else client.base_url base_url = httpx2.URL(str(base_value)) ws_scheme = {"http": "ws", "https": "wss"}.get(base_url.scheme, base_url.scheme) - params = dict(base_url.params) + params: dict[str, Any] = dict(base_url.params) + default_query = client.default_query + if default_query is not None and not _is_openai_omitted_value(default_query): + for key, value in default_query.items(): + query_key = str(key) + if isinstance(value, Omit): + params.pop(query_key, None) + continue + if isinstance(value, NotGiven): + continue + params[query_key] = value params["intent"] = "transcription" path = base_url.path.rstrip("/") + "/realtime" return str(base_url.copy_with(scheme=ws_scheme, path=path, params=params)) @@ -74,6 +88,8 @@ def _prepare_websocket_headers(client: AsyncOpenAI) -> dict[str, str]: headers: dict[str, str] = {} for source in (client.auth_headers, client.default_headers): for key, value in source.items(): + if _is_openai_omitted_value(value): + continue header_key = str(key) for existing_key in list(headers): if existing_key.lower() == header_key.lower(): From a0b7ac03819b05db584423918269c92e2f00aa3d Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:29:53 +0100 Subject: [PATCH 06/17] fix(voice): refresh client auth before streamed STT handshake --- src/agents/voice/models/openai_stt.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index 7e22d91bc3..cb14d46d21 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -14,6 +14,7 @@ from ... import _debug from ...exceptions import AgentsException, UserError from ...logger import logger +from ...models.openai_responses import _refresh_openai_client_api_key_if_supported from ...tracing import Span, SpanError, TranscriptionSpanData, transcription_span from ...util._error_tracing import get_trace_error from ..exceptions import STTWebsocketConnectionError @@ -344,6 +345,7 @@ async def _stream_audio( async def _process_websocket_connection(self) -> None: try: + await _refresh_openai_client_api_key_if_supported(self._client) async with websockets.connect( _prepare_websocket_url(self._client), additional_headers=_prepare_websocket_headers(self._client), @@ -591,4 +593,4 @@ async def create_session( settings, trace_include_sensitive_data, trace_include_sensitive_audio_data, - ) + ) \ No newline at end of file From 86d59ef547daa8090debcd883eecf50f81445447 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:30:08 +0100 Subject: [PATCH 07/17] test(voice): cover streamed STT API key refresh --- .../voice/test_openai_stt_api_key_refresh.py | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/voice/test_openai_stt_api_key_refresh.py diff --git a/tests/voice/test_openai_stt_api_key_refresh.py b/tests/voice/test_openai_stt_api_key_refresh.py new file mode 100644 index 0000000000..ae159a8d6d --- /dev/null +++ b/tests/voice/test_openai_stt_api_key_refresh.py @@ -0,0 +1,66 @@ +from typing import Any, cast +from unittest.mock import AsyncMock + +import httpx2 +import pytest +from openai import AsyncOpenAI + +from agents.voice import STTModelSettings, StreamedAudioInput +from agents.voice.models import openai_stt +from agents.voice.models.openai_stt import OpenAISTTTranscriptionSession + + +class _RotatingClient: + def __init__(self) -> None: + self.api_key = "" + self.refresh_calls = 0 + self.websocket_base_url = None + self.base_url = httpx2.URL("https://api.openai.com/v1/") + self.default_query: dict[str, str] = {} + self.auth_headers = {"Authorization": "Bearer stale"} + self.default_headers: dict[str, str] = {} + + async def _refresh_api_key(self) -> None: + self.refresh_calls += 1 + self.api_key = "sk-refreshed" + self.auth_headers = {"Authorization": f"Bearer {self.api_key}"} + + +class _WebSocketContext: + async def __aenter__(self) -> Any: + return object() + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: + return False + + +@pytest.mark.asyncio +async def test_streamed_stt_refreshes_callable_api_key_before_handshake(monkeypatch) -> None: + client = _RotatingClient() + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=cast(AsyncOpenAI, client), + model="gpt-4o-mini-transcribe", + settings=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + captured_headers: dict[str, str] = {} + + def connect(_url: str, *, additional_headers: dict[str, str]) -> _WebSocketContext: + captured_headers.update(additional_headers) + return _WebSocketContext() + + monkeypatch.setattr(openai_stt.websockets, "connect", connect) + monkeypatch.setattr( + session, + "_setup_connection", + AsyncMock(side_effect=RuntimeError("stop after handshake")), + ) + + with pytest.raises(RuntimeError, match="stop after handshake"): + await session._process_websocket_connection() + + assert client.refresh_calls == 1 + assert captured_headers["Authorization"] == "Bearer sk-refreshed" From 941ebc9511e6af536150fd3ac78c1ec59ce38947 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:31:49 +0100 Subject: [PATCH 08/17] test(voice): give streamed STT client doubles routing metadata --- tests/voice/test_openai_stt.py | 47 ++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index 50daf0c2ba..caea1860aa 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -9,9 +9,11 @@ from typing import cast from unittest.mock import AsyncMock, MagicMock, patch +import httpx2 import numpy as np import numpy.typing as npt import pytest +from openai import AsyncOpenAI import agents._debug as _debug from agents import trace @@ -55,6 +57,17 @@ def create_mock_websocket(messages: list[str]) -> AsyncMock: return mock_ws +def create_mock_openai_client(api_key: str = "FAKE_KEY") -> AsyncOpenAI: + client = AsyncMock(api_key=api_key) + client.websocket_base_url = None + client.base_url = httpx2.URL("https://api.openai.com/v1/") + client.default_query = {} + client.auth_headers = {"Authorization": f"Bearer {api_key}"} + client.default_headers = {} + client._refresh_api_key = AsyncMock() + return cast(AsyncOpenAI, client) + + def fake_time(increment: int): current = 1000 while True: @@ -67,7 +80,7 @@ def fake_time(increment: int): async def test_transcribe_turns_propagates_consumer_cancellation(monkeypatch) -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -105,7 +118,7 @@ async def hold_connection_open() -> None: async def test_transcribe_turns_closes_owned_tasks_after_yield(monkeypatch) -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -165,7 +178,7 @@ async def hold_connection_open() -> None: async def test_close_finishes_span_started_while_websocket_close_is_pending() -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -223,7 +236,7 @@ async def test_transcribe_turns_preserves_consumer_exception_when_cleanup_fails( ) -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -270,7 +283,7 @@ async def fail_cleanup() -> None: async def test_transcribe_turns_propagates_cancellation_during_cleanup(monkeypatch) -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -307,7 +320,7 @@ async def test_transcribe_turns_preserves_terminal_error_when_close_fails( ) -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -372,7 +385,7 @@ async def test_non_json_messages_should_crash(): session = OpenAISTTTranscriptionSession( input=input_audio, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -412,7 +425,7 @@ async def test_session_connects_and_configures_successfully(): session = OpenAISTTTranscriptionSession( input=input_audio, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -472,7 +485,7 @@ async def test_stream_audio_sends_pcm16( session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -548,7 +561,7 @@ async def test_transcription_event_puts_output_in_queue(created, updated, comple session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -594,7 +607,7 @@ def fake_time_func(): session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -643,7 +656,7 @@ async def test_session_error_event(monkeypatch: pytest.MonkeyPatch): session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -679,7 +692,7 @@ async def test_session_error_event_before_session_created(): audio_input = await StreamedAudioInputFactory.get(count=2) session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -722,7 +735,7 @@ async def messages_then_timeout() -> AsyncGenerator[str, None]: audio_input = await StreamedAudioInputFactory.get(count=2) session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -778,7 +791,7 @@ async def test_inactivity_timeout(): session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=stt_settings, trace_include_sensitive_data=False, @@ -804,7 +817,7 @@ async def test_stream_audio_buffers_turn_audio_only_for_audio_tracing( ) -> None: session = OpenAISTTTranscriptionSession( input=StreamedAudioInput(), - client=AsyncMock(api_key="FAKE_KEY"), + client=create_mock_openai_client(), model="whisper-1", settings=STTModelSettings(), trace_include_sensitive_data=False, @@ -837,4 +850,4 @@ async def test_stream_audio_buffers_turn_audio_only_for_audio_tracing( for buffered, frame in zip(session._turn_audio_buffer, frames, strict=True) ) else: - assert session._turn_audio_buffer == [] + assert session._turn_audio_buffer == [] \ No newline at end of file From d7c5eae8c71b7cddffa61bd48afd321d97f08e6b Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:35:15 +0100 Subject: [PATCH 09/17] refactor(models): share OpenAI websocket client normalization --- src/agents/models/_openai_websocket.py | 110 +++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/agents/models/_openai_websocket.py diff --git a/src/agents/models/_openai_websocket.py b/src/agents/models/_openai_websocket.py new file mode 100644 index 0000000000..eed54b8d14 --- /dev/null +++ b/src/agents/models/_openai_websocket.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import httpx2 +from openai import AsyncOpenAI, NotGiven, Omit + +from .._httpx_compat import is_legacy_httpx_instance +from ..exceptions import UserError + + +def _is_openai_omitted_value(value: Any) -> bool: + return isinstance(value, Omit | NotGiven) + + +async def refresh_openai_client_api_key_if_supported(client: Any) -> None: + """Refresh dynamic OpenAI client credentials before materializing handshake headers.""" + refresh_api_key = getattr(client, "_refresh_api_key", None) + if callable(refresh_api_key): + await refresh_api_key() + + +def _set_header(headers: dict[str, str], key: object, value: object) -> None: + header_key = str(key) + for existing_key in list(headers): + if existing_key.lower() == header_key.lower(): + del headers[existing_key] + headers[header_key] = str(value) + + +def merge_openai_client_websocket_headers( + client: AsyncOpenAI, + *, + extra_headers: Mapping[str, Any] | None = None, +) -> dict[str, str]: + """Materialize OpenAI client auth/default headers for a WebSocket handshake.""" + headers: dict[str, str] = {} + for source in ( + getattr(client, "auth_headers", {}), + getattr(client, "default_headers", {}), + ): + for key, value in source.items(): + if _is_openai_omitted_value(value): + continue + _set_header(headers, key, value) + + for key, value in (extra_headers or {}).items(): + if isinstance(value, NotGiven): + continue + header_key = str(key) + for existing_key in list(headers): + if existing_key.lower() == header_key.lower(): + del headers[existing_key] + if isinstance(value, Omit): + continue + headers[header_key] = str(value) + + return headers + + +def _merge_query_values(params: dict[str, Any], values: Mapping[str, Any]) -> None: + for key, value in values.items(): + query_key = str(key) + if isinstance(value, Omit): + params.pop(query_key, None) + continue + if isinstance(value, NotGiven): + continue + params[query_key] = value + + +def prepare_openai_client_websocket_base_url( + client: AsyncOpenAI, + *, + extra_query: Any = None, + context: str, +) -> httpx2.URL: + """Build the client-derived WebSocket base URL and normalized query parameters. + + Endpoint suffixes and transport-specific fixed query parameters are intentionally left to + each caller. + """ + websocket_base_url = getattr(client, "websocket_base_url", None) + if websocket_base_url is not None: + if is_legacy_httpx_instance(websocket_base_url, "URL"): + websocket_base_url = str(websocket_base_url) + base_url = httpx2.URL(websocket_base_url) + else: + client_base_url = client.base_url + if is_legacy_httpx_instance(client_base_url, "URL"): + client_base_url = str(client_base_url) + base_url = httpx2.URL(client_base_url) + + ws_scheme = {"http": "ws", "https": "wss"}.get(base_url.scheme, base_url.scheme) + base_url = base_url.copy_with(scheme=ws_scheme) + params: dict[str, Any] = dict(base_url.params) + + default_query = getattr(client, "default_query", None) + if default_query is not None and not _is_openai_omitted_value(default_query): + if not isinstance(default_query, Mapping): + raise UserError(f"{context} client default_query must be a mapping.") + _merge_query_values(params, default_query) + + if extra_query is not None and not _is_openai_omitted_value(extra_query): + if not isinstance(extra_query, Mapping): + raise UserError(f"{context} extra_query must be a mapping.") + _merge_query_values(params, extra_query) + + return base_url.copy_with(params=params) From bcc8d5706620bb1684d659a4822c475764d13a4f Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:36:08 +0100 Subject: [PATCH 10/17] refactor(voice): use shared OpenAI websocket normalization --- src/agents/voice/models/openai_stt.py | 46 ++++++++------------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index cb14d46d21..5d092e0637 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -8,13 +8,16 @@ from dataclasses import dataclass from typing import Any, cast -import httpx2 -from openai import AsyncOpenAI, NotGiven, Omit +from openai import AsyncOpenAI from ... import _debug from ...exceptions import AgentsException, UserError from ...logger import logger -from ...models.openai_responses import _refresh_openai_client_api_key_if_supported +from ...models._openai_websocket import ( + merge_openai_client_websocket_headers, + prepare_openai_client_websocket_base_url, + refresh_openai_client_api_key_if_supported, +) from ...tracing import Span, SpanError, TranscriptionSpanData, transcription_span from ...util._error_tracing import get_trace_error from ..exceptions import STTWebsocketConnectionError @@ -60,42 +63,19 @@ def _audio_buffer_to_base64(buffer: npt.NDArray[np.int16 | np.float32]) -> str: return base64.b64encode(buffer.tobytes()).decode("utf-8") -def _is_openai_omitted_value(value: Any) -> bool: - return isinstance(value, Omit | NotGiven) - - def _prepare_websocket_url(client: AsyncOpenAI) -> str: - websocket_base_url = client.websocket_base_url - base_value = websocket_base_url if websocket_base_url is not None else client.base_url - base_url = httpx2.URL(str(base_value)) - ws_scheme = {"http": "ws", "https": "wss"}.get(base_url.scheme, base_url.scheme) + base_url = prepare_openai_client_websocket_base_url( + client, + context="Streamed STT websocket", + ) params: dict[str, Any] = dict(base_url.params) - default_query = client.default_query - if default_query is not None and not _is_openai_omitted_value(default_query): - for key, value in default_query.items(): - query_key = str(key) - if isinstance(value, Omit): - params.pop(query_key, None) - continue - if isinstance(value, NotGiven): - continue - params[query_key] = value params["intent"] = "transcription" path = base_url.path.rstrip("/") + "/realtime" - return str(base_url.copy_with(scheme=ws_scheme, path=path, params=params)) + return str(base_url.copy_with(path=path, params=params)) def _prepare_websocket_headers(client: AsyncOpenAI) -> dict[str, str]: - headers: dict[str, str] = {} - for source in (client.auth_headers, client.default_headers): - for key, value in source.items(): - if _is_openai_omitted_value(value): - continue - header_key = str(key) - for existing_key in list(headers): - if existing_key.lower() == header_key.lower(): - del headers[existing_key] - headers[header_key] = str(value) + headers = merge_openai_client_websocket_headers(client) headers["OpenAI-Log-Session"] = "1" return headers @@ -345,7 +325,7 @@ async def _stream_audio( async def _process_websocket_connection(self) -> None: try: - await _refresh_openai_client_api_key_if_supported(self._client) + await refresh_openai_client_api_key_if_supported(self._client) async with websockets.connect( _prepare_websocket_url(self._client), additional_headers=_prepare_websocket_headers(self._client), From f17573906fb36d2c53a163edc922917d9ca43fab Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:42:27 +0100 Subject: [PATCH 11/17] refactor(models): use shared OpenAI websocket normalization --- src/agents/models/openai_responses.py | 88 ++++++--------------------- 1 file changed, 17 insertions(+), 71 deletions(-) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index f6ff8e2b3c..d7244e324e 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -91,6 +91,11 @@ from ..util._json import _to_dump_compatible from ..version import __version__ from ._openai_retry import get_openai_retry_advice +from ._openai_websocket import ( + merge_openai_client_websocket_headers, + prepare_openai_client_websocket_base_url, + refresh_openai_client_api_key_if_supported, +) from ._response_terminal import response_error_event_failure_error, response_terminal_failure_error from ._retry_runtime import ( should_disable_provider_managed_retries, @@ -177,10 +182,8 @@ def _materialize_responses_tool_params( async def _refresh_openai_client_api_key_if_supported(client: Any) -> None: - """Refresh client auth if the current OpenAI SDK exposes a refresh hook.""" - refresh_api_key = getattr(client, "_refresh_api_key", None) - if callable(refresh_api_key): - await refresh_api_key() + """Backward-compatible wrapper around shared WebSocket client credential refresh.""" + await refresh_openai_client_api_key_if_supported(client) def _construct_response_stream_event_from_payload( @@ -1533,76 +1536,19 @@ async def _prepare_websocket_request( return frame, ws_url, handshake_headers def _merge_websocket_headers(self, extra_headers: Mapping[str, Any]) -> dict[str, str]: - headers: dict[str, str] = {} - for source in ( - getattr(self._client, "auth_headers", {}), - self._client.default_headers, - ): - for key, value in source.items(): - if _is_openai_omitted_value(value): - continue - header_key = str(key) - for existing_key in list(headers): - if existing_key.lower() == header_key.lower(): - del headers[existing_key] - headers[header_key] = str(value) - - for key, value in extra_headers.items(): - if isinstance(value, NotGiven): - continue - header_key = str(key) - for existing_key in list(headers): - if existing_key.lower() == header_key.lower(): - del headers[existing_key] - if isinstance(value, Omit): - continue - headers[header_key] = str(value) - - return headers + return merge_openai_client_websocket_headers( + self._client, + extra_headers=extra_headers, + ) def _prepare_websocket_url(self, extra_query: Any) -> str: - if self._client.websocket_base_url is not None: - websocket_base_url = self._client.websocket_base_url - if is_legacy_httpx_instance(websocket_base_url, "URL"): - websocket_base_url = str(websocket_base_url) - base_url = httpx2.URL(websocket_base_url) - ws_scheme = {"http": "ws", "https": "wss"}.get(base_url.scheme, base_url.scheme) - base_url = base_url.copy_with(scheme=ws_scheme) - else: - client_base_url = self._client.base_url - ws_scheme = {"http": "ws", "https": "wss"}.get( - client_base_url.scheme, client_base_url.scheme - ) - base_url = client_base_url.copy_with(scheme=ws_scheme) - - params: dict[str, Any] = dict(base_url.params) - default_query = getattr(self._client, "default_query", None) - if default_query is not None and not _is_openai_omitted_value(default_query): - if not isinstance(default_query, Mapping): - raise UserError("Responses websocket client default_query must be a mapping.") - for key, value in default_query.items(): - query_key = str(key) - if isinstance(value, Omit): - params.pop(query_key, None) - continue - if isinstance(value, NotGiven): - continue - params[query_key] = value - - if extra_query is not None and not _is_openai_omitted_value(extra_query): - if not isinstance(extra_query, Mapping): - raise UserError("Responses websocket extra_query must be a mapping.") - for key, value in extra_query.items(): - query_key = str(key) - if isinstance(value, Omit): - params.pop(query_key, None) - continue - if isinstance(value, NotGiven): - continue - params[query_key] = value - + base_url = prepare_openai_client_websocket_base_url( + self._client, + extra_query=extra_query, + context="Responses websocket", + ) path = base_url.path.rstrip("/") + "/responses" - return str(base_url.copy_with(path=path, params=params)) + return str(base_url.copy_with(path=path)) async def _ensure_websocket_connection( self, From 832db7ed181b1aae3ea4bf53e6eb8436a0c6247a Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:44:59 +0100 Subject: [PATCH 12/17] chore(voice): restore final newline --- src/agents/voice/models/openai_stt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index 5d092e0637..c599650a64 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -573,4 +573,4 @@ async def create_session( settings, trace_include_sensitive_data, trace_include_sensitive_audio_data, - ) \ No newline at end of file + ) From 101108b5028bf12626c3a45da8aedf20633d8c51 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:46:17 +0100 Subject: [PATCH 13/17] chore(voice): restore test file newline --- tests/voice/test_openai_stt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index caea1860aa..a64c7079f5 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -850,4 +850,4 @@ async def test_stream_audio_buffers_turn_audio_only_for_audio_tracing( for buffered, frame in zip(session._turn_audio_buffer, frames, strict=True) ) else: - assert session._turn_audio_buffer == [] \ No newline at end of file + assert session._turn_audio_buffer == [] From 5d163b503794170133ad07e8cfc845c21c1affe9 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:54:11 +0100 Subject: [PATCH 14/17] fix(models): honor omitted inherited websocket headers --- src/agents/models/_openai_websocket.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/agents/models/_openai_websocket.py b/src/agents/models/_openai_websocket.py index eed54b8d14..1687bfc253 100644 --- a/src/agents/models/_openai_websocket.py +++ b/src/agents/models/_openai_websocket.py @@ -21,11 +21,16 @@ async def refresh_openai_client_api_key_if_supported(client: Any) -> None: await refresh_api_key() -def _set_header(headers: dict[str, str], key: object, value: object) -> None: +def _remove_header(headers: dict[str, str], key: object) -> None: header_key = str(key) for existing_key in list(headers): if existing_key.lower() == header_key.lower(): del headers[existing_key] + + +def _set_header(headers: dict[str, str], key: object, value: object) -> None: + header_key = str(key) + _remove_header(headers, header_key) headers[header_key] = str(value) @@ -41,20 +46,20 @@ def merge_openai_client_websocket_headers( getattr(client, "default_headers", {}), ): for key, value in source.items(): - if _is_openai_omitted_value(value): + if isinstance(value, NotGiven): + continue + if isinstance(value, Omit): + _remove_header(headers, key) continue _set_header(headers, key, value) for key, value in (extra_headers or {}).items(): if isinstance(value, NotGiven): continue - header_key = str(key) - for existing_key in list(headers): - if existing_key.lower() == header_key.lower(): - del headers[existing_key] + _remove_header(headers, key) if isinstance(value, Omit): continue - headers[header_key] = str(value) + headers[str(key)] = str(value) return headers From e51f40cb251dc50f63b74a9c054ac69b3ef1ea27 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:54:21 +0100 Subject: [PATCH 15/17] test(voice): cover omitted inherited websocket header --- tests/voice/test_openai_stt_client_config.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/voice/test_openai_stt_client_config.py b/tests/voice/test_openai_stt_client_config.py index 22845bf725..8c9eb50e16 100644 --- a/tests/voice/test_openai_stt_client_config.py +++ b/tests/voice/test_openai_stt_client_config.py @@ -100,3 +100,15 @@ def test_streaming_stt_websocket_headers_skip_openai_omission_sentinels() -> Non assert "OpenAI-Organization" not in headers assert "OpenAI-Project" not in headers assert headers["OpenAI-Log-Session"] == "1" + + +def test_streaming_stt_websocket_headers_omit_removes_inherited_header() -> None: + client = _mock_client( + auth_headers={"Authorization": "Bearer sk-client"}, + default_headers={"authorization": omit}, + ) + + headers = _prepare_websocket_headers(client) + + assert all(key.lower() != "authorization" for key in headers) + assert headers["OpenAI-Log-Session"] == "1" From 1743799e5ba6176d7149cc167226501583f1ca0c Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:58:38 +0100 Subject: [PATCH 16/17] fix(voice): replace fixed session header case-insensitively --- src/agents/voice/models/openai_stt.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index c599650a64..ff47496399 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -75,9 +75,10 @@ def _prepare_websocket_url(client: AsyncOpenAI) -> str: def _prepare_websocket_headers(client: AsyncOpenAI) -> dict[str, str]: - headers = merge_openai_client_websocket_headers(client) - headers["OpenAI-Log-Session"] = "1" - return headers + return merge_openai_client_websocket_headers( + client, + extra_headers={"OpenAI-Log-Session": "1"}, + ) async def _wait_for_event( From 40a10d46d20daefc184ba5da481182e557ad7694 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:58:55 +0100 Subject: [PATCH 17/17] test(voice): cover case-insensitive session header override --- tests/voice/test_openai_stt_client_config.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/voice/test_openai_stt_client_config.py b/tests/voice/test_openai_stt_client_config.py index 8c9eb50e16..6813444f3f 100644 --- a/tests/voice/test_openai_stt_client_config.py +++ b/tests/voice/test_openai_stt_client_config.py @@ -112,3 +112,17 @@ def test_streaming_stt_websocket_headers_omit_removes_inherited_header() -> None assert all(key.lower() != "authorization" for key in headers) assert headers["OpenAI-Log-Session"] == "1" + + +def test_streaming_stt_websocket_fixed_session_header_replaces_client_casing() -> None: + client = _mock_client( + auth_headers={}, + default_headers={"openai-log-session": "0"}, + ) + + headers = _prepare_websocket_headers(client) + + session_headers = { + key: value for key, value in headers.items() if key.lower() == "openai-log-session" + } + assert session_headers == {"OpenAI-Log-Session": "1"}