diff --git a/pyproject.toml b/pyproject.toml index 0f9b70852..b654f0c3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.9" license = "MIT" authors = [{ name = "OpenAI", email = "support@openai.com" }] dependencies = [ - "openai>=1.93.1, <2", + "openai>=1.96.0, <2", "pydantic>=2.10, <3", "griffe>=1.5.6, <2", "typing-extensions>=4.12.2, <5", diff --git a/src/agents/realtime/items.py b/src/agents/realtime/items.py index a835e7a88..4d618f3a9 100644 --- a/src/agents/realtime/items.py +++ b/src/agents/realtime/items.py @@ -73,6 +73,7 @@ class AssistantMessageItem(BaseModel): class RealtimeToolCallItem(BaseModel): item_id: str previous_item_id: str | None = None + call_id: str | None type: Literal["function_call"] = "function_call" status: Literal["in_progress", "completed"] arguments: str diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index 1c4a4de3c..129d89c49 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -10,14 +10,47 @@ import pydantic import websockets -from openai.types.beta.realtime.conversation_item import ConversationItem +from openai.types.beta.realtime.conversation_item import ( + ConversationItem, + ConversationItem as OpenAIConversationItem, +) +from openai.types.beta.realtime.conversation_item_content import ( + ConversationItemContent as OpenAIConversationItemContent, +) +from openai.types.beta.realtime.conversation_item_create_event import ( + ConversationItemCreateEvent as OpenAIConversationItemCreateEvent, +) +from openai.types.beta.realtime.conversation_item_retrieve_event import ( + ConversationItemRetrieveEvent as OpenAIConversationItemRetrieveEvent, +) +from openai.types.beta.realtime.conversation_item_truncate_event import ( + ConversationItemTruncateEvent as OpenAIConversationItemTruncateEvent, +) +from openai.types.beta.realtime.input_audio_buffer_append_event import ( + InputAudioBufferAppendEvent as OpenAIInputAudioBufferAppendEvent, +) +from openai.types.beta.realtime.input_audio_buffer_commit_event import ( + InputAudioBufferCommitEvent as OpenAIInputAudioBufferCommitEvent, +) +from openai.types.beta.realtime.realtime_client_event import ( + RealtimeClientEvent as OpenAIRealtimeClientEvent, +) from openai.types.beta.realtime.realtime_server_event import ( RealtimeServerEvent as OpenAIRealtimeServerEvent, ) from openai.types.beta.realtime.response_audio_delta_event import ResponseAudioDeltaEvent +from openai.types.beta.realtime.response_cancel_event import ( + ResponseCancelEvent as OpenAIResponseCancelEvent, +) +from openai.types.beta.realtime.response_create_event import ( + ResponseCreateEvent as OpenAIResponseCreateEvent, +) from openai.types.beta.realtime.session_update_event import ( Session as OpenAISessionObject, SessionTool as OpenAISessionTool, + SessionTracing as OpenAISessionTracing, + SessionTracingTracingConfiguration as OpenAISessionTracingConfiguration, + SessionUpdateEvent as OpenAISessionUpdateEvent, ) from pydantic import TypeAdapter from typing_extensions import assert_never @@ -135,12 +168,11 @@ async def _send_tracing_config( ) -> None: """Update tracing configuration via session.update event.""" if tracing_config is not None: + converted_tracing_config = _ConversionHelper.convert_tracing_config(tracing_config) await self._send_raw_message( - RealtimeModelSendRawMessage( - message={ - "type": "session.update", - "other_data": {"session": {"tracing": tracing_config}}, - } + OpenAISessionUpdateEvent( + session=OpenAISessionObject(tracing=converted_tracing_config), + type="session.update", ) ) @@ -199,7 +231,11 @@ async def _listen_for_messages(self): async def send_event(self, event: RealtimeModelSendEvent) -> None: """Send an event to the model.""" if isinstance(event, RealtimeModelSendRawMessage): - await self._send_raw_message(event) + converted = _ConversionHelper.try_convert_raw_message(event) + if converted is not None: + await self._send_raw_message(converted) + else: + logger.error(f"Failed to convert raw message: {event}") elif isinstance(event, RealtimeModelSendUserInput): await self._send_user_input(event) elif isinstance(event, RealtimeModelSendAudio): @@ -214,77 +250,33 @@ async def send_event(self, event: RealtimeModelSendEvent) -> None: assert_never(event) raise ValueError(f"Unknown event type: {type(event)}") - async def _send_raw_message(self, event: RealtimeModelSendRawMessage) -> None: + async def _send_raw_message(self, event: OpenAIRealtimeClientEvent) -> None: """Send a raw message to the model.""" assert self._websocket is not None, "Not connected" - converted_event = { - "type": event.message["type"], - } - - converted_event.update(event.message.get("other_data", {})) - - await self._websocket.send(json.dumps(converted_event)) + await self._websocket.send(event.model_dump_json(exclude_none=True, exclude_unset=True)) async def _send_user_input(self, event: RealtimeModelSendUserInput) -> None: - message = ( - event.user_input - if isinstance(event.user_input, dict) - else { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": event.user_input}], - } - ) - other_data = { - "item": message, - } - - await self._send_raw_message( - RealtimeModelSendRawMessage( - message={"type": "conversation.item.create", "other_data": other_data} - ) - ) - await self._send_raw_message( - RealtimeModelSendRawMessage(message={"type": "response.create"}) - ) + converted = _ConversionHelper.convert_user_input_to_item_create(event) + await self._send_raw_message(converted) + await self._send_raw_message(OpenAIResponseCreateEvent(type="response.create")) async def _send_audio(self, event: RealtimeModelSendAudio) -> None: - base64_audio = base64.b64encode(event.audio).decode("utf-8") - await self._send_raw_message( - RealtimeModelSendRawMessage( - message={ - "type": "input_audio_buffer.append", - "other_data": { - "audio": base64_audio, - }, - } - ) - ) + converted = _ConversionHelper.convert_audio_to_input_audio_buffer_append(event) + await self._send_raw_message(converted) if event.commit: await self._send_raw_message( - RealtimeModelSendRawMessage(message={"type": "input_audio_buffer.commit"}) + OpenAIInputAudioBufferCommitEvent(type="input_audio_buffer.commit") ) async def _send_tool_output(self, event: RealtimeModelSendToolOutput) -> None: - await self._send_raw_message( - RealtimeModelSendRawMessage( - message={ - "type": "conversation.item.create", - "other_data": { - "item": { - "type": "function_call_output", - "output": event.output, - "call_id": event.tool_call.id, - }, - }, - } - ) - ) + converted = _ConversionHelper.convert_tool_output(event) + await self._send_raw_message(converted) tool_item = RealtimeToolCallItem( item_id=event.tool_call.id or "", previous_item_id=event.tool_call.previous_item_id, + call_id=event.tool_call.call_id, type="function_call", status="completed", arguments=event.tool_call.arguments, @@ -294,9 +286,7 @@ async def _send_tool_output(self, event: RealtimeModelSendToolOutput) -> None: await self._emit_event(RealtimeModelItemUpdatedEvent(item=tool_item)) if event.start_response: - await self._send_raw_message( - RealtimeModelSendRawMessage(message={"type": "response.create"}) - ) + await self._send_raw_message(OpenAIResponseCreateEvent(type="response.create")) async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: if not self._current_item_id or not self._audio_start_time: @@ -307,18 +297,12 @@ async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: elapsed_time_ms = (datetime.now() - self._audio_start_time).total_seconds() * 1000 if elapsed_time_ms > 0 and elapsed_time_ms < self._audio_length_ms: await self._emit_event(RealtimeModelAudioInterruptedEvent()) - await self._send_raw_message( - RealtimeModelSendRawMessage( - message={ - "type": "conversation.item.truncate", - "other_data": { - "item_id": self._current_item_id, - "content_index": self._current_audio_content_index, - "audio_end_ms": elapsed_time_ms, - }, - } - ) + converted = _ConversionHelper.convert_interrupt( + self._current_item_id, + self._current_audio_content_index or 0, + int(elapsed_time_ms), ) + await self._send_raw_message(converted) self._current_item_id = None self._audio_start_time = None @@ -354,6 +338,7 @@ async def _handle_output_item(self, item: ConversationItem) -> None: tool_call = RealtimeToolCallItem( item_id=item.id or "", previous_item_id=None, + call_id=item.call_id, type="function_call", # We use the same item for tool call and output, so it will be completed by the # output being added @@ -365,7 +350,7 @@ async def _handle_output_item(self, item: ConversationItem) -> None: await self._emit_event(RealtimeModelItemUpdatedEvent(item=tool_call)) await self._emit_event( RealtimeModelToolCallEvent( - call_id=item.id or "", + call_id=item.call_id or "", name=item.name or "", arguments=item.arguments or "", id=item.id or "", @@ -404,9 +389,7 @@ async def close(self) -> None: async def _cancel_response(self) -> None: if self._ongoing_response: - await self._send_raw_message( - RealtimeModelSendRawMessage(message={"type": "response.cancel"}) - ) + await self._send_raw_message(OpenAIResponseCancelEvent(type="response.cancel")) self._ongoing_response = False async def _handle_ws_event(self, event: dict[str, Any]): @@ -466,16 +449,13 @@ async def _handle_ws_event(self, event: dict[str, Any]): parsed.type == "conversation.item.input_audio_transcription.completed" or parsed.type == "conversation.item.truncated" ): - await self._send_raw_message( - RealtimeModelSendRawMessage( - message={ - "type": "conversation.item.retrieve", - "other_data": { - "item_id": self._current_item_id, - }, - } + if self._current_item_id: + await self._send_raw_message( + OpenAIConversationItemRetrieveEvent( + type="conversation.item.retrieve", + item_id=self._current_item_id, + ) ) - ) if parsed.type == "conversation.item.input_audio_transcription.completed": await self._emit_event( RealtimeModelInputAudioTranscriptionCompletedEvent( @@ -504,14 +484,7 @@ async def _handle_ws_event(self, event: dict[str, Any]): async def _update_session_config(self, model_settings: RealtimeSessionModelSettings) -> None: session_config = self._get_session_config(model_settings) await self._send_raw_message( - RealtimeModelSendRawMessage( - message={ - "type": "session.update", - "other_data": { - "session": session_config.model_dump(exclude_unset=True, exclude_none=True) - }, - } - ) + OpenAISessionUpdateEvent(session=session_config, type="session.update") ) def _get_session_config( @@ -582,3 +555,98 @@ def conversation_item_to_realtime_message_item( "status": "in_progress", }, ) + + @classmethod + def try_convert_raw_message( + cls, message: RealtimeModelSendRawMessage + ) -> OpenAIRealtimeClientEvent | None: + try: + data = {} + data["type"] = message.message["type"] + data.update(message.message.get("other_data", {})) + return TypeAdapter(OpenAIRealtimeClientEvent).validate_python(data) + except Exception: + return None + + @classmethod + def convert_tracing_config( + cls, tracing_config: RealtimeModelTracingConfig | Literal["auto"] | None + ) -> OpenAISessionTracing | None: + if tracing_config is None: + return None + elif tracing_config == "auto": + return "auto" + return OpenAISessionTracingConfiguration( + group_id=tracing_config.get("group_id"), + metadata=tracing_config.get("metadata"), + workflow_name=tracing_config.get("workflow_name"), + ) + + @classmethod + def convert_user_input_to_conversation_item( + cls, event: RealtimeModelSendUserInput + ) -> OpenAIConversationItem: + user_input = event.user_input + + if isinstance(user_input, dict): + return OpenAIConversationItem( + type="message", + role="user", + content=[ + OpenAIConversationItemContent( + type="input_text", + text=item.get("text"), + ) + for item in user_input.get("content", []) + ], + ) + else: + return OpenAIConversationItem( + type="message", + role="user", + content=[OpenAIConversationItemContent(type="input_text", text=user_input)], + ) + + @classmethod + def convert_user_input_to_item_create( + cls, event: RealtimeModelSendUserInput + ) -> OpenAIRealtimeClientEvent: + return OpenAIConversationItemCreateEvent( + type="conversation.item.create", + item=cls.convert_user_input_to_conversation_item(event), + ) + + @classmethod + def convert_audio_to_input_audio_buffer_append( + cls, event: RealtimeModelSendAudio + ) -> OpenAIRealtimeClientEvent: + base64_audio = base64.b64encode(event.audio).decode("utf-8") + return OpenAIInputAudioBufferAppendEvent( + type="input_audio_buffer.append", + audio=base64_audio, + ) + + @classmethod + def convert_tool_output(cls, event: RealtimeModelSendToolOutput) -> OpenAIRealtimeClientEvent: + return OpenAIConversationItemCreateEvent( + type="conversation.item.create", + item=OpenAIConversationItem( + type="function_call_output", + output=event.output, + call_id=event.tool_call.call_id, + ), + ) + + @classmethod + def convert_interrupt( + cls, + current_item_id: str, + current_audio_content_index: int, + elapsed_time_ms: int, + ) -> OpenAIRealtimeClientEvent: + return OpenAIConversationItemTruncateEvent( + type="conversation.item.truncate", + item_id=current_item_id, + content_index=current_audio_content_index, + audio_end_ms=elapsed_time_ms, + ) diff --git a/tests/realtime/test_conversion_helpers.py b/tests/realtime/test_conversion_helpers.py new file mode 100644 index 000000000..2264407c9 --- /dev/null +++ b/tests/realtime/test_conversion_helpers.py @@ -0,0 +1,375 @@ +import base64 +from unittest.mock import Mock + +from openai.types.beta.realtime.conversation_item import ConversationItem +from openai.types.beta.realtime.conversation_item_create_event import ConversationItemCreateEvent +from openai.types.beta.realtime.conversation_item_truncate_event import ( + ConversationItemTruncateEvent, +) +from openai.types.beta.realtime.input_audio_buffer_append_event import InputAudioBufferAppendEvent +from openai.types.beta.realtime.session_update_event import ( + SessionTracingTracingConfiguration, +) + +from agents.realtime.config import RealtimeModelTracingConfig +from agents.realtime.model_inputs import ( + RealtimeModelSendAudio, + RealtimeModelSendRawMessage, + RealtimeModelSendToolOutput, + RealtimeModelSendUserInput, + RealtimeModelUserInputMessage, +) +from agents.realtime.openai_realtime import _ConversionHelper + + +class TestConversionHelperTryConvertRawMessage: + """Test suite for _ConversionHelper.try_convert_raw_message method.""" + + def test_try_convert_raw_message_valid_session_update(self): + """Test converting a valid session.update raw message.""" + raw_message = RealtimeModelSendRawMessage( + message={ + "type": "session.update", + "other_data": { + "session": { + "modalities": ["text", "audio"], + "voice": "ash", + } + }, + } + ) + + result = _ConversionHelper.try_convert_raw_message(raw_message) + + assert result is not None + assert result.type == "session.update" + + def test_try_convert_raw_message_valid_response_create(self): + """Test converting a valid response.create raw message.""" + raw_message = RealtimeModelSendRawMessage( + message={ + "type": "response.create", + "other_data": {}, + } + ) + + result = _ConversionHelper.try_convert_raw_message(raw_message) + + assert result is not None + assert result.type == "response.create" + + def test_try_convert_raw_message_invalid_type(self): + """Test converting an invalid message type returns None.""" + raw_message = RealtimeModelSendRawMessage( + message={ + "type": "invalid.message.type", + "other_data": {}, + } + ) + + result = _ConversionHelper.try_convert_raw_message(raw_message) + + assert result is None + + def test_try_convert_raw_message_malformed_data(self): + """Test converting malformed message data returns None.""" + raw_message = RealtimeModelSendRawMessage( + message={ + "type": "session.update", + "other_data": { + "session": "invalid_session_data" # Should be dict + }, + } + ) + + result = _ConversionHelper.try_convert_raw_message(raw_message) + + assert result is None + + def test_try_convert_raw_message_missing_type(self): + """Test converting message without type returns None.""" + raw_message = RealtimeModelSendRawMessage( + message={ + "type": "missing.type.test", + "other_data": {"some": "data"}, + } + ) + + result = _ConversionHelper.try_convert_raw_message(raw_message) + + assert result is None + + +class TestConversionHelperTracingConfig: + """Test suite for _ConversionHelper.convert_tracing_config method.""" + + def test_convert_tracing_config_none(self): + """Test converting None tracing config.""" + result = _ConversionHelper.convert_tracing_config(None) + assert result is None + + def test_convert_tracing_config_auto(self): + """Test converting 'auto' tracing config.""" + result = _ConversionHelper.convert_tracing_config("auto") + assert result == "auto" + + def test_convert_tracing_config_dict_full(self): + """Test converting full tracing config dict.""" + tracing_config: RealtimeModelTracingConfig = { + "group_id": "test-group", + "metadata": {"env": "test"}, + "workflow_name": "test-workflow", + } + + result = _ConversionHelper.convert_tracing_config(tracing_config) + + assert isinstance(result, SessionTracingTracingConfiguration) + assert result.group_id == "test-group" + assert result.metadata == {"env": "test"} + assert result.workflow_name == "test-workflow" + + def test_convert_tracing_config_dict_partial(self): + """Test converting partial tracing config dict.""" + tracing_config: RealtimeModelTracingConfig = { + "group_id": "test-group", + } + + result = _ConversionHelper.convert_tracing_config(tracing_config) + + assert isinstance(result, SessionTracingTracingConfiguration) + assert result.group_id == "test-group" + assert result.metadata is None + assert result.workflow_name is None + + def test_convert_tracing_config_empty_dict(self): + """Test converting empty tracing config dict.""" + tracing_config: RealtimeModelTracingConfig = {} + + result = _ConversionHelper.convert_tracing_config(tracing_config) + + assert isinstance(result, SessionTracingTracingConfiguration) + assert result.group_id is None + assert result.metadata is None + assert result.workflow_name is None + + +class TestConversionHelperUserInput: + """Test suite for _ConversionHelper user input conversion methods.""" + + def test_convert_user_input_to_conversation_item_string(self): + """Test converting string user input to conversation item.""" + event = RealtimeModelSendUserInput(user_input="Hello, world!") + + result = _ConversionHelper.convert_user_input_to_conversation_item(event) + + assert isinstance(result, ConversationItem) + assert result.type == "message" + assert result.role == "user" + assert result.content is not None + assert len(result.content) == 1 + assert result.content[0].type == "input_text" + assert result.content[0].text == "Hello, world!" + + def test_convert_user_input_to_conversation_item_dict(self): + """Test converting dict user input to conversation item.""" + user_input_dict: RealtimeModelUserInputMessage = { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Hello"}, + {"type": "input_text", "text": "World"}, + ] + } + event = RealtimeModelSendUserInput(user_input=user_input_dict) + + result = _ConversionHelper.convert_user_input_to_conversation_item(event) + + assert isinstance(result, ConversationItem) + assert result.type == "message" + assert result.role == "user" + assert result.content is not None + assert len(result.content) == 2 + assert result.content[0].type == "input_text" + assert result.content[0].text == "Hello" + assert result.content[1].type == "input_text" + assert result.content[1].text == "World" + + def test_convert_user_input_to_conversation_item_dict_empty_content(self): + """Test converting dict user input with empty content.""" + user_input_dict: RealtimeModelUserInputMessage = { + "type": "message", + "role": "user", + "content": [] + } + event = RealtimeModelSendUserInput(user_input=user_input_dict) + + result = _ConversionHelper.convert_user_input_to_conversation_item(event) + + assert isinstance(result, ConversationItem) + assert result.type == "message" + assert result.role == "user" + assert result.content is not None + assert len(result.content) == 0 + + def test_convert_user_input_to_item_create(self): + """Test converting user input to item create event.""" + event = RealtimeModelSendUserInput(user_input="Test message") + + result = _ConversionHelper.convert_user_input_to_item_create(event) + + assert isinstance(result, ConversationItemCreateEvent) + assert result.type == "conversation.item.create" + assert isinstance(result.item, ConversationItem) + assert result.item.type == "message" + assert result.item.role == "user" + + +class TestConversionHelperAudio: + """Test suite for _ConversionHelper.convert_audio_to_input_audio_buffer_append.""" + + def test_convert_audio_to_input_audio_buffer_append(self): + """Test converting audio data to input audio buffer append event.""" + audio_data = b"test audio data" + event = RealtimeModelSendAudio(audio=audio_data, commit=False) + + result = _ConversionHelper.convert_audio_to_input_audio_buffer_append(event) + + assert isinstance(result, InputAudioBufferAppendEvent) + assert result.type == "input_audio_buffer.append" + + # Verify base64 encoding + expected_b64 = base64.b64encode(audio_data).decode("utf-8") + assert result.audio == expected_b64 + + def test_convert_audio_to_input_audio_buffer_append_empty(self): + """Test converting empty audio data.""" + audio_data = b"" + event = RealtimeModelSendAudio(audio=audio_data, commit=True) + + result = _ConversionHelper.convert_audio_to_input_audio_buffer_append(event) + + assert isinstance(result, InputAudioBufferAppendEvent) + assert result.type == "input_audio_buffer.append" + assert result.audio == "" + + def test_convert_audio_to_input_audio_buffer_append_large_data(self): + """Test converting large audio data.""" + audio_data = b"x" * 10000 # Large audio buffer + event = RealtimeModelSendAudio(audio=audio_data, commit=False) + + result = _ConversionHelper.convert_audio_to_input_audio_buffer_append(event) + + assert isinstance(result, InputAudioBufferAppendEvent) + assert result.type == "input_audio_buffer.append" + + # Verify it can be decoded back + decoded = base64.b64decode(result.audio) + assert decoded == audio_data + + +class TestConversionHelperToolOutput: + """Test suite for _ConversionHelper.convert_tool_output method.""" + + def test_convert_tool_output(self): + """Test converting tool output to conversation item create event.""" + mock_tool_call = Mock() + mock_tool_call.call_id = "call_123" + + event = RealtimeModelSendToolOutput( + tool_call=mock_tool_call, + output="Function executed successfully", + start_response=False, + ) + + result = _ConversionHelper.convert_tool_output(event) + + assert isinstance(result, ConversationItemCreateEvent) + assert result.type == "conversation.item.create" + assert isinstance(result.item, ConversationItem) + assert result.item.type == "function_call_output" + assert result.item.output == "Function executed successfully" + assert result.item.call_id == "call_123" + + def test_convert_tool_output_no_call_id(self): + """Test converting tool output with None call_id.""" + mock_tool_call = Mock() + mock_tool_call.call_id = None + + event = RealtimeModelSendToolOutput( + tool_call=mock_tool_call, + output="Output without call ID", + start_response=False, + ) + + result = _ConversionHelper.convert_tool_output(event) + + assert isinstance(result, ConversationItemCreateEvent) + assert result.type == "conversation.item.create" + assert result.item.call_id is None + + def test_convert_tool_output_empty_output(self): + """Test converting tool output with empty output.""" + mock_tool_call = Mock() + mock_tool_call.call_id = "call_456" + + event = RealtimeModelSendToolOutput( + tool_call=mock_tool_call, + output="", + start_response=True, + ) + + result = _ConversionHelper.convert_tool_output(event) + + assert isinstance(result, ConversationItemCreateEvent) + assert result.item.output == "" + assert result.item.call_id == "call_456" + + +class TestConversionHelperInterrupt: + """Test suite for _ConversionHelper.convert_interrupt method.""" + + def test_convert_interrupt(self): + """Test converting interrupt parameters to conversation item truncate event.""" + current_item_id = "item_789" + current_audio_content_index = 2 + elapsed_time_ms = 1500 + + result = _ConversionHelper.convert_interrupt( + current_item_id, current_audio_content_index, elapsed_time_ms + ) + + assert isinstance(result, ConversationItemTruncateEvent) + assert result.type == "conversation.item.truncate" + assert result.item_id == "item_789" + assert result.content_index == 2 + assert result.audio_end_ms == 1500 + + def test_convert_interrupt_zero_time(self): + """Test converting interrupt with zero elapsed time.""" + result = _ConversionHelper.convert_interrupt("item_1", 0, 0) + + assert isinstance(result, ConversationItemTruncateEvent) + assert result.type == "conversation.item.truncate" + assert result.item_id == "item_1" + assert result.content_index == 0 + assert result.audio_end_ms == 0 + + def test_convert_interrupt_large_values(self): + """Test converting interrupt with large values.""" + result = _ConversionHelper.convert_interrupt("item_xyz", 99, 999999) + + assert isinstance(result, ConversationItemTruncateEvent) + assert result.type == "conversation.item.truncate" + assert result.item_id == "item_xyz" + assert result.content_index == 99 + assert result.audio_end_ms == 999999 + + def test_convert_interrupt_empty_item_id(self): + """Test converting interrupt with empty item ID.""" + result = _ConversionHelper.convert_interrupt("", 1, 100) + + assert isinstance(result, ConversationItemTruncateEvent) + assert result.type == "conversation.item.truncate" + assert result.item_id == "" + assert result.content_index == 1 + assert result.audio_end_ms == 100 diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index 9ecc433ca..5cb0eb0fa 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -292,6 +292,7 @@ async def test_handle_tool_call_event_success(self, model): "output_index": 0, "item": { "id": "call_123", + "call_id": "call_123", "type": "function_call", "status": "completed", "name": "get_weather", diff --git a/tests/realtime/test_tracing.py b/tests/realtime/test_tracing.py index 4cff46c49..85da63897 100644 --- a/tests/realtime/test_tracing.py +++ b/tests/realtime/test_tracing.py @@ -99,22 +99,18 @@ async def async_websocket(*args, **kwargs): await model._handle_ws_event(session_created_event) # Should send session.update with tracing config - from agents.realtime.model_inputs import RealtimeModelSendRawMessage - - expected_event = RealtimeModelSendRawMessage( - message={ - "type": "session.update", - "other_data": { - "session": { - "tracing": { - "workflow_name": "test_workflow", - "group_id": "group_123", - } - } - }, - } + from openai.types.beta.realtime.session_update_event import ( + SessionTracingTracingConfiguration, + SessionUpdateEvent, ) - mock_send_raw_message.assert_called_once_with(expected_event) + + mock_send_raw_message.assert_called_once() + call_args = mock_send_raw_message.call_args[0][0] + assert isinstance(call_args, SessionUpdateEvent) + assert call_args.type == "session.update" + assert isinstance(call_args.session.tracing, SessionTracingTracingConfiguration) + assert call_args.session.tracing.workflow_name == "test_workflow" + assert call_args.session.tracing.group_id == "group_123" @pytest.mark.asyncio async def test_send_tracing_config_auto_mode(self, model, mock_websocket): @@ -144,15 +140,13 @@ async def async_websocket(*args, **kwargs): await model._handle_ws_event(session_created_event) # Should send session.update with "auto" - from agents.realtime.model_inputs import RealtimeModelSendRawMessage + from openai.types.beta.realtime.session_update_event import SessionUpdateEvent - expected_event = RealtimeModelSendRawMessage( - message={ - "type": "session.update", - "other_data": {"session": {"tracing": "auto"}}, - } - ) - mock_send_raw_message.assert_called_once_with(expected_event) + mock_send_raw_message.assert_called_once() + call_args = mock_send_raw_message.call_args[0][0] + assert isinstance(call_args, SessionUpdateEvent) + assert call_args.type == "session.update" + assert call_args.session.tracing == "auto" @pytest.mark.asyncio async def test_tracing_config_none_skips_session_update(self, model, mock_websocket): @@ -209,22 +203,18 @@ async def async_websocket(*args, **kwargs): await model._handle_ws_event(session_created_event) # Should send session.update with complete tracing config including metadata - from agents.realtime.model_inputs import RealtimeModelSendRawMessage - - expected_event = RealtimeModelSendRawMessage( - message={ - "type": "session.update", - "other_data": { - "session": { - "tracing": { - "workflow_name": "complex_workflow", - "metadata": complex_metadata, - } - } - }, - } + from openai.types.beta.realtime.session_update_event import ( + SessionTracingTracingConfiguration, + SessionUpdateEvent, ) - mock_send_raw_message.assert_called_once_with(expected_event) + + mock_send_raw_message.assert_called_once() + call_args = mock_send_raw_message.call_args[0][0] + assert isinstance(call_args, SessionUpdateEvent) + assert call_args.type == "session.update" + assert isinstance(call_args.session.tracing, SessionTracingTracingConfiguration) + assert call_args.session.tracing.workflow_name == "complex_workflow" + assert call_args.session.tracing.metadata == complex_metadata @pytest.mark.asyncio async def test_tracing_disabled_prevents_tracing(self, mock_websocket): diff --git a/uv.lock b/uv.lock index 7d0621d88..3bff55bf9 100644 --- a/uv.lock +++ b/uv.lock @@ -1461,7 +1461,7 @@ wheels = [ [[package]] name = "openai" -version = "1.93.1" +version = "1.96.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1473,9 +1473,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/a8/e4427729da048cb33bda15e70f09f7520bdf3577bafc546b135ecb36af7d/openai-1.93.1.tar.gz", hash = "sha256:11eb8932965d0f79ecc4cb38a60a0c4cef4bcd5fcf08b99fc9a399fa5f1e50ab", size = 487124, upload-time = "2025-07-07T16:40:38.389Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/7d/dbc636786f8bf029600abdbf89da74706bdde37c4fe1471ce78834a7ecaa/openai-1.96.0.tar.gz", hash = "sha256:36e34b5aa2c9c0380c1934fa16ba53b3b3c6462450b4c008b98859b9b6424cf7", size = 488898, upload-time = "2025-07-15T15:56:52.853Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/4f/875e5af1fb4e5ed4ea9e4a88f482d9ca2e48932105605b6c516e9a14de25/openai-1.93.1-py3-none-any.whl", hash = "sha256:a2c2946c4f21346d4902311a7440381fd8a33466ee7ca688133d1cad29a9357c", size = 755081, upload-time = "2025-07-07T16:40:36.585Z" }, + { url = "https://files.pythonhosted.org/packages/dc/63/e29319a52449b7ac4c3a13a1448a9fa326aa1263478eb4c4a9bcbbe95648/openai-1.96.0-py3-none-any.whl", hash = "sha256:4dee023520f8a70ddeaa9f4d6fb247e6dcafd79d2ebb415e3f85932d95aa64a0", size = 757092, upload-time = "2025-07-15T15:56:50.387Z" }, ] [[package]] @@ -1539,7 +1539,7 @@ requires-dist = [ { name = "litellm", marker = "extra == 'litellm'", specifier = ">=1.67.4.post1,<2" }, { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.9.4,<2" }, { name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" }, - { name = "openai", specifier = ">=1.93.1,<2" }, + { name = "openai", specifier = ">=1.96.0,<2" }, { name = "pydantic", specifier = ">=2.10,<3" }, { name = "requests", specifier = ">=2.0,<3" }, { name = "types-requests", specifier = ">=2.0,<3" },