chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from openai.types.realtime.realtime_conversation_item_user_message import (
|
||||
RealtimeConversationItemUserMessage,
|
||||
)
|
||||
from openai.types.realtime.realtime_response_usage import RealtimeResponseUsage
|
||||
from openai.types.realtime.realtime_tracing_config import (
|
||||
TracingConfiguration,
|
||||
)
|
||||
|
||||
from agents import Agent, function_tool, tool_namespace
|
||||
from agents.exceptions import UserError
|
||||
from agents.handoffs import handoff
|
||||
from agents.realtime.config import RealtimeModelTracingConfig
|
||||
from agents.realtime.model_inputs import (
|
||||
RealtimeModelSendRawMessage,
|
||||
RealtimeModelSendUserInput,
|
||||
RealtimeModelUserInputMessage,
|
||||
)
|
||||
from agents.realtime.openai_realtime import (
|
||||
OpenAIRealtimeWebSocketModel,
|
||||
_ConversionHelper,
|
||||
get_api_key,
|
||||
)
|
||||
from agents.tool import Tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_api_key_from_env(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "env-key")
|
||||
assert await get_api_key(None) == "env-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_api_key_from_callable_async():
|
||||
async def f():
|
||||
return "k"
|
||||
|
||||
assert await get_api_key(f) == "k"
|
||||
|
||||
|
||||
def test_try_convert_raw_message_invalid_returns_none():
|
||||
msg = RealtimeModelSendRawMessage(message={"type": "invalid.event", "other_data": {}})
|
||||
assert _ConversionHelper.try_convert_raw_message(msg) is None
|
||||
|
||||
|
||||
def test_convert_response_usage_preserves_modality_details():
|
||||
event = _ConversionHelper.convert_response_usage(
|
||||
RealtimeResponseUsage.model_validate(
|
||||
{
|
||||
"total_tokens": 253,
|
||||
"input_tokens": 132,
|
||||
"output_tokens": 121,
|
||||
"input_token_details": {
|
||||
"text_tokens": 119,
|
||||
"audio_tokens": 13,
|
||||
"image_tokens": 0,
|
||||
"cached_tokens": 64,
|
||||
"cached_tokens_details": {
|
||||
"text_tokens": 60,
|
||||
"audio_tokens": 4,
|
||||
"image_tokens": 0,
|
||||
},
|
||||
},
|
||||
"output_token_details": {"text_tokens": 30, "audio_tokens": 91},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert event.usage.requests == 1
|
||||
assert event.usage.input_tokens == 132
|
||||
assert event.usage.output_tokens == 121
|
||||
assert event.usage.total_tokens == 253
|
||||
assert event.usage.input_tokens_details.cached_tokens == 64
|
||||
assert event.input_tokens_details is not None
|
||||
assert event.input_tokens_details.text_tokens == 119
|
||||
assert event.input_tokens_details.audio_tokens == 13
|
||||
assert event.input_tokens_details.image_tokens == 0
|
||||
assert event.input_tokens_details.cached_tokens == 64
|
||||
assert event.input_tokens_details.cached_tokens_details is not None
|
||||
assert event.input_tokens_details.cached_tokens_details.text_tokens == 60
|
||||
assert event.input_tokens_details.cached_tokens_details.audio_tokens == 4
|
||||
assert event.input_tokens_details.cached_tokens_details.image_tokens == 0
|
||||
assert event.output_tokens_details is not None
|
||||
assert event.output_tokens_details.text_tokens == 30
|
||||
assert event.output_tokens_details.audio_tokens == 91
|
||||
|
||||
|
||||
def test_convert_response_usage_preserves_missing_details_and_derives_total():
|
||||
event = _ConversionHelper.convert_response_usage(
|
||||
RealtimeResponseUsage.model_validate(
|
||||
{
|
||||
"input_tokens": 12,
|
||||
"output_tokens": 3,
|
||||
"input_token_details": {"audio_tokens": 0},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert event.usage.total_tokens == 15
|
||||
assert event.input_tokens_details is not None
|
||||
assert event.input_tokens_details.audio_tokens == 0
|
||||
assert event.input_tokens_details.text_tokens is None
|
||||
assert event.input_tokens_details.cached_tokens is None
|
||||
assert event.input_tokens_details.cached_tokens_details is None
|
||||
assert event.output_tokens_details is None
|
||||
|
||||
|
||||
def test_convert_user_input_to_conversation_item_dict_and_str():
|
||||
# Dict with mixed, including unknown parts (silently skipped)
|
||||
dict_input_any = {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "hello"},
|
||||
{"type": "input_image", "image_url": "http://x/y.png", "detail": "auto"},
|
||||
{"type": "bogus", "x": 1},
|
||||
],
|
||||
}
|
||||
event = RealtimeModelSendUserInput(
|
||||
user_input=cast(RealtimeModelUserInputMessage, dict_input_any)
|
||||
)
|
||||
item_any = _ConversionHelper.convert_user_input_to_conversation_item(event)
|
||||
item = cast(RealtimeConversationItemUserMessage, item_any)
|
||||
assert item.role == "user"
|
||||
|
||||
# String input becomes input_text
|
||||
event2 = RealtimeModelSendUserInput(user_input="hi")
|
||||
item2_any = _ConversionHelper.convert_user_input_to_conversation_item(event2)
|
||||
item2 = cast(RealtimeConversationItemUserMessage, item2_any)
|
||||
assert item2.content[0].type == "input_text"
|
||||
|
||||
|
||||
def test_convert_user_input_dict_skips_invalid_input_text_parts():
|
||||
"""input_text parts with missing/non-string text must be skipped, not
|
||||
forwarded as Content(text=None) which the realtime API rejects."""
|
||||
dict_input_any = {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text"}, # missing text
|
||||
{"type": "input_text", "text": 123}, # non-string text
|
||||
{"type": "input_text", "text": "ok"}, # valid
|
||||
],
|
||||
}
|
||||
event = RealtimeModelSendUserInput(
|
||||
user_input=cast(RealtimeModelUserInputMessage, dict_input_any)
|
||||
)
|
||||
item = cast(
|
||||
RealtimeConversationItemUserMessage,
|
||||
_ConversionHelper.convert_user_input_to_conversation_item(event),
|
||||
)
|
||||
assert item.content is not None
|
||||
assert len(item.content) == 1
|
||||
assert item.content[0].type == "input_text"
|
||||
assert item.content[0].text == "ok"
|
||||
|
||||
|
||||
def test_convert_tracing_config_variants():
|
||||
from agents.realtime.openai_realtime import _ConversionHelper as CH
|
||||
|
||||
assert CH.convert_tracing_config(None) is None
|
||||
assert CH.convert_tracing_config("auto") == "auto"
|
||||
cfg: RealtimeModelTracingConfig = {
|
||||
"group_id": "g",
|
||||
"metadata": {"k": "v"},
|
||||
"workflow_name": "wf",
|
||||
}
|
||||
oc_any = CH.convert_tracing_config(cfg)
|
||||
oc = cast(TracingConfiguration, oc_any)
|
||||
assert oc.group_id == "g"
|
||||
assert oc.workflow_name == "wf"
|
||||
|
||||
|
||||
def test_tools_to_session_tools_raises_on_non_function_tool():
|
||||
class NotFunctionTool:
|
||||
def __init__(self):
|
||||
self.name = "x"
|
||||
|
||||
m = OpenAIRealtimeWebSocketModel()
|
||||
with pytest.raises(UserError):
|
||||
m._tools_to_session_tools(cast(list[Tool], [NotFunctionTool()]), [])
|
||||
|
||||
|
||||
def test_tools_to_session_tools_includes_handoffs():
|
||||
a = Agent(name="a")
|
||||
h = handoff(a)
|
||||
m = OpenAIRealtimeWebSocketModel()
|
||||
out = m._tools_to_session_tools([], [h])
|
||||
assert out[0].name is not None and out[0].name.startswith("transfer_to_")
|
||||
|
||||
|
||||
def test_tools_to_session_tools_rejects_duplicate_function_tool_names():
|
||||
tool_one = function_tool(lambda: "one", name_override="lookup_account")
|
||||
tool_two = function_tool(lambda: "two", name_override="lookup_account")
|
||||
m = OpenAIRealtimeWebSocketModel()
|
||||
|
||||
with pytest.raises(
|
||||
UserError,
|
||||
match=("Duplicate Realtime tool name found: 'lookup_account' \\(2 function tools\\)"),
|
||||
):
|
||||
m._tools_to_session_tools([tool_one, tool_two], [])
|
||||
|
||||
|
||||
def test_tools_to_session_tools_rejects_function_handoff_name_conflict():
|
||||
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
|
||||
h = handoff(Agent(name="billing"), tool_name_override="transfer_to_billing")
|
||||
m = OpenAIRealtimeWebSocketModel()
|
||||
|
||||
with pytest.raises(
|
||||
UserError,
|
||||
match=(
|
||||
"Duplicate Realtime tool name found: "
|
||||
"'transfer_to_billing' \\(function tool and handoff\\)"
|
||||
),
|
||||
):
|
||||
m._tools_to_session_tools([tool], [h])
|
||||
|
||||
|
||||
def test_tools_to_session_tools_ignores_disabled_function_tool_name_conflict():
|
||||
tool = function_tool(
|
||||
lambda: "ok",
|
||||
name_override="transfer_to_billing",
|
||||
is_enabled=False,
|
||||
)
|
||||
h = handoff(Agent(name="billing"), tool_name_override="transfer_to_billing")
|
||||
m = OpenAIRealtimeWebSocketModel()
|
||||
|
||||
out = m._tools_to_session_tools([tool], [h])
|
||||
|
||||
assert [tool.name for tool in out] == ["transfer_to_billing"]
|
||||
|
||||
|
||||
def test_tools_to_session_tools_omits_disabled_function_tool():
|
||||
tool = function_tool(
|
||||
lambda: "ok",
|
||||
name_override="hidden_tool",
|
||||
is_enabled=False,
|
||||
)
|
||||
m = OpenAIRealtimeWebSocketModel()
|
||||
|
||||
out = m._tools_to_session_tools([tool], [])
|
||||
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_tools_to_session_tools_ignores_disabled_handoff_name_conflict():
|
||||
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
|
||||
h = handoff(
|
||||
Agent(name="billing"),
|
||||
tool_name_override="transfer_to_billing",
|
||||
is_enabled=False,
|
||||
)
|
||||
m = OpenAIRealtimeWebSocketModel()
|
||||
|
||||
out = m._tools_to_session_tools([tool], [h])
|
||||
|
||||
assert [tool.name for tool in out] == ["transfer_to_billing"]
|
||||
|
||||
|
||||
def test_tools_to_session_tools_rejects_duplicate_handoff_names():
|
||||
handoff_one = handoff(Agent(name="billing"), tool_name_override="transfer_to_support")
|
||||
handoff_two = handoff(Agent(name="technical"), tool_name_override="transfer_to_support")
|
||||
m = OpenAIRealtimeWebSocketModel()
|
||||
|
||||
with pytest.raises(
|
||||
UserError,
|
||||
match=("Duplicate Realtime tool name found: 'transfer_to_support' \\(2 handoffs\\)"),
|
||||
):
|
||||
m._tools_to_session_tools([], [handoff_one, handoff_two])
|
||||
|
||||
|
||||
def test_tools_to_session_tools_rejects_namespaced_function_tools():
|
||||
tool = tool_namespace(
|
||||
name="crm",
|
||||
description="CRM tools",
|
||||
tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")],
|
||||
)[0]
|
||||
m = OpenAIRealtimeWebSocketModel()
|
||||
|
||||
with pytest.raises(UserError, match="tool_namespace\\(\\)"):
|
||||
m._tools_to_session_tools([tool], [])
|
||||
|
||||
|
||||
def test_tools_to_session_tools_rejects_deferred_function_tools():
|
||||
tool = function_tool(
|
||||
lambda customer_id: customer_id,
|
||||
name_override="lookup_account",
|
||||
defer_loading=True,
|
||||
)
|
||||
m = OpenAIRealtimeWebSocketModel()
|
||||
|
||||
with pytest.raises(UserError, match="defer_loading=True"):
|
||||
m._tools_to_session_tools([tool], [])
|
||||
Reference in New Issue
Block a user