openai-agents 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of openai-agents might be problematic. Click here for more details.

@@ -51,6 +51,8 @@ from ..model_settings import MCPToolChoice
51
51
  from ..tool import FunctionTool, Tool
52
52
  from .fake_id import FAKE_RESPONSES_ID
53
53
 
54
+ ResponseInputContentWithAudioParam = Union[ResponseInputContentParam, ResponseInputAudioParam]
55
+
54
56
 
55
57
  class Converter:
56
58
  @classmethod
@@ -136,7 +138,9 @@ class Converter:
136
138
  )
137
139
  if message.content:
138
140
  message_item.content.append(
139
- ResponseOutputText(text=message.content, type="output_text", annotations=[])
141
+ ResponseOutputText(
142
+ text=message.content, type="output_text", annotations=[], logprobs=[]
143
+ )
140
144
  )
141
145
  if message.refusal:
142
146
  message_item.content.append(
@@ -246,7 +250,7 @@ class Converter:
246
250
 
247
251
  @classmethod
248
252
  def extract_text_content(
249
- cls, content: str | Iterable[ResponseInputContentParam]
253
+ cls, content: str | Iterable[ResponseInputContentWithAudioParam]
250
254
  ) -> str | list[ChatCompletionContentPartTextParam]:
251
255
  all_content = cls.extract_all_content(content)
252
256
  if isinstance(all_content, str):
@@ -259,7 +263,7 @@ class Converter:
259
263
 
260
264
  @classmethod
261
265
  def extract_all_content(
262
- cls, content: str | Iterable[ResponseInputContentParam]
266
+ cls, content: str | Iterable[ResponseInputContentWithAudioParam]
263
267
  ) -> str | list[ChatCompletionContentPartParam]:
264
268
  if isinstance(content, str):
265
269
  return content
@@ -535,7 +539,7 @@ class Converter:
535
539
  elif func_output := cls.maybe_function_tool_call_output(item):
536
540
  flush_assistant_message()
537
541
  output_content = cast(
538
- Union[str, Iterable[ResponseInputContentParam]], func_output["output"]
542
+ Union[str, Iterable[ResponseInputContentWithAudioParam]], func_output["output"]
539
543
  )
540
544
  msg: ChatCompletionToolMessageParam = {
541
545
  "role": "tool",
@@ -150,6 +150,12 @@ class ChatCmplStreamHandler:
150
150
  )
151
151
 
152
152
  if reasoning_content and state.reasoning_content_index_and_output:
153
+ # Ensure summary list has at least one element
154
+ if not state.reasoning_content_index_and_output[1].summary:
155
+ state.reasoning_content_index_and_output[1].summary = [
156
+ Summary(text="", type="summary_text")
157
+ ]
158
+
153
159
  yield ResponseReasoningSummaryTextDeltaEvent(
154
160
  delta=reasoning_content,
155
161
  item_id=FAKE_RESPONSES_ID,
@@ -201,7 +207,7 @@ class ChatCmplStreamHandler:
201
207
  )
202
208
 
203
209
  # Create a new summary with updated text
204
- if state.reasoning_content_index_and_output[1].content is None:
210
+ if not state.reasoning_content_index_and_output[1].content:
205
211
  state.reasoning_content_index_and_output[1].content = [
206
212
  Content(text="", type="reasoning_text")
207
213
  ]
@@ -225,6 +231,7 @@ class ChatCmplStreamHandler:
225
231
  text="",
226
232
  type="output_text",
227
233
  annotations=[],
234
+ logprobs=[],
228
235
  ),
229
236
  )
230
237
  # Start a new assistant message stream
@@ -252,6 +259,7 @@ class ChatCmplStreamHandler:
252
259
  text="",
253
260
  type="output_text",
254
261
  annotations=[],
262
+ logprobs=[],
255
263
  ),
256
264
  type="response.content_part.added",
257
265
  sequence_number=sequence_number.get_and_increment(),
@@ -303,12 +311,10 @@ class ChatCmplStreamHandler:
303
311
  yield ResponseContentPartAddedEvent(
304
312
  content_index=state.refusal_content_index_and_output[0],
305
313
  item_id=FAKE_RESPONSES_ID,
306
- output_index=state.reasoning_content_index_and_output
307
- is not None, # fixed 0 -> 0 or 1
308
- part=ResponseOutputText(
309
- text="",
310
- type="output_text",
311
- annotations=[],
314
+ output_index=(1 if state.reasoning_content_index_and_output else 0),
315
+ part=ResponseOutputRefusal(
316
+ refusal="",
317
+ type="refusal",
312
318
  ),
313
319
  type="response.content_part.added",
314
320
  sequence_number=sequence_number.get_and_increment(),
agents/realtime/config.py CHANGED
@@ -184,6 +184,9 @@ class RealtimeRunConfig(TypedDict):
184
184
  tracing_disabled: NotRequired[bool]
185
185
  """Whether tracing is disabled for this run."""
186
186
 
187
+ async_tool_calls: NotRequired[bool]
188
+ """Whether function tool calls should run asynchronously. Defaults to True."""
189
+
187
190
  # TODO (rm) Add history audio storage config
188
191
 
189
192
 
agents/realtime/events.py CHANGED
@@ -69,6 +69,10 @@ class RealtimeToolStart:
69
69
  """The agent that updated."""
70
70
 
71
71
  tool: Tool
72
+ """The tool being called."""
73
+
74
+ arguments: str
75
+ """The arguments passed to the tool as a JSON string."""
72
76
 
73
77
  info: RealtimeEventInfo
74
78
  """Common info for all events, such as the context."""
@@ -86,6 +90,9 @@ class RealtimeToolEnd:
86
90
  tool: Tool
87
91
  """The tool that was called."""
88
92
 
93
+ arguments: str
94
+ """The arguments passed to the tool as a JSON string."""
95
+
89
96
  output: Any
90
97
  """The output of the tool call."""
91
98
 
agents/realtime/model.py CHANGED
@@ -139,6 +139,13 @@ class RealtimeModelConfig(TypedDict):
139
139
  is played to the user.
140
140
  """
141
141
 
142
+ call_id: NotRequired[str]
143
+ """Attach to an existing realtime call instead of creating a new session.
144
+
145
+ When provided, the transport connects using the `call_id` query string parameter rather than a
146
+ model name. This is used for SIP-originated calls that are accepted via the Realtime Calls API.
147
+ """
148
+
142
149
 
143
150
  class RealtimeModel(abc.ABC):
144
151
  """Interface for connecting to a realtime model and sending/receiving events."""
@@ -95,6 +95,9 @@ class RealtimeModelSendToolOutput:
95
95
  class RealtimeModelSendInterrupt:
96
96
  """Send an interrupt to the model."""
97
97
 
98
+ force_response_cancel: bool = False
99
+ """Force sending a response.cancel event even if automatic cancellation is enabled."""
100
+
98
101
 
99
102
  @dataclass
100
103
  class RealtimeModelSendSessionUpdate:
@@ -208,7 +208,18 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
208
208
 
209
209
  self._playback_tracker = options.get("playback_tracker", None)
210
210
 
211
- self.model = model_settings.get("model_name", self.model)
211
+ call_id = options.get("call_id")
212
+ model_name = model_settings.get("model_name")
213
+ if call_id and model_name:
214
+ error_message = (
215
+ "Cannot specify both `call_id` and `model_name` "
216
+ "when attaching to an existing realtime call."
217
+ )
218
+ raise UserError(error_message)
219
+
220
+ if model_name:
221
+ self.model = model_name
222
+
212
223
  api_key = await get_api_key(options.get("api_key"))
213
224
 
214
225
  if "tracing" in model_settings:
@@ -216,7 +227,10 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
216
227
  else:
217
228
  self._tracing_config = "auto"
218
229
 
219
- url = options.get("url", f"wss://api.openai.com/v1/realtime?model={self.model}")
230
+ if call_id:
231
+ url = options.get("url", f"wss://api.openai.com/v1/realtime?call_id={call_id}")
232
+ else:
233
+ url = options.get("url", f"wss://api.openai.com/v1/realtime?model={self.model}")
220
234
 
221
235
  headers: dict[str, str] = {}
222
236
  if options.get("headers") is not None:
@@ -266,7 +280,8 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
266
280
 
267
281
  async def _emit_event(self, event: RealtimeModelEvent) -> None:
268
282
  """Emit an event to the listeners."""
269
- for listener in self._listeners:
283
+ # Copy list to avoid modification during iteration
284
+ for listener in list(self._listeners):
270
285
  await listener.on_event(event)
271
286
 
272
287
  async def _listen_for_messages(self):
@@ -394,6 +409,7 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
394
409
  current_item_id = playback_state.get("current_item_id")
395
410
  current_item_content_index = playback_state.get("current_item_content_index")
396
411
  elapsed_ms = playback_state.get("elapsed_ms")
412
+
397
413
  if current_item_id is None or elapsed_ms is None:
398
414
  logger.debug(
399
415
  "Skipping interrupt. "
@@ -401,29 +417,28 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
401
417
  f"elapsed ms: {elapsed_ms}, "
402
418
  f"content index: {current_item_content_index}"
403
419
  )
404
- return
405
-
406
- current_item_content_index = current_item_content_index or 0
407
- if elapsed_ms > 0:
408
- await self._emit_event(
409
- RealtimeModelAudioInterruptedEvent(
410
- item_id=current_item_id,
411
- content_index=current_item_content_index,
412
- )
413
- )
414
- converted = _ConversionHelper.convert_interrupt(
415
- current_item_id,
416
- current_item_content_index,
417
- int(elapsed_ms),
418
- )
419
- await self._send_raw_message(converted)
420
420
  else:
421
- logger.debug(
422
- "Didn't interrupt bc elapsed ms is < 0. "
423
- f"Item id: {current_item_id}, "
424
- f"elapsed ms: {elapsed_ms}, "
425
- f"content index: {current_item_content_index}"
426
- )
421
+ current_item_content_index = current_item_content_index or 0
422
+ if elapsed_ms > 0:
423
+ await self._emit_event(
424
+ RealtimeModelAudioInterruptedEvent(
425
+ item_id=current_item_id,
426
+ content_index=current_item_content_index,
427
+ )
428
+ )
429
+ converted = _ConversionHelper.convert_interrupt(
430
+ current_item_id,
431
+ current_item_content_index,
432
+ int(elapsed_ms),
433
+ )
434
+ await self._send_raw_message(converted)
435
+ else:
436
+ logger.debug(
437
+ "Didn't interrupt bc elapsed ms is < 0. "
438
+ f"Item id: {current_item_id}, "
439
+ f"elapsed ms: {elapsed_ms}, "
440
+ f"content index: {current_item_content_index}"
441
+ )
427
442
 
428
443
  session = self._created_session
429
444
  automatic_response_cancellation_enabled = (
@@ -431,14 +446,18 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
431
446
  and session.audio is not None
432
447
  and session.audio.input is not None
433
448
  and session.audio.input.turn_detection is not None
434
- and session.audio.input.turn_detection.interrupt_response is True,
449
+ and session.audio.input.turn_detection.interrupt_response is True
435
450
  )
436
- if not automatic_response_cancellation_enabled:
451
+ should_cancel_response = event.force_response_cancel or (
452
+ not automatic_response_cancellation_enabled
453
+ )
454
+ if should_cancel_response:
437
455
  await self._cancel_response()
438
456
 
439
- self._audio_state_tracker.on_interrupted()
440
- if self._playback_tracker:
441
- self._playback_tracker.on_interrupted()
457
+ if current_item_id is not None and elapsed_ms is not None:
458
+ self._audio_state_tracker.on_interrupted()
459
+ if self._playback_tracker:
460
+ self._playback_tracker.on_interrupted()
442
461
 
443
462
  async def _send_session_update(self, event: RealtimeModelSendSessionUpdate) -> None:
444
463
  """Send a session update to the model."""
@@ -516,6 +535,10 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
516
535
  self._websocket = None
517
536
  if self._websocket_task:
518
537
  self._websocket_task.cancel()
538
+ try:
539
+ await self._websocket_task
540
+ except asyncio.CancelledError:
541
+ pass
519
542
  self._websocket_task = None
520
543
 
521
544
  async def _cancel_response(self) -> None:
@@ -616,12 +639,13 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
616
639
  and session.audio is not None
617
640
  and session.audio.input is not None
618
641
  and session.audio.input.turn_detection is not None
619
- and session.audio.input.turn_detection.interrupt_response is True,
642
+ and session.audio.input.turn_detection.interrupt_response is True
620
643
  )
621
644
  if not automatic_response_cancellation_enabled:
622
645
  await self._cancel_response()
623
- # Avoid sending conversation.item.truncate here; when GA is set to
624
- # interrupt on VAD start, the server will handle truncation.
646
+ # Avoid sending conversation.item.truncate here. When the session's
647
+ # turn_detection.interrupt_response is enabled (GA default), the server emits
648
+ # conversation.item.truncated after the VAD start and takes care of history updates.
625
649
  elif parsed.type == "response.created":
626
650
  self._ongoing_response = True
627
651
  await self._emit_event(RealtimeModelTurnStartedEvent())
@@ -920,6 +944,18 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
920
944
  return converted_tools
921
945
 
922
946
 
947
+ class OpenAIRealtimeSIPModel(OpenAIRealtimeWebSocketModel):
948
+ """Realtime model that attaches to SIP-originated calls using a call ID."""
949
+
950
+ async def connect(self, options: RealtimeModelConfig) -> None:
951
+ call_id = options.get("call_id")
952
+ if not call_id:
953
+ raise UserError("OpenAIRealtimeSIPModel requires `call_id` in the model configuration.")
954
+
955
+ sip_options = options.copy()
956
+ await super().connect(sip_options)
957
+
958
+
923
959
  class _ConversionHelper:
924
960
  @classmethod
925
961
  def conversation_item_to_realtime_message_item(
@@ -112,7 +112,7 @@ class RealtimeSession(RealtimeModelListener):
112
112
  }
113
113
  self._event_queue: asyncio.Queue[RealtimeSessionEvent] = asyncio.Queue()
114
114
  self._closed = False
115
- self._stored_exception: Exception | None = None
115
+ self._stored_exception: BaseException | None = None
116
116
 
117
117
  # Guardrails state tracking
118
118
  self._interrupted_response_ids: set[str] = set()
@@ -123,6 +123,8 @@ class RealtimeSession(RealtimeModelListener):
123
123
  )
124
124
 
125
125
  self._guardrail_tasks: set[asyncio.Task[Any]] = set()
126
+ self._tool_call_tasks: set[asyncio.Task[Any]] = set()
127
+ self._async_tool_calls: bool = bool(self._run_config.get("async_tool_calls", True))
126
128
 
127
129
  @property
128
130
  def model(self) -> RealtimeModel:
@@ -216,7 +218,11 @@ class RealtimeSession(RealtimeModelListener):
216
218
  if event.type == "error":
217
219
  await self._put_event(RealtimeError(info=self._event_info, error=event.error))
218
220
  elif event.type == "function_call":
219
- await self._handle_tool_call(event)
221
+ agent_snapshot = self._current_agent
222
+ if self._async_tool_calls:
223
+ self._enqueue_tool_call_task(event, agent_snapshot)
224
+ else:
225
+ await self._handle_tool_call(event, agent_snapshot=agent_snapshot)
220
226
  elif event.type == "audio":
221
227
  await self._put_event(
222
228
  RealtimeAudio(
@@ -384,11 +390,17 @@ class RealtimeSession(RealtimeModelListener):
384
390
  """Put an event into the queue."""
385
391
  await self._event_queue.put(event)
386
392
 
387
- async def _handle_tool_call(self, event: RealtimeModelToolCallEvent) -> None:
393
+ async def _handle_tool_call(
394
+ self,
395
+ event: RealtimeModelToolCallEvent,
396
+ *,
397
+ agent_snapshot: RealtimeAgent | None = None,
398
+ ) -> None:
388
399
  """Handle a tool call event."""
400
+ agent = agent_snapshot or self._current_agent
389
401
  tools, handoffs = await asyncio.gather(
390
- self._current_agent.get_all_tools(self._context_wrapper),
391
- self._get_handoffs(self._current_agent, self._context_wrapper),
402
+ agent.get_all_tools(self._context_wrapper),
403
+ self._get_handoffs(agent, self._context_wrapper),
392
404
  )
393
405
  function_map = {tool.name: tool for tool in tools if isinstance(tool, FunctionTool)}
394
406
  handoff_map = {handoff.tool_name: handoff for handoff in handoffs}
@@ -398,7 +410,8 @@ class RealtimeSession(RealtimeModelListener):
398
410
  RealtimeToolStart(
399
411
  info=self._event_info,
400
412
  tool=function_map[event.name],
401
- agent=self._current_agent,
413
+ agent=agent,
414
+ arguments=event.arguments,
402
415
  )
403
416
  )
404
417
 
@@ -423,7 +436,8 @@ class RealtimeSession(RealtimeModelListener):
423
436
  info=self._event_info,
424
437
  tool=func_tool,
425
438
  output=result,
426
- agent=self._current_agent,
439
+ agent=agent,
440
+ arguments=event.arguments,
427
441
  )
428
442
  )
429
443
  elif event.name in handoff_map:
@@ -444,7 +458,7 @@ class RealtimeSession(RealtimeModelListener):
444
458
  )
445
459
 
446
460
  # Store previous agent for event
447
- previous_agent = self._current_agent
461
+ previous_agent = agent
448
462
 
449
463
  # Update current agent
450
464
  self._current_agent = result
@@ -704,7 +718,7 @@ class RealtimeSession(RealtimeModelListener):
704
718
  )
705
719
 
706
720
  # Interrupt the model
707
- await self._model.send_event(RealtimeModelSendInterrupt())
721
+ await self._model.send_event(RealtimeModelSendInterrupt(force_response_cancel=True))
708
722
 
709
723
  # Send guardrail triggered message
710
724
  guardrail_names = [result.guardrail.get_name() for result in triggered_results]
@@ -752,10 +766,49 @@ class RealtimeSession(RealtimeModelListener):
752
766
  task.cancel()
753
767
  self._guardrail_tasks.clear()
754
768
 
769
+ def _enqueue_tool_call_task(
770
+ self, event: RealtimeModelToolCallEvent, agent_snapshot: RealtimeAgent
771
+ ) -> None:
772
+ """Run tool calls in the background to avoid blocking realtime transport."""
773
+ task = asyncio.create_task(self._handle_tool_call(event, agent_snapshot=agent_snapshot))
774
+ self._tool_call_tasks.add(task)
775
+ task.add_done_callback(self._on_tool_call_task_done)
776
+
777
+ def _on_tool_call_task_done(self, task: asyncio.Task[Any]) -> None:
778
+ self._tool_call_tasks.discard(task)
779
+
780
+ if task.cancelled():
781
+ return
782
+
783
+ exception = task.exception()
784
+ if exception is None:
785
+ return
786
+
787
+ logger.exception("Realtime tool call task failed", exc_info=exception)
788
+
789
+ if self._stored_exception is None:
790
+ self._stored_exception = exception
791
+
792
+ asyncio.create_task(
793
+ self._put_event(
794
+ RealtimeError(
795
+ info=self._event_info,
796
+ error={"message": f"Tool call task failed: {exception}"},
797
+ )
798
+ )
799
+ )
800
+
801
+ def _cleanup_tool_call_tasks(self) -> None:
802
+ for task in self._tool_call_tasks:
803
+ if not task.done():
804
+ task.cancel()
805
+ self._tool_call_tasks.clear()
806
+
755
807
  async def _cleanup(self) -> None:
756
808
  """Clean up all resources and mark session as closed."""
757
809
  # Cancel and cleanup guardrail tasks
758
810
  self._cleanup_guardrail_tasks()
811
+ self._cleanup_tool_call_tasks()
759
812
 
760
813
  # Remove ourselves as a listener
761
814
  self._model.remove_listener(self)
agents/run.py CHANGED
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
4
5
  import inspect
5
6
  import os
7
+ import warnings
6
8
  from dataclasses import dataclass, field
7
9
  from typing import Any, Callable, Generic, cast, get_args
8
10
 
@@ -720,7 +722,40 @@ class AgentRunner:
720
722
  conversation_id = kwargs.get("conversation_id")
721
723
  session = kwargs.get("session")
722
724
 
723
- return asyncio.get_event_loop().run_until_complete(
725
+ # Python 3.14 stopped implicitly wiring up a default event loop
726
+ # when synchronous code touches asyncio APIs for the first time.
727
+ # Several of our synchronous entry points (for example the Redis/SQLAlchemy session helpers)
728
+ # construct asyncio primitives like asyncio.Lock during __init__,
729
+ # which binds them to whatever loop happens to be the thread's default at that moment.
730
+ # To keep those locks usable we must ensure that run_sync reuses that same default loop
731
+ # instead of hopping over to a brand-new asyncio.run() loop.
732
+ try:
733
+ already_running_loop = asyncio.get_running_loop()
734
+ except RuntimeError:
735
+ already_running_loop = None
736
+
737
+ if already_running_loop is not None:
738
+ # This method is only expected to run when no loop is already active.
739
+ # (Each thread has its own default loop; concurrent sync runs should happen on
740
+ # different threads. In a single thread use the async API to interleave work.)
741
+ raise RuntimeError(
742
+ "AgentRunner.run_sync() cannot be called when an event loop is already running."
743
+ )
744
+
745
+ policy = asyncio.get_event_loop_policy()
746
+ with warnings.catch_warnings():
747
+ warnings.simplefilter("ignore", DeprecationWarning)
748
+ try:
749
+ default_loop = policy.get_event_loop()
750
+ except RuntimeError:
751
+ default_loop = policy.new_event_loop()
752
+ policy.set_event_loop(default_loop)
753
+
754
+ # We intentionally leave the default loop open even if we had to create one above. Session
755
+ # instances and other helpers stash loop-bound primitives between calls and expect to find
756
+ # the same default loop every time run_sync is invoked on this thread.
757
+ # Schedule the async run on the default loop so that we can manage cancellation explicitly.
758
+ task = default_loop.create_task(
724
759
  self.run(
725
760
  starting_agent,
726
761
  input,
@@ -734,6 +769,24 @@ class AgentRunner:
734
769
  )
735
770
  )
736
771
 
772
+ try:
773
+ # Drive the coroutine to completion, harvesting the final RunResult.
774
+ return default_loop.run_until_complete(task)
775
+ except BaseException:
776
+ # If the sync caller aborts (KeyboardInterrupt, etc.), make sure the scheduled task
777
+ # does not linger on the shared loop by cancelling it and waiting for completion.
778
+ if not task.done():
779
+ task.cancel()
780
+ with contextlib.suppress(asyncio.CancelledError):
781
+ default_loop.run_until_complete(task)
782
+ raise
783
+ finally:
784
+ if not default_loop.is_closed():
785
+ # The loop stays open for subsequent runs, but we still need to flush any pending
786
+ # async generators so their cleanup code executes promptly.
787
+ with contextlib.suppress(RuntimeError):
788
+ default_loop.run_until_complete(default_loop.shutdown_asyncgens())
789
+
737
790
  def run_streamed(
738
791
  self,
739
792
  starting_agent: Agent[TContext],
@@ -1138,6 +1191,15 @@ class AgentRunner:
1138
1191
 
1139
1192
  streamed_result.is_complete = True
1140
1193
  finally:
1194
+ if streamed_result._input_guardrails_task:
1195
+ try:
1196
+ await AgentRunner._input_guardrail_tripwire_triggered_for_stream(
1197
+ streamed_result
1198
+ )
1199
+ except Exception as e:
1200
+ logger.debug(
1201
+ f"Error in streamed_result finalize for agent {current_agent.name} - {e}"
1202
+ )
1141
1203
  if current_span:
1142
1204
  current_span.finish(reset_current=True)
1143
1205
  if streamed_result.trace:
agents/stream_events.py CHANGED
@@ -37,6 +37,7 @@ class RunItemStreamEvent:
37
37
  "tool_output",
38
38
  "reasoning_item_created",
39
39
  "mcp_approval_requested",
40
+ "mcp_approval_response",
40
41
  "mcp_list_tools",
41
42
  ]
42
43
  """The name of the event."""
agents/tool.py CHANGED
@@ -15,7 +15,7 @@ from openai.types.responses.response_output_item import LocalShellCall, McpAppro
15
15
  from openai.types.responses.tool_param import CodeInterpreter, ImageGeneration, Mcp
16
16
  from openai.types.responses.web_search_tool import Filters as WebSearchToolFilters
17
17
  from openai.types.responses.web_search_tool_param import UserLocation
18
- from pydantic import BaseModel, TypeAdapter, ValidationError
18
+ from pydantic import BaseModel, TypeAdapter, ValidationError, model_validator
19
19
  from typing_extensions import Concatenate, NotRequired, ParamSpec, TypedDict
20
20
 
21
21
  from . import _debug
@@ -75,6 +75,13 @@ class ToolOutputImage(BaseModel):
75
75
  file_id: str | None = None
76
76
  detail: Literal["low", "high", "auto"] | None = None
77
77
 
78
+ @model_validator(mode="after")
79
+ def check_at_least_one_required_field(self) -> ToolOutputImage:
80
+ """Validate that at least one of image_url or file_id is provided."""
81
+ if self.image_url is None and self.file_id is None:
82
+ raise ValueError("At least one of image_url or file_id must be provided")
83
+ return self
84
+
78
85
 
79
86
  class ToolOutputImageDict(TypedDict, total=False):
80
87
  """TypedDict variant for image tool outputs."""
@@ -98,6 +105,13 @@ class ToolOutputFileContent(BaseModel):
98
105
  file_id: str | None = None
99
106
  filename: str | None = None
100
107
 
108
+ @model_validator(mode="after")
109
+ def check_at_least_one_required_field(self) -> ToolOutputFileContent:
110
+ """Validate that at least one of file_data, file_url, or file_id is provided."""
111
+ if self.file_data is None and self.file_url is None and self.file_id is None:
112
+ raise ValueError("At least one of file_data, file_url, or file_id must be provided")
113
+ return self
114
+
101
115
 
102
116
  class ToolOutputFileContentDict(TypedDict, total=False):
103
117
  """TypedDict variant for file content tool outputs."""