Skip to content

Commit 9040bf9

Browse files
committed
fix: #1880 fix a bug where OpenAI Realtime API tracing does not work for realtime agents
1 parent 368734f commit 9040bf9

File tree

2 files changed

+160
-6
lines changed

2 files changed

+160
-6
lines changed

src/agents/realtime/openai_realtime.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import pydantic
1313
import websockets
14+
from openai import AsyncOpenAI
1415
from openai.types.realtime import realtime_audio_config as _rt_audio_config
1516
from openai.types.realtime.conversation_item import (
1617
ConversationItem,
@@ -81,6 +82,7 @@
8182
from pydantic import Field, TypeAdapter
8283
from typing_extensions import assert_never
8384
from websockets.asyncio.client import ClientConnection
85+
from websockets.typing import Subprotocol
8486

8587
from agents.handoffs import Handoff
8688
from agents.prompts import Prompt
@@ -138,6 +140,7 @@
138140

139141

140142
_USER_AGENT = f"Agents/Python {__version__}"
143+
_SDK_CLIENT_META = f"openai-agents-sdk.python.{__version__}"
141144

142145
DEFAULT_MODEL_SETTINGS: RealtimeSessionModelSettings = {
143146
"voice": "ash",
@@ -210,7 +213,6 @@ async def connect(self, options: RealtimeModelConfig) -> None:
210213

211214
self.model = model_settings.get("model_name", self.model)
212215
api_key = await get_api_key(options.get("api_key"))
213-
214216
if "tracing" in model_settings:
215217
self._tracing_config = model_settings["tracing"]
216218
else:
@@ -219,24 +221,71 @@ async def connect(self, options: RealtimeModelConfig) -> None:
219221
url = options.get("url", f"wss://api.openai.com/v1/realtime?model={self.model}")
220222

221223
headers: dict[str, str] = {}
222-
if options.get("headers") is not None:
224+
subprotocols: list[Subprotocol] = [
225+
Subprotocol("realtime"),
226+
Subprotocol(_SDK_CLIENT_META),
227+
]
228+
229+
custom_headers = options.get("headers")
230+
if custom_headers is not None:
223231
# For customizing request headers
224-
headers.update(options["headers"])
232+
headers.update(custom_headers)
225233
else:
226234
# OpenAI's Realtime API
227235
if not api_key:
228236
raise UserError("API key is required but was not provided.")
229237

230-
headers.update({"Authorization": f"Bearer {api_key}"})
238+
ephemeral_key: str | None
239+
if api_key.startswith("ek_"):
240+
ephemeral_key = api_key
241+
else:
242+
ephemeral_key = await self._maybe_create_client_secret(api_key, self.model)
243+
244+
if ephemeral_key:
245+
subprotocols = [
246+
Subprotocol("realtime"),
247+
Subprotocol(f"openai-insecure-api-key.{ephemeral_key}"),
248+
Subprotocol(_SDK_CLIENT_META),
249+
]
250+
else:
251+
headers["Authorization"] = f"Bearer {api_key}"
252+
231253
self._websocket = await websockets.connect(
232254
url,
233255
user_agent_header=_USER_AGENT,
234256
additional_headers=headers,
257+
subprotocols=tuple(subprotocols),
235258
max_size=None, # Allow any size of message
236259
)
237260
self._websocket_task = asyncio.create_task(self._listen_for_messages())
238261
await self._update_session_config(model_settings)
239262

263+
async def _maybe_create_client_secret(self, api_key: str, model_name: str) -> str | None:
264+
try:
265+
return await self._create_client_secret(api_key, model_name)
266+
except Exception as exc:
267+
logger.warning(
268+
"Failed to create realtime client secret; using API key directly: %s",
269+
exc,
270+
)
271+
return None
272+
273+
async def _create_client_secret(self, api_key: str, model_name: str) -> str:
274+
client = AsyncOpenAI(api_key=api_key)
275+
try:
276+
secret = await client.realtime.client_secrets.create(
277+
session={"type": "realtime", "model": model_name}
278+
)
279+
finally:
280+
await client.close()
281+
282+
value = secret.value if isinstance(getattr(secret, "value", None), str) else None
283+
284+
if value is None:
285+
raise UserError("Realtime client secret response did not include a value.")
286+
287+
return value
288+
240289
async def _send_tracing_config(
241290
self, tracing_config: RealtimeModelTracingConfig | Literal["auto"] | None
242291
) -> None:

tests/realtime/test_tracing.py

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import cast
1+
from types import SimpleNamespace
2+
from typing import Any, cast
23
from unittest.mock import AsyncMock, Mock, patch
34

45
import pytest
@@ -8,11 +9,40 @@
89
from openai.types.realtime.realtime_tracing_config import TracingConfiguration
910

1011
from agents.realtime.agent import RealtimeAgent
11-
from agents.realtime.model import RealtimeModel
12+
from agents.realtime.model import RealtimeModel, RealtimeModelConfig
1213
from agents.realtime.openai_realtime import OpenAIRealtimeWebSocketModel
1314
from agents.realtime.session import RealtimeSession
1415

1516

17+
@pytest.fixture(autouse=True)
18+
def mock_client_secret_request(monkeypatch):
19+
records: dict[str, list[dict[str, Any]]] = {"init_kwargs": [], "sessions": []}
20+
21+
class DummySecrets:
22+
async def create(self, *, session: dict[str, Any]) -> SimpleNamespace:
23+
records["sessions"].append(session)
24+
return SimpleNamespace(value="ek_test")
25+
26+
class DummyRealtime:
27+
def __init__(self):
28+
self.client_secrets = DummySecrets()
29+
30+
class DummyClient:
31+
def __init__(self, *args, **kwargs):
32+
records["init_kwargs"].append(kwargs)
33+
self.realtime = DummyRealtime()
34+
35+
async def close(self) -> None:
36+
return None
37+
38+
monkeypatch.setattr(
39+
"agents.realtime.openai_realtime.AsyncOpenAI",
40+
DummyClient,
41+
)
42+
43+
return records
44+
45+
1646
class TestRealtimeTracingIntegration:
1747
"""Test tracing configuration and session.update integration."""
1848

@@ -62,6 +92,7 @@ async def async_websocket(*args, **kwargs):
6292
"metadata": {"version": "1.0"},
6393
}
6494

95+
6596
# Test without tracing config - should default to "auto"
6697
model2 = OpenAIRealtimeWebSocketModel()
6798
config_no_tracing = {
@@ -251,3 +282,77 @@ async def test_tracing_disabled_prevents_tracing(self, mock_websocket):
251282

252283
# When tracing is disabled, model settings should have tracing=None
253284
assert model_settings["tracing"] is None
285+
286+
@pytest.mark.asyncio
287+
async def test_connect_sets_sdk_headers_and_subprotocols(
288+
self,
289+
mock_websocket,
290+
mock_client_secret_request,
291+
):
292+
"""Ensure websocket handshake mirrors Agents JS with client secrets."""
293+
model = OpenAIRealtimeWebSocketModel()
294+
config: RealtimeModelConfig = {
295+
"api_key": "sk-test",
296+
"initial_model_settings": {},
297+
}
298+
299+
captured_kwargs: dict[str, Any] = {}
300+
301+
async def async_websocket(*args, **kwargs):
302+
captured_kwargs.update(kwargs)
303+
return mock_websocket
304+
305+
with patch("websockets.connect", side_effect=async_websocket):
306+
with patch("asyncio.create_task") as mock_create_task:
307+
mock_task = AsyncMock()
308+
mock_create_task.return_value = mock_task
309+
mock_create_task.side_effect = lambda coro: (coro.close(), mock_task)[1]
310+
311+
await model.connect(config)
312+
313+
headers = captured_kwargs["additional_headers"]
314+
assert "Authorization" not in headers
315+
316+
subprotocols = captured_kwargs["subprotocols"]
317+
assert subprotocols[0] == "realtime"
318+
assert subprotocols[1].startswith("openai-insecure-api-key.ek_test")
319+
assert subprotocols[2].startswith("openai-agents-sdk.python.")
320+
# Ensure client secret API was called once
321+
assert mock_client_secret_request["init_kwargs"] == [{"api_key": "sk-test"}]
322+
assert mock_client_secret_request["sessions"] == [
323+
{"type": "realtime", "model": "gpt-realtime"}
324+
]
325+
326+
@pytest.mark.asyncio
327+
async def test_connect_with_ephemeral_key_skips_client_secret(
328+
self,
329+
mock_websocket,
330+
mock_client_secret_request,
331+
):
332+
"""Ensure pre-generated ek_ keys are used directly without calling the API."""
333+
model = OpenAIRealtimeWebSocketModel()
334+
config: RealtimeModelConfig = {
335+
"api_key": "ek_existing",
336+
"initial_model_settings": {},
337+
}
338+
339+
captured_kwargs: dict[str, Any] = {}
340+
341+
async def async_websocket(*args, **kwargs):
342+
captured_kwargs.update(kwargs)
343+
return mock_websocket
344+
345+
with patch("websockets.connect", side_effect=async_websocket):
346+
with patch("asyncio.create_task") as mock_create_task:
347+
mock_task = AsyncMock()
348+
mock_create_task.return_value = mock_task
349+
mock_create_task.side_effect = lambda coro: (coro.close(), mock_task)[1]
350+
351+
await model.connect(config)
352+
353+
# No client secret API calls should have been made
354+
assert mock_client_secret_request["init_kwargs"] == []
355+
assert mock_client_secret_request["sessions"] == []
356+
357+
subprotocols = captured_kwargs["subprotocols"]
358+
assert subprotocols[1] == "openai-insecure-api-key.ek_existing"

0 commit comments

Comments
 (0)