Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/agents/voice/models/openai_stt.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ def _audio_buffer_to_base64(buffer: npt.NDArray[np.int16 | np.float32]) -> str:
return base64.b64encode(buffer.tobytes()).decode("utf-8")


async def _refresh_openai_client_api_key_if_supported(client: Any) -> None:
"""Refresh callable/rotating OpenAI credentials before a manual websocket handshake."""
refresh_api_key = getattr(client, "_refresh_api_key", None)
if callable(refresh_api_key):
await refresh_api_key()


async def _wait_for_event(
event_queue: asyncio.Queue[dict[str, Any] | ErrorSentinel],
expected_types: list[str],
Expand Down Expand Up @@ -303,6 +310,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(
"wss://api.openai.com/v1/realtime?intent=transcription",
additional_headers={
Expand Down Expand Up @@ -553,4 +561,4 @@ async def create_session(
settings,
trace_include_sensitive_data,
trace_include_sensitive_audio_data,
)
)
59 changes: 59 additions & 0 deletions tests/voice/test_openai_stt_api_key_refresh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from typing import Any, cast
from unittest.mock import AsyncMock

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

async def _refresh_api_key(self) -> None:
self.refresh_calls += 1
self.api_key = "sk-refreshed"


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"