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