Skip to content

Commit 53073e5

Browse files
romanlutzCopilot
andauthored
MAINT: Unify OpenAI realtime event routing (#2319)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: be81055b-39ed-494c-94ca-c7656200a344
1 parent 101b139 commit 53073e5

4 files changed

Lines changed: 468 additions & 56 deletions

File tree

pyrit/prompt_target/openai/_openai_realtime_dispatcher.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
"""Concrete OpenAI Realtime event dispatcher for streaming sessions."""
55

6-
import base64
76
import logging
87
from typing import Any, ClassVar
98

@@ -13,6 +12,10 @@
1312
RealtimeTargetResult,
1413
RealtimeTurnState,
1514
)
15+
from pyrit.prompt_target.openai._openai_realtime_event_router import (
16+
_OpenAIRealtimeEventKind,
17+
_OpenAIRealtimeEventRouter,
18+
)
1619

1720
logger = logging.getLogger(__name__)
1821

@@ -32,18 +35,19 @@ class _OpenAIRealtimeDispatcher(RealtimeEventDispatcher):
3235
async def _route_event_async(self, *, event: Any, state: RealtimeTurnState | None) -> None:
3336
"""Route an OpenAI Realtime event to the active turn or to an input-side callback."""
3437
event_type = getattr(event, "type", "")
38+
event_kind = _OpenAIRealtimeEventRouter.classify_event(event_type)
3539

3640
# Capture audio_start_ms from speech_started for the next committed event.
3741
# The server reports it reliably here but omits it from the commit event itself.
3842
# Do not return — the downstream state-aware branch still needs to fire the
3943
# barge-in cancel when speech starts mid-response.
40-
if event_type == "input_audio_buffer.speech_started":
44+
if event_kind is _OpenAIRealtimeEventKind.SPEECH_STARTED:
4145
speech_start = getattr(event, "audio_start_ms", None)
4246
if speech_start is not None:
4347
self._pending_speech_start_ms = speech_start
4448

4549
# Input-side events fire callbacks regardless of whether a turn is registered.
46-
if event_type == "input_audio_buffer.committed":
50+
if event_kind is _OpenAIRealtimeEventKind.INPUT_COMMITTED:
4751
item_id = getattr(event, "item_id", None)
4852
if item_id is None:
4953
return
@@ -63,32 +67,33 @@ async def _route_event_async(self, *, event: Any, state: RealtimeTurnState | Non
6367
if state is None or state.completion.done():
6468
return
6569

66-
if event_type == "response.created":
70+
_OpenAIRealtimeEventRouter.collect_response_delta(
71+
event=event,
72+
event_kind=event_kind,
73+
audio_buffer=state.delivered_audio,
74+
transcripts=state.delivered_transcripts,
75+
)
76+
77+
if event_kind is _OpenAIRealtimeEventKind.RESPONSE_CREATED:
6778
state.is_responding = True
6879
response = getattr(event, "response", None)
6980
if response is not None:
7081
state.last_response_id = getattr(response, "id", None)
7182
return
7283

73-
if event_type in ("response.output_item.added", "response.output_item.created"):
84+
if event_kind is _OpenAIRealtimeEventKind.OUTPUT_ITEM:
7485
item = getattr(event, "item", None)
7586
if item is not None:
7687
state.current_item_id = getattr(item, "id", None)
7788
return
7889

79-
if event_type in ("response.audio.delta", "response.output_audio.delta"):
80-
delta = getattr(event, "delta", "")
81-
if delta:
82-
state.delivered_audio.extend(base64.b64decode(delta))
90+
if event_kind is _OpenAIRealtimeEventKind.AUDIO_DELTA:
8391
return
8492

85-
if event_type in ("response.audio_transcript.delta", "response.output_audio_transcript.delta"):
86-
delta = getattr(event, "delta", "")
87-
if delta:
88-
state.delivered_transcripts.append(delta)
93+
if event_kind is _OpenAIRealtimeEventKind.TRANSCRIPT_DELTA:
8994
return
9095

91-
if event_type == "response.done":
96+
if event_kind is _OpenAIRealtimeEventKind.RESPONSE_DONE:
9297
response = getattr(event, "response", None)
9398
done_response_id = getattr(response, "id", None) if response is not None else None
9499
if state.last_response_id is not None and done_response_id != state.last_response_id:
@@ -103,7 +108,7 @@ async def _route_event_async(self, *, event: Any, state: RealtimeTurnState | Non
103108
)
104109
return
105110

106-
if event_type == "input_audio_buffer.speech_started" and state.is_responding:
111+
if event_kind is _OpenAIRealtimeEventKind.SPEECH_STARTED and state.is_responding:
107112
await self._cancel_async(state=state)
108113
state.is_responding = False
109114
state.completion.set_result(
@@ -115,7 +120,7 @@ async def _route_event_async(self, *, event: Any, state: RealtimeTurnState | Non
115120
)
116121
return
117122

118-
if event_type == "error":
123+
if event_kind is _OpenAIRealtimeEventKind.ERROR:
119124
error = getattr(event, "error", None)
120125
code = getattr(error, "code", None) if error is not None else None
121126
message = getattr(error, "message", "unknown") if error is not None else "unknown"
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Shared OpenAI Realtime event classification and response accumulation."""
5+
6+
import base64
7+
from enum import Enum, auto
8+
from typing import Any, ClassVar
9+
10+
11+
class _OpenAIRealtimeEventKind(Enum):
12+
"""Provider event categories shared by atomic and streaming receive policies."""
13+
14+
RESPONSE_DONE = auto()
15+
ERROR = auto()
16+
AUDIO_DELTA = auto()
17+
AUDIO_DONE = auto()
18+
TRANSCRIPT_DELTA = auto()
19+
OUTPUT_TEXT_DONE = auto()
20+
RESPONSE_CREATED = auto()
21+
OUTPUT_ITEM = auto()
22+
SPEECH_STARTED = auto()
23+
INPUT_COMMITTED = auto()
24+
LIFECYCLE = auto()
25+
OTHER = auto()
26+
27+
28+
class _OpenAIRealtimeEventRouter:
29+
"""Classify provider events and apply response deltas to caller-owned buffers."""
30+
31+
_KINDS_BY_EVENT_TYPE: ClassVar[dict[str, _OpenAIRealtimeEventKind]] = {
32+
"response.done": _OpenAIRealtimeEventKind.RESPONSE_DONE,
33+
"error": _OpenAIRealtimeEventKind.ERROR,
34+
"response.audio.delta": _OpenAIRealtimeEventKind.AUDIO_DELTA,
35+
"response.output_audio.delta": _OpenAIRealtimeEventKind.AUDIO_DELTA,
36+
"response.audio.done": _OpenAIRealtimeEventKind.AUDIO_DONE,
37+
"response.output_audio.done": _OpenAIRealtimeEventKind.AUDIO_DONE,
38+
"response.audio_transcript.delta": _OpenAIRealtimeEventKind.TRANSCRIPT_DELTA,
39+
"response.output_audio_transcript.delta": _OpenAIRealtimeEventKind.TRANSCRIPT_DELTA,
40+
"response.output_text.done": _OpenAIRealtimeEventKind.OUTPUT_TEXT_DONE,
41+
"response.created": _OpenAIRealtimeEventKind.RESPONSE_CREATED,
42+
"response.output_item.added": _OpenAIRealtimeEventKind.OUTPUT_ITEM,
43+
"response.output_item.created": _OpenAIRealtimeEventKind.OUTPUT_ITEM,
44+
"input_audio_buffer.speech_started": _OpenAIRealtimeEventKind.SPEECH_STARTED,
45+
"input_audio_buffer.committed": _OpenAIRealtimeEventKind.INPUT_COMMITTED,
46+
}
47+
_LIFECYCLE_EVENT_TYPES: ClassVar[frozenset[str]] = frozenset(
48+
{
49+
"session.created",
50+
"session.updated",
51+
"conversation.created",
52+
"conversation.item.created",
53+
"conversation.item.added",
54+
"conversation.item.done",
55+
"input_audio_buffer.speech_stopped",
56+
"conversation.item.input_audio_transcription.completed",
57+
"response.output_item.done",
58+
"response.content_part.added",
59+
"response.content_part.done",
60+
"response.audio_transcript.done",
61+
"response.output_audio_transcript.done",
62+
"response.output_text.delta",
63+
"rate_limits.updated",
64+
}
65+
)
66+
_LIFECYCLE_KINDS: ClassVar[frozenset[_OpenAIRealtimeEventKind]] = frozenset(
67+
{
68+
_OpenAIRealtimeEventKind.RESPONSE_CREATED,
69+
_OpenAIRealtimeEventKind.OUTPUT_ITEM,
70+
_OpenAIRealtimeEventKind.SPEECH_STARTED,
71+
_OpenAIRealtimeEventKind.INPUT_COMMITTED,
72+
_OpenAIRealtimeEventKind.LIFECYCLE,
73+
}
74+
)
75+
76+
@classmethod
77+
def classify_event(cls, event_type: str) -> _OpenAIRealtimeEventKind:
78+
"""Return the normalized category for a provider event type."""
79+
event_kind = cls._KINDS_BY_EVENT_TYPE.get(event_type)
80+
if event_kind is not None:
81+
return event_kind
82+
if event_type in cls._LIFECYCLE_EVENT_TYPES:
83+
return _OpenAIRealtimeEventKind.LIFECYCLE
84+
return _OpenAIRealtimeEventKind.OTHER
85+
86+
@classmethod
87+
def is_lifecycle_event(cls, event_kind: _OpenAIRealtimeEventKind) -> bool:
88+
"""Return whether atomic receiving should log the event as lifecycle-only."""
89+
return event_kind in cls._LIFECYCLE_KINDS
90+
91+
@staticmethod
92+
def collect_response_delta(
93+
*,
94+
event: Any,
95+
event_kind: _OpenAIRealtimeEventKind,
96+
audio_buffer: bytearray,
97+
transcripts: list[str],
98+
) -> None:
99+
"""Apply an audio or transcript delta to caller-owned response buffers."""
100+
delta = getattr(event, "delta", "")
101+
if not delta:
102+
return
103+
if event_kind is _OpenAIRealtimeEventKind.AUDIO_DELTA:
104+
audio_buffer.extend(base64.b64decode(delta))
105+
elif event_kind is _OpenAIRealtimeEventKind.TRANSCRIPT_DELTA:
106+
transcripts.append(delta)

pyrit/prompt_target/openai/openai_realtime_target.py

Lines changed: 28 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
from pyrit.prompt_target.common.target_capabilities import TargetCapabilities
2525
from pyrit.prompt_target.common.target_configuration import TargetConfiguration
2626
from pyrit.prompt_target.common.utils import limit_requests_per_minute
27+
from pyrit.prompt_target.openai._openai_realtime_event_router import (
28+
_OpenAIRealtimeEventKind,
29+
_OpenAIRealtimeEventRouter,
30+
)
2731
from pyrit.prompt_target.openai._openai_realtime_streaming_session import (
2832
_OpenAIRealtimeStreamingSession,
2933
)
@@ -575,6 +579,7 @@ async def receive_events_async(self, conversation_id: str) -> RealtimeTargetResu
575579
connection = self._get_connection(conversation_id=conversation_id)
576580

577581
result = RealtimeTargetResult()
582+
audio_buffer = bytearray()
578583
audio_done_received = False
579584
current_turn_event_count = 0
580585
grace_period_sec = 1.0 # Wait 1 second after audio.done before soft-finishing
@@ -595,7 +600,7 @@ async def receive_events_async(self, conversation_id: str) -> RealtimeTargetResu
595600
if audio_done_received:
596601
logger.warning(
597602
f"Soft-finishing: No response.done {grace_period_sec}s after audio.done. "
598-
f"Audio bytes: {len(result.audio_bytes)}"
603+
f"Audio bytes: {len(audio_buffer)}"
599604
)
600605
break
601606
# Should not happen if timeout is None, but re-raise if it does
@@ -606,22 +611,30 @@ async def receive_events_async(self, conversation_id: str) -> RealtimeTargetResu
606611
break
607612
except Exception as conn_err:
608613
# Handle websockets connection errors as soft-finish if we have audio
609-
if "ConnectionClosed" in str(type(conn_err).__name__) and result.audio_bytes:
614+
if "ConnectionClosed" in str(type(conn_err).__name__) and audio_buffer:
610615
logger.warning(
611616
f"Connection closed without response.done (likely API issue). "
612-
f"Audio bytes received: {len(result.audio_bytes)}. Soft-finishing."
617+
f"Audio bytes received: {len(audio_buffer)}. Soft-finishing."
613618
)
614619
break
615620
# Re-raise if not a connection close or no audio received
616621
raise
617622

618623
event_type = event.type
624+
event_kind = _OpenAIRealtimeEventRouter.classify_event(event_type)
619625
current_turn_event_count += 1
620626
logger.debug(f"Processing event type: {event_type}")
621-
622-
if event_type == "response.done":
627+
audio_size_before = len(audio_buffer)
628+
_OpenAIRealtimeEventRouter.collect_response_delta(
629+
event=event,
630+
event_kind=event_kind,
631+
audio_buffer=audio_buffer,
632+
transcripts=result.transcripts,
633+
)
634+
635+
if event_kind is _OpenAIRealtimeEventKind.RESPONSE_DONE:
623636
self._handle_response_done_event(event=event, result=result)
624-
if result.audio_bytes or current_turn_event_count > 1:
637+
if audio_buffer or current_turn_event_count > 1:
625638
# Legitimate response.done: either we have audio, or other events
626639
# (e.g. response.created) preceded it, confirming it belongs to this turn.
627640
logger.debug("Received response.done - finishing normally")
@@ -635,53 +648,27 @@ async def receive_events_async(self, conversation_id: str) -> RealtimeTargetResu
635648
"likely a stale event from a prior turn's soft-finish. Skipping."
636649
)
637650

638-
elif event_type == "error":
651+
elif event_kind is _OpenAIRealtimeEventKind.ERROR:
639652
error_message = event.error.message if hasattr(event.error, "message") else str(event.error)
640653
error_type = event.error.type if hasattr(event.error, "type") else "unknown"
641654
logger.error(f"Received 'error' event: [{error_type}] {error_message}")
642655
raise RuntimeError(f"Server error: [{error_type}] {error_message}")
643656

644-
elif event_type in ["response.audio.delta", "response.output_audio.delta"]:
645-
audio_data = base64.b64decode(event.delta)
646-
result.audio_bytes += audio_data
647-
logger.debug(f"Decoded {len(audio_data)} bytes of audio data")
657+
elif event_kind is _OpenAIRealtimeEventKind.AUDIO_DELTA:
658+
logger.debug(f"Decoded {len(audio_buffer) - audio_size_before} bytes of audio data")
648659

649-
elif event_type in ["response.audio.done", "response.output_audio.done"]:
660+
elif event_kind is _OpenAIRealtimeEventKind.AUDIO_DONE:
650661
logger.debug(f"Received audio.done - will soft-finish in {grace_period_sec}s if no response.done")
651662
audio_done_received = True
652663

653-
elif event_type in ["response.audio_transcript.delta", "response.output_audio_transcript.delta"]:
654-
# Capture transcript deltas as they arrive (needed when response.done never comes)
655-
if hasattr(event, "delta") and event.delta:
656-
result.transcripts.append(event.delta)
664+
elif event_kind is _OpenAIRealtimeEventKind.TRANSCRIPT_DELTA:
665+
if getattr(event, "delta", ""):
657666
logger.debug(f"Captured transcript delta: {event.delta[:50]}...")
658667

659-
elif event_type in ["response.output_text.done"]:
668+
elif event_kind is _OpenAIRealtimeEventKind.OUTPUT_TEXT_DONE:
660669
logger.debug("Received text.done")
661670

662-
# Handle lifecycle events that we can safely log
663-
elif event_type in [
664-
"session.created",
665-
"session.updated",
666-
"conversation.created",
667-
"conversation.item.created",
668-
"conversation.item.added",
669-
"conversation.item.done",
670-
"input_audio_buffer.committed",
671-
"input_audio_buffer.speech_started",
672-
"input_audio_buffer.speech_stopped",
673-
"conversation.item.input_audio_transcription.completed",
674-
"response.created",
675-
"response.output_item.added",
676-
"response.output_item.created",
677-
"response.output_item.done",
678-
"response.content_part.added",
679-
"response.content_part.done",
680-
"response.audio_transcript.done",
681-
"response.output_audio_transcript.done",
682-
"response.output_text.delta",
683-
"rate_limits.updated",
684-
]:
671+
elif _OpenAIRealtimeEventRouter.is_lifecycle_event(event_kind):
685672
logger.debug(f"Lifecycle event '{event_type}'")
686673

687674
else:
@@ -691,6 +678,7 @@ async def receive_events_async(self, conversation_id: str) -> RealtimeTargetResu
691678
logger.error(f"An unexpected error occurred for conversation {conversation_id}: {e}")
692679
raise
693680

681+
result.audio_bytes = bytes(audio_buffer)
694682
logger.debug(
695683
f"Completed receive_events with {len(result.transcripts)} transcripts "
696684
f"and {len(result.audio_bytes)} bytes of audio"

0 commit comments

Comments
 (0)