500 lines
16 KiB
Python
500 lines
16 KiB
Python
import asyncio
|
|
import gc
|
|
import os
|
|
import weakref
|
|
|
|
import openai
|
|
import pytest
|
|
|
|
from agents import (
|
|
UserError,
|
|
responses_websocket_session,
|
|
set_default_openai_api,
|
|
set_default_openai_client,
|
|
set_default_openai_key,
|
|
set_default_openai_responses_transport,
|
|
)
|
|
from agents.models import _openai_shared
|
|
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
|
|
from agents.models.openai_provider import OpenAIProvider
|
|
from agents.models.openai_responses import OpenAIResponsesModel, OpenAIResponsesWSModel
|
|
|
|
|
|
def test_cc_no_default_key_errors(monkeypatch):
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
with pytest.raises(openai.OpenAIError):
|
|
OpenAIProvider(use_responses=False).get_model("gpt-4")
|
|
|
|
|
|
def test_cc_set_default_openai_key():
|
|
set_default_openai_key("test_key")
|
|
chat_model = OpenAIProvider(use_responses=False).get_model("gpt-4")
|
|
assert chat_model._client.api_key == "test_key" # type: ignore
|
|
|
|
|
|
def test_cc_set_default_openai_client():
|
|
client = openai.AsyncOpenAI(api_key="test_key")
|
|
set_default_openai_client(client)
|
|
chat_model = OpenAIProvider(use_responses=False).get_model("gpt-4")
|
|
assert chat_model._client.api_key == "test_key" # type: ignore
|
|
|
|
|
|
def test_resp_no_default_key_errors(monkeypatch):
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
assert os.getenv("OPENAI_API_KEY") is None
|
|
with pytest.raises(openai.OpenAIError):
|
|
OpenAIProvider(use_responses=True).get_model("gpt-4")
|
|
|
|
|
|
def test_resp_set_default_openai_key():
|
|
set_default_openai_key("test_key")
|
|
resp_model = OpenAIProvider(use_responses=True).get_model("gpt-4")
|
|
assert resp_model._client.api_key == "test_key" # type: ignore
|
|
|
|
|
|
def test_resp_set_default_openai_client():
|
|
client = openai.AsyncOpenAI(api_key="test_key")
|
|
set_default_openai_client(client)
|
|
resp_model = OpenAIProvider(use_responses=True).get_model("gpt-4")
|
|
assert resp_model._client.api_key == "test_key" # type: ignore
|
|
|
|
|
|
def test_set_default_openai_api():
|
|
assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIResponsesModel), (
|
|
"Default should be responses"
|
|
)
|
|
|
|
set_default_openai_api("chat_completions")
|
|
assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIChatCompletionsModel), (
|
|
"Should be chat completions model"
|
|
)
|
|
|
|
set_default_openai_api("responses")
|
|
assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIResponsesModel), (
|
|
"Should be responses model"
|
|
)
|
|
|
|
|
|
def test_set_default_openai_responses_transport():
|
|
set_default_openai_api("responses")
|
|
|
|
assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIResponsesModel), (
|
|
"Default responses transport should be HTTP"
|
|
)
|
|
|
|
set_default_openai_responses_transport("websocket")
|
|
assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIResponsesWSModel), (
|
|
"Should be websocket responses model"
|
|
)
|
|
|
|
set_default_openai_responses_transport("http")
|
|
assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIResponsesModel), (
|
|
"Should switch back to HTTP responses model"
|
|
)
|
|
|
|
|
|
def test_set_default_openai_responses_transport_rejects_invalid_value():
|
|
with pytest.raises(ValueError, match="Expected one of: 'http', 'websocket'"):
|
|
set_default_openai_responses_transport("ws") # type: ignore[arg-type]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"conflicting_kwargs",
|
|
[
|
|
{"api_key": "other_key"},
|
|
{"base_url": "https://example.com"},
|
|
{"websocket_base_url": "wss://example.com"},
|
|
{
|
|
"api_key": "other_key",
|
|
"base_url": "https://example.com",
|
|
"websocket_base_url": "wss://example.com",
|
|
},
|
|
],
|
|
)
|
|
def test_openai_provider_rejects_client_with_conflicting_args(conflicting_kwargs):
|
|
# Regression test for #3808: this validation used a bare `assert`, which is
|
|
# stripped under `python -O`, silently ignoring the conflicting arguments.
|
|
client = openai.AsyncOpenAI(api_key="test_key")
|
|
with pytest.raises(UserError, match="Don't provide"):
|
|
OpenAIProvider(openai_client=client, **conflicting_kwargs)
|
|
|
|
|
|
def test_openai_provider_transport_override_beats_default():
|
|
set_default_openai_api("responses")
|
|
set_default_openai_responses_transport("websocket")
|
|
|
|
assert isinstance(
|
|
OpenAIProvider(use_responses=True, use_responses_websocket=False).get_model("gpt-4"),
|
|
OpenAIResponsesModel,
|
|
)
|
|
assert isinstance(
|
|
OpenAIProvider(use_responses=True, use_responses_websocket=True).get_model("gpt-4"),
|
|
OpenAIResponsesWSModel,
|
|
)
|
|
|
|
|
|
def test_legacy_websocket_default_flag_syncs_transport_getter():
|
|
_openai_shared._use_responses_websocket_by_default = True
|
|
assert _openai_shared.get_default_openai_responses_transport() == "websocket"
|
|
|
|
_openai_shared._use_responses_websocket_by_default = False
|
|
assert _openai_shared.get_default_openai_responses_transport() == "http"
|
|
|
|
|
|
def test_openai_provider_uses_base_urls_from_env(monkeypatch):
|
|
captured_kwargs: dict[str, object] = {}
|
|
|
|
class FakeAsyncOpenAI:
|
|
def __init__(self, **kwargs):
|
|
captured_kwargs.update(kwargs)
|
|
self.api_key = kwargs.get("api_key")
|
|
self.base_url = kwargs.get("base_url")
|
|
self.websocket_base_url = kwargs.get("websocket_base_url")
|
|
|
|
monkeypatch.setenv("OPENAI_BASE_URL", "https://proxy.example.test/v1")
|
|
monkeypatch.setenv("OPENAI_WEBSOCKET_BASE_URL", "wss://proxy.example.test/v1")
|
|
monkeypatch.setattr("agents.models.openai_provider.AsyncOpenAI", FakeAsyncOpenAI)
|
|
|
|
model = OpenAIProvider(use_responses=True).get_model("gpt-4")
|
|
assert isinstance(model, OpenAIResponsesModel)
|
|
assert captured_kwargs["base_url"] == "https://proxy.example.test/v1"
|
|
assert captured_kwargs["websocket_base_url"] == "wss://proxy.example.test/v1"
|
|
|
|
|
|
def test_openai_provider_websocket_base_url_arg_overrides_env(monkeypatch):
|
|
captured_kwargs: dict[str, object] = {}
|
|
|
|
class FakeAsyncOpenAI:
|
|
def __init__(self, **kwargs):
|
|
captured_kwargs.update(kwargs)
|
|
self.api_key = kwargs.get("api_key")
|
|
self.base_url = kwargs.get("base_url")
|
|
self.websocket_base_url = kwargs.get("websocket_base_url")
|
|
|
|
monkeypatch.setenv("OPENAI_WEBSOCKET_BASE_URL", "wss://env.example.test/v1")
|
|
monkeypatch.setattr("agents.models.openai_provider.AsyncOpenAI", FakeAsyncOpenAI)
|
|
|
|
model = OpenAIProvider(
|
|
use_responses=True,
|
|
websocket_base_url="wss://explicit.example.test/v1",
|
|
).get_model("gpt-4")
|
|
assert isinstance(model, OpenAIResponsesModel)
|
|
assert captured_kwargs["websocket_base_url"] == "wss://explicit.example.test/v1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_provider_reuses_websocket_model_instance_for_same_model_name():
|
|
provider = OpenAIProvider(use_responses=True, use_responses_websocket=True)
|
|
|
|
model1 = provider.get_model("gpt-4")
|
|
model2 = provider.get_model("gpt-4")
|
|
|
|
assert isinstance(model1, OpenAIResponsesWSModel)
|
|
assert model1 is model2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_provider_passes_responses_websocket_options_to_model():
|
|
class DummyAsyncOpenAI:
|
|
pass
|
|
|
|
provider = OpenAIProvider(
|
|
use_responses=True,
|
|
use_responses_websocket=True,
|
|
openai_client=DummyAsyncOpenAI(), # type: ignore[arg-type]
|
|
responses_websocket_options={"ping_interval": 30.0, "ping_timeout": None},
|
|
)
|
|
|
|
model = provider.get_model("gpt-4")
|
|
|
|
assert isinstance(model, OpenAIResponsesWSModel)
|
|
assert model._websocket_options == {"ping_interval": 30.0, "ping_timeout": None}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_responses_websocket_session_passes_keepalive_options_to_provider():
|
|
async with responses_websocket_session(
|
|
api_key="test-key",
|
|
responses_websocket_options={"ping_interval": None, "ping_timeout": None},
|
|
) as session:
|
|
assert session.provider._responses_websocket_options == {
|
|
"ping_interval": None,
|
|
"ping_timeout": None,
|
|
}
|
|
|
|
|
|
def test_openai_provider_does_not_reuse_non_websocket_model_instances():
|
|
provider = OpenAIProvider(use_responses=True, use_responses_websocket=False)
|
|
|
|
model1 = provider.get_model("gpt-4")
|
|
model2 = provider.get_model("gpt-4")
|
|
|
|
assert isinstance(model1, OpenAIResponsesModel)
|
|
assert isinstance(model2, OpenAIResponsesModel)
|
|
assert model1 is not model2
|
|
|
|
|
|
def test_openai_provider_does_not_reuse_websocket_model_without_running_loop():
|
|
class DummyAsyncOpenAI:
|
|
pass
|
|
|
|
provider = OpenAIProvider(
|
|
use_responses=True,
|
|
use_responses_websocket=True,
|
|
openai_client=DummyAsyncOpenAI(), # type: ignore[arg-type]
|
|
)
|
|
|
|
model1 = provider.get_model("gpt-4")
|
|
model2 = provider.get_model("gpt-4")
|
|
|
|
assert isinstance(model1, OpenAIResponsesWSModel)
|
|
assert isinstance(model2, OpenAIResponsesWSModel)
|
|
assert model1 is not model2
|
|
|
|
|
|
def test_openai_provider_scopes_websocket_model_cache_to_running_loop():
|
|
class DummyAsyncOpenAI:
|
|
pass
|
|
|
|
provider = OpenAIProvider(
|
|
use_responses=True,
|
|
use_responses_websocket=True,
|
|
openai_client=DummyAsyncOpenAI(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async def get_model():
|
|
return provider.get_model("gpt-4")
|
|
|
|
loop1 = asyncio.new_event_loop()
|
|
loop2 = asyncio.new_event_loop()
|
|
try:
|
|
model1 = loop1.run_until_complete(get_model())
|
|
model1_again = loop1.run_until_complete(get_model())
|
|
model2 = loop2.run_until_complete(get_model())
|
|
finally:
|
|
loop1.close()
|
|
loop2.close()
|
|
asyncio.set_event_loop(None)
|
|
|
|
assert isinstance(model1, OpenAIResponsesWSModel)
|
|
assert model1 is model1_again
|
|
assert model2 is not model1
|
|
|
|
|
|
def test_openai_provider_websocket_loop_cache_does_not_keep_closed_loop_alive(monkeypatch):
|
|
class DummyAsyncOpenAI:
|
|
pass
|
|
|
|
class DummyWSConnection:
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
provider = OpenAIProvider(
|
|
use_responses=True,
|
|
use_responses_websocket=True,
|
|
openai_client=DummyAsyncOpenAI(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async def create_and_warm_model() -> OpenAIResponsesWSModel:
|
|
model = provider.get_model("gpt-4")
|
|
assert isinstance(model, OpenAIResponsesWSModel)
|
|
|
|
async def fake_open(
|
|
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
|
|
) -> DummyWSConnection:
|
|
return DummyWSConnection()
|
|
|
|
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
|
|
model._get_ws_request_lock()
|
|
await model._ensure_websocket_connection(
|
|
"wss://example.test/v1/responses",
|
|
{},
|
|
connect_timeout=None,
|
|
)
|
|
return model
|
|
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
model = loop.run_until_complete(create_and_warm_model())
|
|
loop_ref = weakref.ref(loop)
|
|
finally:
|
|
loop.close()
|
|
asyncio.set_event_loop(None)
|
|
|
|
del loop
|
|
gc.collect()
|
|
|
|
assert loop_ref() is None
|
|
assert list(provider._ws_model_cache_by_loop.items()) == []
|
|
# Keep a live reference to the model to ensure cache cleanup doesn't depend on model GC.
|
|
assert isinstance(model, OpenAIResponsesWSModel)
|
|
|
|
|
|
def test_openai_provider_prunes_closed_loop_cache_with_live_ws_connection(monkeypatch):
|
|
class DummyAsyncOpenAI:
|
|
pass
|
|
|
|
abort_calls: list[str] = []
|
|
|
|
class DummyTransport:
|
|
def abort(self) -> None:
|
|
abort_calls.append("abort")
|
|
|
|
class PinningWSConnection:
|
|
def __init__(self, loop: asyncio.AbstractEventLoop):
|
|
self.loop = loop
|
|
self.transport = DummyTransport()
|
|
|
|
async def close(self) -> None:
|
|
raise AssertionError("Closed-loop cache pruning should not await websocket.close().")
|
|
|
|
provider = OpenAIProvider(
|
|
use_responses=True,
|
|
use_responses_websocket=True,
|
|
openai_client=DummyAsyncOpenAI(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async def create_and_warm_model() -> None:
|
|
model = provider.get_model("gpt-4")
|
|
assert isinstance(model, OpenAIResponsesWSModel)
|
|
|
|
async def fake_open(
|
|
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
|
|
) -> PinningWSConnection:
|
|
return PinningWSConnection(asyncio.get_running_loop())
|
|
|
|
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
|
|
await model._ensure_websocket_connection(
|
|
"wss://example.test/v1/responses",
|
|
{},
|
|
connect_timeout=None,
|
|
)
|
|
|
|
async def get_model_on_current_loop() -> OpenAIResponsesWSModel:
|
|
model = provider.get_model("gpt-4")
|
|
assert isinstance(model, OpenAIResponsesWSModel)
|
|
return model
|
|
|
|
loop1 = asyncio.new_event_loop()
|
|
try:
|
|
loop1.run_until_complete(create_and_warm_model())
|
|
loop1_ref = weakref.ref(loop1)
|
|
finally:
|
|
loop1.close()
|
|
asyncio.set_event_loop(None)
|
|
|
|
del loop1
|
|
gc.collect()
|
|
|
|
# The cached websocket model's live connection pins the closed loop until provider cleanup runs.
|
|
assert loop1_ref() is not None
|
|
|
|
loop2 = asyncio.new_event_loop()
|
|
try:
|
|
loop2.run_until_complete(get_model_on_current_loop())
|
|
finally:
|
|
loop2.close()
|
|
asyncio.set_event_loop(None)
|
|
|
|
del loop2
|
|
gc.collect()
|
|
|
|
assert abort_calls == ["abort"]
|
|
assert loop1_ref() is None
|
|
assert all(not loop.is_closed() for loop in provider._ws_model_cache_by_loop)
|
|
|
|
|
|
def test_openai_provider_aclose_closes_websocket_models_from_other_loops(monkeypatch):
|
|
class DummyAsyncOpenAI:
|
|
pass
|
|
|
|
provider = OpenAIProvider(
|
|
use_responses=True,
|
|
use_responses_websocket=True,
|
|
openai_client=DummyAsyncOpenAI(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async def get_model():
|
|
return provider.get_model("gpt-4")
|
|
|
|
closed_models: list[object] = []
|
|
|
|
async def fake_close(self):
|
|
closed_models.append(self)
|
|
|
|
monkeypatch.setattr(OpenAIResponsesWSModel, "close", fake_close)
|
|
monkeypatch.setattr(
|
|
"agents.models.openai_provider.asyncio.to_thread",
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
AssertionError("provider.aclose() should not drive foreign loops in to_thread")
|
|
),
|
|
)
|
|
|
|
loop1 = asyncio.new_event_loop()
|
|
loop2 = asyncio.new_event_loop()
|
|
try:
|
|
model1 = loop1.run_until_complete(get_model())
|
|
model2 = loop2.run_until_complete(get_model())
|
|
|
|
asyncio.run(provider.aclose())
|
|
|
|
model1_new = loop1.run_until_complete(get_model())
|
|
model2_again = loop2.run_until_complete(get_model())
|
|
finally:
|
|
loop1.close()
|
|
loop2.close()
|
|
asyncio.set_event_loop(None)
|
|
|
|
assert closed_models == [model1, model2] or closed_models == [model2, model1]
|
|
assert model1_new is not model1
|
|
assert model2_again is not model2
|
|
|
|
|
|
def test_openai_provider_aclose_closes_websocket_models_when_original_loop_is_closed(monkeypatch):
|
|
class DummyAsyncOpenAI:
|
|
pass
|
|
|
|
provider = OpenAIProvider(
|
|
use_responses=True,
|
|
use_responses_websocket=True,
|
|
openai_client=DummyAsyncOpenAI(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async def get_model():
|
|
return provider.get_model("gpt-4")
|
|
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
model = loop.run_until_complete(get_model())
|
|
finally:
|
|
loop.close()
|
|
asyncio.set_event_loop(None)
|
|
|
|
closed_models: list[object] = []
|
|
|
|
async def fake_close(self):
|
|
closed_models.append(self)
|
|
|
|
monkeypatch.setattr(OpenAIResponsesWSModel, "close", fake_close)
|
|
|
|
asyncio.run(provider.aclose())
|
|
|
|
assert closed_models == [model]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_provider_aclose_closes_cached_models(monkeypatch):
|
|
provider = OpenAIProvider(use_responses=True, use_responses_websocket=True)
|
|
model1 = provider.get_model("gpt-4")
|
|
|
|
closed_models: list[object] = []
|
|
|
|
async def fake_close(self):
|
|
closed_models.append(self)
|
|
|
|
monkeypatch.setattr(OpenAIResponsesWSModel, "close", fake_close)
|
|
|
|
await provider.aclose()
|
|
assert closed_models == [model1]
|
|
assert provider.get_model("gpt-4") is not model1
|