Files
openai--openai-agents-python/tests/models/test_openai_responses.py
T
2026-07-13 12:39:17 +08:00

3972 lines
124 KiB
Python

from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
from typing import Any, cast
import httpx
import pytest
from openai import NOT_GIVEN, APIConnectionError, AsyncOpenAI, RateLimitError, omit
from openai.types.responses import ResponseCompletedEvent, ResponseErrorEvent
from openai.types.responses.response_create_params import ContextManagement, PromptCacheOptions
from openai.types.shared.reasoning import Reasoning
from agents import (
Agent,
AsyncComputer,
Computer,
ComputerTool,
ModelSettings,
ModelTracing,
Runner,
ToolSearchTool,
__version__,
trace,
)
from agents.exceptions import ModelBehaviorError, UserError
from agents.models._retry_runtime import (
provider_managed_retries_disabled,
websocket_pre_event_retries_disabled,
)
from agents.models.openai_responses import (
_HEADERS_OVERRIDE as RESP_HEADERS,
ConvertedTools,
Converter,
OpenAIResponsesModel,
OpenAIResponsesWSModel,
ResponsesWebSocketError,
_should_retry_pre_event_websocket_disconnect,
)
from agents.retry import ModelRetryAdviceRequest
from agents.usage import Usage
from tests.fake_model import get_response_obj
from tests.testing_processor import fetch_ordered_spans
async def _run_responses_model_with_custom_base_url(
model_settings: ModelSettings | None = None,
) -> dict[str, Any]:
class DummyResponses:
def __init__(self) -> None:
self.kwargs: dict[str, Any] = {}
async def create(self, **kwargs: Any) -> Any:
self.kwargs = kwargs
return get_response_obj([])
class DummyResponsesClient:
def __init__(self, responses: DummyResponses) -> None:
self.responses = responses
self.base_url = httpx.URL("https://custom.example.test/v1/")
responses = DummyResponses()
model = OpenAIResponsesModel(
model="gpt-4",
openai_client=DummyResponsesClient(responses), # type: ignore[arg-type]
)
agent = Agent(name="test", model=model, model_settings=model_settings or ModelSettings())
await Runner.run(agent, "hi")
return responses.kwargs
async def _run_responses_model_with_official_client(
model_settings: ModelSettings | None = None,
) -> list[httpx.Request]:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200,
content=get_response_obj([]).model_dump_json(),
headers={"content-type": "application/json"},
request=request,
)
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
try:
client = AsyncOpenAI(
api_key="test-key",
base_url="https://example.test/v1",
http_client=http_client,
)
model = OpenAIResponsesModel(model="gpt-4", openai_client=client)
await model.get_response(
system_instructions=None,
input="hi",
model_settings=model_settings or ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
finally:
await http_client.aclose()
return requests
class DummyWSConnection:
def __init__(self, frames: list[str]):
self._frames = frames
self.sent_messages: list[dict[str, Any]] = []
self.close_calls = 0
self.close_code: int | None = None
async def send(self, payload: str) -> None:
self.sent_messages.append(json.loads(payload))
async def recv(self) -> str:
if not self._frames:
raise RuntimeError("No more websocket frames configured")
return self._frames.pop(0)
async def close(self) -> None:
self.close_calls += 1
if self.close_code is None:
self.close_code = 1000
class DummyWSClient:
def __init__(self):
self.base_url = httpx.URL("https://api.openai.com/v1/")
self.websocket_base_url = None
self.default_query: dict[str, Any] = {}
self.auth_headers = {"Authorization": "Bearer test-key"}
self.default_headers = {"User-Agent": "AsyncOpenAI/Python test"}
self.timeout: Any = None
self.refresh_calls = 0
async def _refresh_api_key(self) -> None:
self.refresh_calls += 1
def _response_event_frame(event_type: str, response_id: str, sequence_number: int) -> str:
response = get_response_obj([]).model_dump()
response["id"] = response_id
return json.dumps(
{
"type": event_type,
"response": response,
"sequence_number": sequence_number,
}
)
def _response_completed_frame(response_id: str, sequence_number: int) -> str:
return _response_event_frame("response.completed", response_id, sequence_number)
def _response_error_frame(code: str, message: str, sequence_number: int) -> str:
return json.dumps(
{
"type": "response.error",
"error": {"code": code, "message": message, "param": None},
"sequence_number": sequence_number,
}
)
def _connection_closed_error(message: str) -> Exception:
class ConnectionClosedError(Exception):
pass
ConnectionClosedError.__module__ = "websockets.client"
return ConnectionClosedError(message)
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize("override_ua", [None, "test_user_agent"])
async def test_user_agent_header_responses(override_ua: str | None):
called_kwargs: dict[str, Any] = {}
expected_ua = override_ua or f"Agents/Python {__version__}"
class DummyStream:
def __aiter__(self):
async def gen():
yield ResponseCompletedEvent(
type="response.completed",
response=get_response_obj([]),
sequence_number=0,
)
return gen()
class DummyResponses:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
return DummyStream()
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(model="gpt-4", openai_client=DummyResponsesClient()) # type: ignore
if override_ua is not None:
token = RESP_HEADERS.set({"User-Agent": override_ua})
else:
token = None
try:
stream = model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
async for _ in stream:
pass
finally:
if token is not None:
RESP_HEADERS.reset(token)
assert "extra_headers" in called_kwargs
assert called_kwargs["extra_headers"]["User-Agent"] == expected_ua
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_exposes_request_id():
class DummyResponses:
async def create(self, **kwargs):
response = get_response_obj([], response_id="resp-request-id")
response._request_id = "req_nonstream_123"
return response
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(model="gpt-4", openai_client=DummyResponsesClient()) # type: ignore[arg-type]
response = await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert response.response_id == "resp-request-id"
assert response.request_id == "req_nonstream_123"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_span_exports_usage():
class DummyResponses:
async def create(self, **kwargs):
return get_response_obj(
[],
response_id="resp-usage",
usage=Usage(requests=1, input_tokens=10, output_tokens=4, total_tokens=14),
)
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(model="gpt-4", openai_client=DummyResponsesClient()) # type: ignore[arg-type]
with trace("test"):
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.ENABLED,
)
response_spans = [
span.export() for span in fetch_ordered_spans() if span.span_data.type == "response"
]
assert len(response_spans) == 1
assert response_spans[0]
assert response_spans[0]["span_data"] == {
"type": "response",
"response_id": "resp-usage",
"usage": {
"requests": 1,
"input_tokens": 10,
"output_tokens": 4,
"total_tokens": 14,
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0},
},
}
def test_get_client_disables_provider_managed_retries_on_runner_retry() -> None:
class DummyResponsesClient:
def __init__(self) -> None:
self.responses = SimpleNamespace()
self.with_options_calls: list[dict[str, Any]] = []
def with_options(self, **kwargs):
self.with_options_calls.append(kwargs)
return self
client = DummyResponsesClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
assert cast(object, model._get_client()) is client
with provider_managed_retries_disabled(True):
assert cast(object, model._get_client()) is client
assert client.with_options_calls == [{"max_retries": 0}]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_fetch_response_stream_attaches_request_id_to_terminal_response():
class DummyHTTPStream:
def __init__(self):
self._yielded = False
def __aiter__(self):
return self
async def __anext__(self):
if self._yielded:
raise StopAsyncIteration
self._yielded = True
return ResponseCompletedEvent(
type="response.completed",
response=get_response_obj([], response_id="resp-stream-request-id"),
sequence_number=0,
)
inner_stream = DummyHTTPStream()
class DummyAPIResponse:
def __init__(self):
self.request_id = "req_stream_123"
self.close_calls = 0
self.parse_calls = 0
async def parse(self):
self.parse_calls += 1
return inner_stream
async def close(self) -> None:
self.close_calls += 1
api_response = DummyAPIResponse()
aexit_calls: list[tuple[Any, Any, Any]] = []
class DummyStreamingContextManager:
async def __aenter__(self):
return api_response
async def __aexit__(self, exc_type, exc, tb):
aexit_calls.append((exc_type, exc, tb))
await api_response.close()
return False
class DummyResponses:
def __init__(self):
self.with_streaming_response = SimpleNamespace(create=self.create_streaming)
def create_streaming(self, **kwargs):
return DummyStreamingContextManager()
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(model="gpt-4", openai_client=DummyResponsesClient()) # type: ignore[arg-type]
stream = await model._fetch_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=True,
)
stream_agen = cast(Any, stream)
event = await stream_agen.__anext__()
assert getattr(stream, "request_id", None) == "req_stream_123"
assert getattr(event.response, "_request_id", None) == "req_stream_123"
with pytest.raises(StopAsyncIteration):
await stream_agen.__anext__()
assert api_response.parse_calls == 1
assert api_response.close_calls == 1
assert aexit_calls == [(None, None, None)]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_fetch_response_stream_parse_failure_exits_streaming_context():
parse_error = RuntimeError("parse failed")
aexit_calls: list[tuple[Any, Any, Any]] = []
class DummyAPIResponse:
request_id = "req_stream_123"
async def parse(self):
raise parse_error
api_response = DummyAPIResponse()
class DummyStreamingContextManager:
async def __aenter__(self):
return api_response
async def __aexit__(self, exc_type, exc, tb):
aexit_calls.append((exc_type, exc, tb))
return False
class DummyResponses:
def __init__(self):
self.with_streaming_response = SimpleNamespace(create=self.create_streaming)
def create_streaming(self, **kwargs):
return DummyStreamingContextManager()
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(model="gpt-4", openai_client=DummyResponsesClient()) # type: ignore[arg-type]
with pytest.raises(RuntimeError, match="parse failed"):
await model._fetch_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=True,
)
assert len(aexit_calls) == 1
exc_type, exc, tb = aexit_calls[0]
assert exc_type is RuntimeError
assert exc is parse_error
assert tb is not None
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_fetch_response_stream_without_request_id_still_returns_events():
class DummyHTTPStream:
def __init__(self):
self._yielded = False
def __aiter__(self):
return self
async def __anext__(self):
if self._yielded:
raise StopAsyncIteration
self._yielded = True
return ResponseCompletedEvent(
type="response.completed",
response=get_response_obj([], response_id="resp-stream-request-id"),
sequence_number=0,
)
inner_stream = DummyHTTPStream()
aexit_calls: list[tuple[Any, Any, Any]] = []
class DummyAPIResponse:
def __init__(self):
self.close_calls = 0
self.parse_calls = 0
async def parse(self):
self.parse_calls += 1
return inner_stream
async def close(self) -> None:
self.close_calls += 1
api_response = DummyAPIResponse()
class DummyStreamingContextManager:
async def __aenter__(self):
return api_response
async def __aexit__(self, exc_type, exc, tb):
aexit_calls.append((exc_type, exc, tb))
await api_response.close()
return False
class DummyResponses:
def __init__(self):
self.with_streaming_response = SimpleNamespace(create=self.create_streaming)
def create_streaming(self, **kwargs):
return DummyStreamingContextManager()
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(model="gpt-4", openai_client=DummyResponsesClient()) # type: ignore[arg-type]
stream = await model._fetch_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=True,
)
stream_agen = cast(Any, stream)
event = await stream_agen.__anext__()
assert getattr(stream, "request_id", None) is None
assert getattr(event.response, "_request_id", None) is None
with pytest.raises(StopAsyncIteration):
await stream_agen.__anext__()
assert api_response.parse_calls == 1
assert api_response.close_calls == 1
assert aexit_calls == [(None, None, None)]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_stream_response_ignores_streaming_context_exit_failure_after_terminal_event():
class DummyHTTPStream:
def __init__(self):
self._yielded = False
def __aiter__(self):
return self
async def __anext__(self):
if self._yielded:
raise StopAsyncIteration
self._yielded = True
return ResponseCompletedEvent(
type="response.completed",
response=get_response_obj([], response_id="resp-stream-request-id"),
sequence_number=0,
)
inner_stream = DummyHTTPStream()
aexit_calls: list[tuple[Any, Any, Any]] = []
class DummyAPIResponse:
request_id = "req_stream_123"
async def parse(self):
return inner_stream
api_response = DummyAPIResponse()
class DummyStreamingContextManager:
async def __aenter__(self):
return api_response
async def __aexit__(self, exc_type, exc, tb):
aexit_calls.append((exc_type, exc, tb))
raise RuntimeError("stream context exit failed")
class DummyResponses:
def __init__(self):
self.with_streaming_response = SimpleNamespace(create=self.create_streaming)
def create_streaming(self, **kwargs):
return DummyStreamingContextManager()
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(model="gpt-4", openai_client=DummyResponsesClient()) # type: ignore[arg-type]
events: list[ResponseCompletedEvent] = []
async for event in model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
):
assert isinstance(event, ResponseCompletedEvent)
events.append(event)
assert len(events) == 1
assert aexit_calls == [(None, None, None)]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_stream_response_close_closes_inner_http_stream_with_async_close(monkeypatch):
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
class DummyHTTPStream:
def __init__(self):
self._yielded = False
self.close_calls = 0
def __aiter__(self):
return self
async def __anext__(self):
if self._yielded:
raise StopAsyncIteration
self._yielded = True
return ResponseCompletedEvent(
type="response.completed",
response=get_response_obj([]),
sequence_number=0,
)
async def close(self) -> None:
self.close_calls += 1
inner_stream = DummyHTTPStream()
async def fake_fetch_response(*args: Any, **kwargs: Any) -> DummyHTTPStream:
return inner_stream
monkeypatch.setattr(model, "_fetch_response", fake_fetch_response)
stream = model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
stream_agen = cast(Any, stream)
event = await stream_agen.__anext__()
assert event.type == "response.completed"
await stream_agen.aclose()
assert inner_stream.close_calls == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_stream_response_normal_exhaustion_closes_inner_http_stream(monkeypatch):
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
class DummyHTTPStream:
def __init__(self):
self._yielded = False
self.close_calls = 0
def __aiter__(self):
return self
async def __anext__(self):
if self._yielded:
raise StopAsyncIteration
self._yielded = True
return ResponseCompletedEvent(
type="response.completed",
response=get_response_obj([]),
sequence_number=0,
)
async def close(self) -> None:
self.close_calls += 1
inner_stream = DummyHTTPStream()
async def fake_fetch_response(*args: Any, **kwargs: Any) -> DummyHTTPStream:
return inner_stream
monkeypatch.setattr(model, "_fetch_response", fake_fetch_response)
events: list[ResponseCompletedEvent] = []
async for event in model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
):
assert isinstance(event, ResponseCompletedEvent)
events.append(event)
assert len(events) == 1
assert inner_stream.close_calls == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_stream_response_ignores_inner_close_failure_after_terminal_event(monkeypatch):
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
class DummyHTTPStream:
def __init__(self):
self._yielded = False
self.close_calls = 0
def __aiter__(self):
return self
async def __anext__(self):
if self._yielded:
raise StopAsyncIteration
self._yielded = True
return ResponseCompletedEvent(
type="response.completed",
response=get_response_obj([]),
sequence_number=0,
)
async def close(self) -> None:
self.close_calls += 1
raise RuntimeError("stream close failed")
inner_stream = DummyHTTPStream()
async def fake_fetch_response(*args: Any, **kwargs: Any) -> DummyHTTPStream:
return inner_stream
monkeypatch.setattr(model, "_fetch_response", fake_fetch_response)
events: list[ResponseCompletedEvent] = []
async for event in model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
):
assert isinstance(event, ResponseCompletedEvent)
events.append(event)
assert len(events) == 1
assert inner_stream.close_calls == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_stream_response_cancellation_does_not_block_on_inner_stream_close(monkeypatch):
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
class BlockingHTTPStream:
def __init__(self):
self.next_started = asyncio.Event()
self.close_started = asyncio.Event()
self.close_release = asyncio.Event()
self.close_calls = 0
def __aiter__(self):
return self
async def __anext__(self):
self.next_started.set()
await asyncio.Event().wait()
raise StopAsyncIteration
async def aclose(self) -> None:
self.close_calls += 1
self.close_started.set()
await self.close_release.wait()
inner_stream = BlockingHTTPStream()
async def fake_fetch_response(*args: Any, **kwargs: Any) -> BlockingHTTPStream:
return inner_stream
monkeypatch.setattr(model, "_fetch_response", fake_fetch_response)
stream = model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
stream_agen = cast(Any, stream)
next_task = asyncio.create_task(stream_agen.__anext__())
await asyncio.wait_for(inner_stream.next_started.wait(), timeout=1.0)
next_task.cancel()
try:
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(next_task, timeout=0.5)
await asyncio.wait_for(inner_stream.close_started.wait(), timeout=1.0)
assert inner_stream.close_calls == 1
finally:
inner_stream.close_release.set()
await asyncio.sleep(0)
@pytest.mark.allow_call_model_methods
def test_build_response_create_kwargs_rejects_duplicate_extra_args_keys():
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
with pytest.raises(TypeError, match="multiple values.*stream"):
model._build_response_create_kwargs(
system_instructions=None,
input="hi",
model_settings=ModelSettings(extra_args={"stream": False}),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=True,
prompt=None,
)
@pytest.mark.allow_call_model_methods
def test_build_response_create_kwargs_includes_extra_args_prompt_cache_key():
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
kwargs = model._build_response_create_kwargs(
system_instructions=None,
input="hi",
model_settings=ModelSettings(extra_args={"prompt_cache_key": "cache-key"}),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=False,
prompt=None,
)
assert kwargs["prompt_cache_key"] == "cache-key"
@pytest.mark.allow_call_model_methods
def test_build_response_create_kwargs_includes_context_management():
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
context_management: list[ContextManagement] = [
{"type": "compaction", "compact_threshold": 200000}
]
kwargs = model._build_response_create_kwargs(
system_instructions=None,
input="hi",
model_settings=ModelSettings(context_management=context_management),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=False,
prompt=None,
)
assert kwargs["context_management"] == context_management
@pytest.mark.allow_call_model_methods
def test_build_response_create_kwargs_includes_gpt_5_6_request_controls():
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-5.6-sol", openai_client=client) # type: ignore[arg-type]
reasoning = Reasoning(mode="pro", effort="max", context="all_turns")
prompt_cache_options: PromptCacheOptions = {"mode": "explicit", "ttl": "30m"}
kwargs = model._build_response_create_kwargs(
system_instructions=None,
input="hi",
model_settings=ModelSettings(
reasoning=reasoning,
prompt_cache_retention="24h",
prompt_cache_options=prompt_cache_options,
),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id="resp-previous",
conversation_id=None,
stream=False,
prompt=None,
)
assert kwargs["reasoning"] is reasoning
assert kwargs["prompt_cache_retention"] == "24h"
assert kwargs["prompt_cache_options"] == prompt_cache_options
assert kwargs["previous_response_id"] == "resp-previous"
@pytest.mark.allow_call_model_methods
def test_build_response_create_kwargs_rejects_duplicate_prompt_cache_options_extra_args():
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-5.6-sol", openai_client=client) # type: ignore[arg-type]
with pytest.raises(TypeError, match="multiple values.*prompt_cache_options"):
model._build_response_create_kwargs(
system_instructions=None,
input="hi",
model_settings=ModelSettings(
prompt_cache_options={"mode": "explicit", "ttl": "30m"},
extra_args={"prompt_cache_options": {"mode": "implicit"}},
),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=False,
prompt=None,
)
@pytest.mark.allow_call_model_methods
def test_build_response_create_kwargs_allows_prompt_cache_options_extra_args_when_direct_omitted():
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-5.6-sol", openai_client=client) # type: ignore[arg-type]
prompt_cache_options = {"mode": "explicit", "ttl": "30m"}
kwargs = model._build_response_create_kwargs(
system_instructions=None,
input="hi",
model_settings=ModelSettings(extra_args={"prompt_cache_options": prompt_cache_options}),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=False,
prompt=None,
)
assert kwargs["prompt_cache_options"] == prompt_cache_options
@pytest.mark.allow_call_model_methods
def test_build_response_create_kwargs_allows_extra_arg_when_explicit_arg_is_omitted():
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
context_management: list[ContextManagement] = [
{"type": "compaction", "compact_threshold": 200000}
]
kwargs = model._build_response_create_kwargs(
system_instructions=None,
input="hi",
model_settings=ModelSettings(extra_args={"context_management": context_management}),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=False,
prompt=None,
)
assert kwargs["context_management"] == context_management
@pytest.mark.allow_call_model_methods
def test_build_response_create_kwargs_rejects_duplicate_context_management_extra_args():
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
with pytest.raises(TypeError, match="multiple values.*context_management"):
model._build_response_create_kwargs(
system_instructions=None,
input="hi",
model_settings=ModelSettings(
context_management=[{"type": "compaction", "compact_threshold": 200000}],
extra_args={"context_management": [{"type": "compaction"}]},
),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=False,
prompt=None,
)
@pytest.mark.allow_call_model_methods
def test_build_response_create_kwargs_keeps_unset_transport_extra_kwargs_as_none():
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
kwargs = model._build_response_create_kwargs(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=False,
prompt=None,
)
assert kwargs["extra_query"] is None
assert kwargs["extra_body"] is None
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_with_official_client_accepts_unset_transport_extra_kwargs() -> None:
requests = await _run_responses_model_with_official_client()
assert len(requests) == 1
assert requests[0].url == "https://example.test/v1/responses"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_with_official_client_applies_transport_extra_kwargs() -> None:
requests = await _run_responses_model_with_official_client(
ModelSettings(
extra_query={"api-version": "2026-01-01-preview"},
extra_body={"extra_transport_field": "enabled"},
)
)
assert len(requests) == 1
assert requests[0].url == ("https://example.test/v1/responses?api-version=2026-01-01-preview")
assert json.loads(requests[0].content)["extra_transport_field"] == "enabled"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_custom_base_url_prompt_cache_key_uses_model_settings_only() -> None:
default_kwargs = await _run_responses_model_with_custom_base_url()
explicit_kwargs = await _run_responses_model_with_custom_base_url(
model_settings=ModelSettings(extra_args={"prompt_cache_key": "cache-key"})
)
assert "prompt_cache_key" not in default_kwargs
assert explicit_kwargs["prompt_cache_key"] == "cache-key"
@pytest.mark.allow_call_model_methods
def test_build_response_create_kwargs_preserves_unknown_response_include_values():
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
kwargs = model._build_response_create_kwargs(
system_instructions=None,
input="hi",
model_settings=ModelSettings(response_include=["response.future_flag"]),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=False,
prompt=None,
)
assert kwargs["include"] == ["response.future_flag"]
@pytest.mark.allow_call_model_methods
def test_build_response_create_kwargs_preserves_unknown_tool_types(monkeypatch) -> None:
client = DummyWSClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
future_tool = cast(Any, {"type": "future_beta_tool", "label": "preview"})
monkeypatch.setattr(
Converter,
"convert_tools",
classmethod(
lambda cls, tools, handoffs, **kwargs: ConvertedTools(tools=[future_tool], includes=[])
),
)
kwargs = model._build_response_create_kwargs(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=False,
prompt=None,
)
assert kwargs["tools"] == [future_tool]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_prompt_id_omits_model_parameter():
called_kwargs: dict[str, Any] = {}
class DummyResponses:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
return get_response_obj([])
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(
model="gpt-4",
openai_client=DummyResponsesClient(), # type: ignore[arg-type]
model_is_explicit=False,
)
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
prompt={"id": "pmpt_123"},
)
assert called_kwargs["prompt"] == {"id": "pmpt_123"}
assert called_kwargs["model"] is omit
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_prompt_id_omits_tools_parameter_when_no_tools_configured():
called_kwargs: dict[str, Any] = {}
class DummyResponses:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
return get_response_obj([])
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(
model="gpt-4",
openai_client=DummyResponsesClient(), # type: ignore[arg-type]
model_is_explicit=False,
)
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
prompt={"id": "pmpt_123"},
)
assert called_kwargs["tools"] is omit
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_prompt_id_omits_tool_choice_when_no_tools_configured():
called_kwargs: dict[str, Any] = {}
class DummyResponses:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
return get_response_obj([])
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(
model="gpt-4",
openai_client=DummyResponsesClient(), # type: ignore[arg-type]
model_is_explicit=False,
)
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(tool_choice="web_search_preview"),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
prompt={"id": "pmpt_123"},
)
assert called_kwargs["tools"] is omit
assert called_kwargs["tool_choice"] is omit
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize("tool_choice", ["none", "required"])
async def test_prompt_id_keeps_literal_tool_choice_without_local_tools(tool_choice: str):
called_kwargs: dict[str, Any] = {}
class DummyResponses:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
return get_response_obj([])
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(
model="gpt-4",
openai_client=DummyResponsesClient(), # type: ignore[arg-type]
model_is_explicit=False,
)
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(tool_choice=tool_choice),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
prompt={"id": "pmpt_123"},
)
assert called_kwargs["tools"] is omit
assert called_kwargs["tool_choice"] == tool_choice
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_prompt_id_keeps_explicit_tool_search_without_local_surface() -> None:
called_kwargs: dict[str, Any] = {}
class DummyResponses:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
return get_response_obj([])
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(
model="gpt-4",
openai_client=DummyResponsesClient(), # type: ignore[arg-type]
model_is_explicit=False,
)
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[ToolSearchTool()],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
prompt={"id": "pmpt_123"},
)
assert called_kwargs["prompt"] == {"id": "pmpt_123"}
assert called_kwargs["tools"] == [{"type": "tool_search"}]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_ga_computer_tool_does_not_require_preview_metadata() -> None:
called_kwargs: dict[str, Any] = {}
class DummyComputer(AsyncComputer):
async def screenshot(self) -> str:
return "screenshot"
async def click(self, x: int, y: int, button: str) -> None:
pass
async def double_click(self, x: int, y: int) -> None:
pass
async def drag(self, path: list[tuple[int, int]]) -> None:
pass
async def keypress(self, keys: list[str]) -> None:
pass
async def move(self, x: int, y: int) -> None:
pass
async def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
pass
async def type(self, text: str) -> None:
pass
async def wait(self) -> None:
pass
class DummyResponses:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
return get_response_obj([])
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(
model="gpt-5.4",
openai_client=DummyResponsesClient(), # type: ignore[arg-type]
model_is_explicit=True,
)
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[ComputerTool(computer=DummyComputer())],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
prompt=None,
)
assert called_kwargs["tools"] == [{"type": "computer"}]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_prompt_id_uses_preview_computer_payload_when_prompt_owns_model() -> None:
called_kwargs: dict[str, Any] = {}
class DummyComputer(Computer):
@property
def environment(self) -> str: # type: ignore[override]
return "mac"
@property
def dimensions(self) -> tuple[int, int]:
return (800, 600)
def screenshot(self) -> str:
return "screenshot"
def click(self, x: int, y: int, button: str) -> None:
pass
def double_click(self, x: int, y: int) -> None:
pass
def drag(self, path: list[tuple[int, int]]) -> None:
pass
def keypress(self, keys: list[str]) -> None:
pass
def move(self, x: int, y: int) -> None:
pass
def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
pass
def type(self, text: str) -> None:
pass
def wait(self) -> None:
pass
class DummyResponses:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
return get_response_obj([])
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(
model="gpt-5.4",
openai_client=DummyResponsesClient(), # type: ignore[arg-type]
model_is_explicit=False,
)
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[ComputerTool(computer=DummyComputer())],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
prompt={"id": "pmpt_123"},
)
assert called_kwargs["model"] is omit
assert called_kwargs["tool_choice"] is omit
assert called_kwargs["tools"] == [
{
"type": "computer_use_preview",
"environment": "mac",
"display_width": 800,
"display_height": 600,
}
]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_prompt_id_computer_without_preview_metadata_raises_clear_error() -> None:
called_kwargs: dict[str, Any] = {}
class DummyComputer(Computer):
def screenshot(self) -> str:
return "screenshot"
def click(self, x: int, y: int, button: str) -> None:
pass
def double_click(self, x: int, y: int) -> None:
pass
def drag(self, path: list[tuple[int, int]]) -> None:
pass
def keypress(self, keys: list[str]) -> None:
pass
def move(self, x: int, y: int) -> None:
pass
def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
pass
def type(self, text: str) -> None:
pass
def wait(self) -> None:
pass
class DummyResponses:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
return get_response_obj([])
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(
model="gpt-5.4",
openai_client=DummyResponsesClient(), # type: ignore[arg-type]
model_is_explicit=False,
)
with pytest.raises(
UserError,
match="Preview computer tool payloads require `environment` and `dimensions`",
):
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[ComputerTool(computer=DummyComputer())],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
prompt={"id": "pmpt_123"},
)
assert called_kwargs == {}
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_prompt_id_unresolved_computer_uses_preview_payload_shape() -> None:
called_kwargs: dict[str, Any] = {}
class DummyComputer(Computer):
@property
def environment(self) -> str: # type: ignore[override]
return "mac"
@property
def dimensions(self) -> tuple[int, int]:
return (800, 600)
def screenshot(self) -> str:
return "screenshot"
def click(self, x: int, y: int, button: str) -> None:
pass
def double_click(self, x: int, y: int) -> None:
pass
def drag(self, path: list[tuple[int, int]]) -> None:
pass
def keypress(self, keys: list[str]) -> None:
pass
def move(self, x: int, y: int) -> None:
pass
def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
pass
def type(self, text: str) -> None:
pass
def wait(self) -> None:
pass
class DummyResponses:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
return get_response_obj([])
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(
model="gpt-5.4",
openai_client=DummyResponsesClient(), # type: ignore[arg-type]
model_is_explicit=False,
)
with pytest.raises(UserError, match="Computer tool is not initialized for serialization"):
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[ComputerTool(computer=lambda **_: DummyComputer())],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
prompt={"id": "pmpt_123"},
)
assert called_kwargs == {}
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize("tool_choice", ["computer", "computer_use"])
async def test_prompt_id_explicit_ga_computer_tool_choice_uses_ga_selector_and_tool(
tool_choice: str,
) -> None:
called_kwargs: dict[str, Any] = {}
class DummyComputer(Computer):
@property
def environment(self) -> str: # type: ignore[override]
return "mac"
@property
def dimensions(self) -> tuple[int, int]:
return (800, 600)
def screenshot(self) -> str:
return "screenshot"
def click(self, x: int, y: int, button: str) -> None:
pass
def double_click(self, x: int, y: int) -> None:
pass
def drag(self, path: list[tuple[int, int]]) -> None:
pass
def keypress(self, keys: list[str]) -> None:
pass
def move(self, x: int, y: int) -> None:
pass
def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
pass
def type(self, text: str) -> None:
pass
def wait(self) -> None:
pass
class DummyResponses:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
return get_response_obj([])
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(
model="gpt-5.4",
openai_client=DummyResponsesClient(), # type: ignore[arg-type]
model_is_explicit=False,
)
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(tool_choice=tool_choice),
tools=[ComputerTool(computer=DummyComputer())],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
prompt={"id": "pmpt_123"},
)
assert called_kwargs["model"] is omit
assert called_kwargs["tool_choice"] == {"type": "computer"}
assert called_kwargs["tools"] == [{"type": "computer"}]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize("tool_choice", ["computer", "computer_use"])
async def test_preview_model_forced_computer_tool_choice_uses_preview_selector(
tool_choice: str,
) -> None:
called_kwargs: dict[str, Any] = {}
class DummyComputer(Computer):
@property
def environment(self) -> str: # type: ignore[override]
return "mac"
@property
def dimensions(self) -> tuple[int, int]:
return (800, 600)
def screenshot(self) -> str:
return "screenshot"
def click(self, x: int, y: int, button: str) -> None:
pass
def double_click(self, x: int, y: int) -> None:
pass
def drag(self, path: list[tuple[int, int]]) -> None:
pass
def keypress(self, keys: list[str]) -> None:
pass
def move(self, x: int, y: int) -> None:
pass
def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
pass
def type(self, text: str) -> None:
pass
def wait(self) -> None:
pass
class DummyResponses:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
return get_response_obj([])
class DummyResponsesClient:
def __init__(self):
self.responses = DummyResponses()
model = OpenAIResponsesModel(
model="computer-use-preview",
openai_client=DummyResponsesClient(), # type: ignore[arg-type]
)
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(tool_choice=tool_choice),
tools=[ComputerTool(computer=DummyComputer())],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert called_kwargs["model"] == "computer-use-preview"
assert called_kwargs["tool_choice"] == {"type": "computer_use_preview"}
assert called_kwargs["tools"] == [
{
"type": "computer_use_preview",
"environment": "mac",
"display_width": 800,
"display_height": 600,
}
]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_reuses_connection_and_sends_response_create_frames(monkeypatch):
client = DummyWSClient()
ws = DummyWSConnection(
[
_response_completed_frame("resp-1", 1),
_response_completed_frame("resp-2", 2),
]
)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
opened: list[tuple[str, dict[str, str]]] = []
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
opened.append((ws_url, headers))
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
first = await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(
reasoning=Reasoning(mode="pro", effort="max", context="all_turns"),
prompt_cache_options={"mode": "explicit", "ttl": "30m"},
),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
second = await model.get_response(
system_instructions=None,
input="next",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id="resp-1",
)
assert first.response_id == "resp-1"
assert second.response_id == "resp-2"
assert client.refresh_calls == 2
assert len(opened) == 1
assert ws.sent_messages[0]["type"] == "response.create"
assert ws.sent_messages[0]["stream"] is True
assert ws.sent_messages[0]["reasoning"] == {
"context": "all_turns",
"effort": "max",
"mode": "pro",
}
assert ws.sent_messages[0]["prompt_cache_options"] == {
"mode": "explicit",
"ttl": "30m",
}
assert ws.sent_messages[1]["type"] == "response.create"
assert ws.sent_messages[1]["stream"] is True
assert ws.sent_messages[1]["previous_response_id"] == "resp-1"
@pytest.mark.asyncio
async def test_websocket_model_passes_keepalive_options_to_connect(monkeypatch):
import websockets.asyncio.client as websockets_client
client = DummyWSClient()
model = OpenAIResponsesWSModel(
model="gpt-4",
openai_client=client, # type: ignore[arg-type]
websocket_options={"ping_interval": 45.0, "ping_timeout": None},
)
ws = DummyWSConnection([])
captured_kwargs: dict[str, Any] = {}
async def fake_connect(ws_url: str, **kwargs: Any) -> DummyWSConnection:
captured_kwargs["ws_url"] = ws_url
captured_kwargs.update(kwargs)
return ws
monkeypatch.setattr(websockets_client, "connect", fake_connect)
opened = await model._open_websocket_connection(
"wss://example.test/v1/responses",
{"Authorization": "Bearer test-key"},
connect_timeout=10.0,
)
assert opened is ws
assert captured_kwargs["ws_url"] == "wss://example.test/v1/responses"
assert captured_kwargs["additional_headers"] == {"Authorization": "Bearer test-key"}
assert captured_kwargs["open_timeout"] == 10.0
assert captured_kwargs["ping_interval"] == 45.0
assert captured_kwargs["ping_timeout"] is None
@pytest.mark.asyncio
async def test_websocket_model_passes_max_size_to_connect(monkeypatch):
import websockets.asyncio.client as websockets_client
client = DummyWSClient()
model = OpenAIResponsesWSModel(
model="gpt-4",
openai_client=client, # type: ignore[arg-type]
websocket_options={"max_size": 8 * 1024 * 1024},
)
ws = DummyWSConnection([])
captured_kwargs: dict[str, Any] = {}
async def fake_connect(ws_url: str, **kwargs: Any) -> DummyWSConnection:
captured_kwargs["ws_url"] = ws_url
captured_kwargs.update(kwargs)
return ws
monkeypatch.setattr(websockets_client, "connect", fake_connect)
opened = await model._open_websocket_connection(
"wss://example.test/v1/responses",
{"Authorization": "Bearer test-key"},
connect_timeout=10.0,
)
assert opened is ws
assert captured_kwargs["max_size"] == 8 * 1024 * 1024
@pytest.mark.allow_call_model_methods
def test_websocket_model_reconnects_when_reused_from_different_event_loop(monkeypatch):
client = DummyWSClient()
ws1 = DummyWSConnection([_response_completed_frame("resp-1", 1)])
ws2 = DummyWSConnection([_response_completed_frame("resp-2", 2)])
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
opened: list[tuple[str, dict[str, str]]] = []
ws_connections = [ws1, ws2]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
opened.append((ws_url, headers))
return ws_connections.pop(0)
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
async def get_response(input_text: str, previous_response_id: str | None = None):
return await model.get_response(
system_instructions=None,
input=input_text,
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=previous_response_id,
)
loop1 = asyncio.new_event_loop()
loop2 = asyncio.new_event_loop()
try:
first = loop1.run_until_complete(get_response("hi"))
second = loop2.run_until_complete(get_response("next", previous_response_id="resp-1"))
finally:
loop1.close()
loop2.close()
asyncio.set_event_loop(None)
assert first.response_id == "resp-1"
assert second.response_id == "resp-2"
assert len(opened) == 2
assert ws1.close_calls == 1
assert ws2.close_calls == 0
@pytest.mark.allow_call_model_methods
def test_websocket_model_init_lazily_creates_request_lock(monkeypatch):
client = DummyWSClient()
def fail_lock(*args, **kwargs):
raise RuntimeError("asyncio.Lock() should not be called in __init__")
monkeypatch.setattr("agents.models.openai_responses.asyncio.Lock", fail_lock)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
assert model._ws_request_lock is None
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_stream_response_yields_typed_events(monkeypatch):
client = DummyWSClient()
ws = DummyWSConnection([_response_completed_frame("resp-stream", 1)])
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
events = []
async for event in model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
):
events.append(event)
assert len(events) == 1
assert isinstance(events[0], ResponseCompletedEvent)
assert events[0].response.id == "resp-stream"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize("terminal_event_type", ["response.incomplete", "response.failed"])
async def test_websocket_model_get_response_rejects_failed_terminal_response_payload_events(
monkeypatch, terminal_event_type: str
):
client = DummyWSClient()
ws = DummyWSConnection([_response_event_frame(terminal_event_type, "resp-terminal", 1)])
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
with pytest.raises(ModelBehaviorError, match=terminal_event_type):
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize("terminal_event_type", ["response.incomplete", "response.failed"])
async def test_websocket_model_stream_response_rejects_failed_terminal_response_payload_events(
monkeypatch, terminal_event_type: str
):
client = DummyWSClient()
ws = DummyWSConnection([_response_event_frame(terminal_event_type, "resp-terminal", 1)])
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
events = []
with pytest.raises(ModelBehaviorError, match=terminal_event_type):
async for event in model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
):
events.append(event)
assert len(events) == 1
assert events[0].type == terminal_event_type
assert cast(Any, events[0]).response.id == "resp-terminal"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_stream_response_rejects_response_error_terminal_event(monkeypatch):
model = OpenAIResponsesModel(model="gpt-4", openai_client=object()) # type: ignore[arg-type]
async def dummy_fetch_response(
system_instructions,
input,
model_settings,
tools,
output_schema,
handoffs,
previous_response_id,
conversation_id,
stream,
prompt,
):
class DummyStream:
async def __aiter__(self):
yield ResponseErrorEvent(
type="error",
code="invalid_request_error",
message="bad request",
param=None,
sequence_number=0,
)
return DummyStream()
monkeypatch.setattr(model, "_fetch_response", dummy_fetch_response)
events = []
with pytest.raises(ModelBehaviorError, match="invalid_request_error"):
async for event in model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
):
events.append(event)
assert len(events) == 1
assert events[0].type == "error"
assert events[0].code == "invalid_request_error"
assert events[0].message == "bad request"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_get_response_surfaces_response_error_event(monkeypatch):
client = DummyWSClient()
ws = DummyWSConnection([_response_error_frame("invalid_request_error", "bad request", 1)])
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
with pytest.raises(ResponsesWebSocketError, match="response\\.error") as exc_info:
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert "invalid_request_error" in str(exc_info.value)
assert "bad request" in str(exc_info.value)
assert exc_info.value.event_type == "response.error"
assert exc_info.value.code == "invalid_request_error"
assert exc_info.value.error_message == "bad request"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_stream_response_raises_on_response_error_event(monkeypatch):
client = DummyWSClient()
ws = DummyWSConnection([_response_error_frame("invalid_request_error", "bad request", 1)])
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
with pytest.raises(ResponsesWebSocketError, match="response\\.error") as exc_info:
async for _event in model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
):
pass
assert "invalid_request_error" in str(exc_info.value)
assert "bad request" in str(exc_info.value)
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_stream_break_drops_persistent_connection(monkeypatch):
client = DummyWSClient()
ws = DummyWSConnection(
[
_response_event_frame("response.created", "resp-created", 1),
_response_completed_frame("resp-complete", 2),
]
)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
stream = await model._fetch_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=True,
)
stream_agen = cast(Any, stream)
event = await stream_agen.__anext__()
assert event.type == "response.created"
await stream_agen.aclose()
assert ws.close_calls == 0
assert model._ws_connection is None
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_stream_close_after_terminal_event_preserves_persistent_connection(
monkeypatch,
):
client = DummyWSClient()
ws = DummyWSConnection(
[
_response_completed_frame("resp-complete-1", 1),
_response_completed_frame("resp-complete-2", 2),
]
)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
opened: list[DummyWSConnection] = []
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
opened.append(ws)
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
stream = await model._fetch_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
previous_response_id=None,
conversation_id=None,
stream=True,
)
stream_agen = cast(Any, stream)
event = await stream_agen.__anext__()
assert event.type == "response.completed"
await stream_agen.aclose()
assert ws.close_calls == 0
assert model._ws_connection is ws
assert model._ws_request_lock is not None
assert model._ws_request_lock.locked() is False
second = await model.get_response(
system_instructions=None,
input="next",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert second.response_id == "resp-complete-2"
assert len(opened) == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_stream_response_terminal_close_keeps_connection(
monkeypatch,
):
client = DummyWSClient()
ws = DummyWSConnection(
[
_response_completed_frame("resp-complete-1", 1),
_response_completed_frame("resp-complete-2", 2),
]
)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
opened: list[DummyWSConnection] = []
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
opened.append(ws)
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
stream = model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
stream_agen = cast(Any, stream)
event = await stream_agen.__anext__()
assert event.type == "response.completed"
await stream_agen.aclose()
assert ws.close_calls == 0
assert model._ws_connection is ws
second = await model.get_response(
system_instructions=None,
input="next",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert second.response_id == "resp-complete-2"
assert len(opened) == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_stream_response_close_releases_inner_iterator(monkeypatch):
client = DummyWSClient()
ws = DummyWSConnection(
[
_response_event_frame("response.created", "resp-created", 1),
_response_completed_frame("resp-complete", 2),
]
)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
stream = model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
stream_agen = cast(Any, stream)
event = await stream_agen.__anext__()
assert event.type == "response.created"
await stream_agen.aclose()
assert ws.close_calls == 0
assert model._ws_connection is None
assert model._ws_request_lock is not None
assert model._ws_request_lock.locked() is False
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_stream_response_non_terminal_close_does_not_await_close_handshake(
monkeypatch,
):
class BlockingCloseWSConnection(DummyWSConnection):
def __init__(self):
super().__init__(
[
_response_event_frame("response.created", "resp-created", 1),
_response_completed_frame("resp-complete", 2),
]
)
self.close_started = asyncio.Event()
self.close_release = asyncio.Event()
class DummyTransport:
def __init__(inner_self, outer: BlockingCloseWSConnection):
inner_self.outer = outer
inner_self.abort_calls = 0
def abort(inner_self) -> None:
inner_self.abort_calls += 1
self.transport = DummyTransport(self)
async def close(self) -> None:
self.close_calls += 1
self.close_started.set()
await self.close_release.wait()
client = DummyWSClient()
ws = BlockingCloseWSConnection()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
stream = model.stream_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
stream_agen = cast(Any, stream)
event = await stream_agen.__anext__()
assert event.type == "response.created"
try:
await asyncio.wait_for(stream_agen.aclose(), timeout=0.5)
assert ws.transport.abort_calls == 1
assert ws.close_calls == 0
assert model._ws_connection is None
assert model._ws_request_lock is not None
assert model._ws_request_lock.locked() is False
finally:
ws.close_release.set()
await asyncio.sleep(0)
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_cancellation_drops_persistent_connection(monkeypatch):
class CancelOnRecvWSConnection(DummyWSConnection):
async def recv(self) -> str:
raise asyncio.CancelledError()
client = DummyWSClient()
ws = CancelOnRecvWSConnection([])
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
with pytest.raises(asyncio.CancelledError):
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert ws.close_calls == 0
assert model._ws_connection is None
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_cancellation_does_not_await_close_handshake(monkeypatch):
class BlockingCloseCancelOnRecvWSConnection(DummyWSConnection):
def __init__(self):
super().__init__([])
self.recv_started = asyncio.Event()
self.close_started = asyncio.Event()
self.close_release = asyncio.Event()
class DummyTransport:
def __init__(inner_self, outer: BlockingCloseCancelOnRecvWSConnection):
inner_self.outer = outer
inner_self.abort_calls = 0
def abort(inner_self) -> None:
inner_self.abort_calls += 1
self.transport = DummyTransport(self)
async def recv(self) -> str:
self.recv_started.set()
await asyncio.Event().wait()
raise RuntimeError("unreachable")
async def close(self) -> None:
self.close_calls += 1
self.close_started.set()
await self.close_release.wait()
client = DummyWSClient()
ws = BlockingCloseCancelOnRecvWSConnection()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
request_task = asyncio.create_task(
model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
)
await asyncio.wait_for(ws.recv_started.wait(), timeout=1.0)
request_task.cancel()
try:
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(request_task, timeout=0.5)
assert ws.transport.abort_calls == 1
assert ws.close_calls == 0
assert model._ws_connection is None
finally:
ws.close_release.set()
await asyncio.sleep(0)
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_preserves_pre_event_usererror(monkeypatch):
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
raise UserError("websockets dependency missing")
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
with pytest.raises(UserError, match="websockets dependency missing"):
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_preserves_pre_event_server_error_frame_message(monkeypatch):
client = DummyWSClient()
ws = DummyWSConnection(
[
json.dumps(
{
"type": "error",
"error": {"message": "bad auth", "type": "invalid_request_error"},
}
)
]
)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
with pytest.raises(ResponsesWebSocketError, match="Responses websocket error:") as exc_info:
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert "feature may not be enabled" not in str(exc_info.value)
assert "invalid_request_error" in str(exc_info.value)
assert exc_info.value.event_type == "error"
assert exc_info.value.error_type == "invalid_request_error"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_reconnects_if_cached_connection_is_closed(monkeypatch):
client = DummyWSClient()
ws1 = DummyWSConnection([_response_completed_frame("resp-1", 1)])
ws2 = DummyWSConnection([_response_completed_frame("resp-2", 2)])
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
opened: list[DummyWSConnection] = []
queue = [ws1, ws2]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
next_ws = queue.pop(0)
opened.append(next_ws)
return next_ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
first = await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert first.response_id == "resp-1"
assert len(opened) == 1
# Simulate an idle timeout/server-side close on the cached websocket connection.
ws1.close_code = 1001
second = await model.get_response(
system_instructions=None,
input="next",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert second.response_id == "resp-2"
assert len(opened) == 2
assert ws1.close_calls == 1
assert model._ws_connection is ws2
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_does_not_retry_if_send_raises_after_writing_on_reused_connection(
monkeypatch,
):
client = DummyWSClient()
class ConnectionClosedError(Exception):
pass
ConnectionClosedError.__module__ = "websockets.client"
class DropAfterSendWriteOnReuseWSConnection(DummyWSConnection):
def __init__(self, frames: list[str]):
super().__init__(frames)
self.send_calls = 0
async def send(self, payload: str) -> None:
self.send_calls += 1
if self.send_calls > 1:
await super().send(payload)
raise ConnectionClosedError("peer closed during send after request write")
await super().send(payload)
ws1 = DropAfterSendWriteOnReuseWSConnection([_response_completed_frame("resp-1", 1)])
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
open_calls = 0
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
nonlocal open_calls
open_calls += 1
if open_calls > 1:
raise AssertionError("Unexpected websocket retry after send started")
return ws1
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
first = await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
with pytest.raises(RuntimeError, match="before any response events were received"):
await model.get_response(
system_instructions=None,
input="next",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert first.response_id == "resp-1"
assert open_calls == 1
assert ws1.send_calls == 2
assert len(ws1.sent_messages) == 2
assert ws1.close_calls == 1
assert model._ws_connection is None
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_does_not_retry_after_pre_event_disconnect_once_request_sent(
monkeypatch,
):
client = DummyWSClient()
class ConnectionClosedError(Exception):
pass
ConnectionClosedError.__module__ = "websockets.client"
class DisconnectAfterSendWSConnection(DummyWSConnection):
def __init__(self):
super().__init__([])
self.send_calls = 0
self.recv_calls = 0
async def send(self, payload: str) -> None:
self.send_calls += 1
await super().send(payload)
async def recv(self) -> str:
self.recv_calls += 1
raise ConnectionClosedError("peer closed after request send")
ws = DisconnectAfterSendWSConnection()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
open_calls = 0
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DisconnectAfterSendWSConnection:
nonlocal open_calls
open_calls += 1
if open_calls > 1:
raise AssertionError("Unexpected websocket retry after request frame was sent")
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
with pytest.raises(RuntimeError, match="before any response events were received"):
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert open_calls == 1
assert ws.send_calls == 1
assert ws.recv_calls == 1
assert ws.close_calls == 1
assert model._ws_connection is None
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_does_not_retry_after_client_initiated_close(monkeypatch):
client = DummyWSClient()
class ConnectionClosedError(Exception):
pass
ConnectionClosedError.__module__ = "websockets.client"
class AbortableRecvWSConnection(DummyWSConnection):
def __init__(self):
super().__init__([])
self.send_calls = 0
self.recv_started = asyncio.Event()
self.abort_event = asyncio.Event()
class DummyTransport:
def __init__(inner_self, outer: AbortableRecvWSConnection):
inner_self.outer = outer
inner_self.abort_calls = 0
def abort(inner_self) -> None:
inner_self.abort_calls += 1
inner_self.outer.abort_event.set()
self.transport = DummyTransport(self)
async def send(self, payload: str) -> None:
self.send_calls += 1
await super().send(payload)
async def recv(self) -> str:
self.recv_started.set()
await self.abort_event.wait()
raise ConnectionClosedError("client closed websocket")
ws = AbortableRecvWSConnection()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
open_calls = 0
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> AbortableRecvWSConnection:
nonlocal open_calls
open_calls += 1
if open_calls > 1:
raise AssertionError("Unexpected websocket reconnect after client close")
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
request_task = asyncio.create_task(
model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
)
await asyncio.wait_for(ws.recv_started.wait(), timeout=1.0)
await asyncio.wait_for(model.close(), timeout=1.0)
with pytest.raises(ConnectionClosedError, match="client closed websocket"):
await asyncio.wait_for(request_task, timeout=1.0)
assert open_calls == 1
assert ws.send_calls == 1
assert ws.transport.abort_calls == 1
assert model._ws_connection is None
@pytest.mark.allow_call_model_methods
def test_websocket_model_prepare_websocket_url_preserves_non_tls_scheme_mapping():
client = DummyWSClient()
client.base_url = httpx.URL("http://127.0.0.1:8080/v1/")
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query=None)
assert ws_url == "ws://127.0.0.1:8080/v1/responses"
@pytest.mark.allow_call_model_methods
def test_websocket_model_prepare_websocket_url_appends_path_with_existing_query():
client = DummyWSClient()
client.websocket_base_url = "wss://proxy.example.test/v1?token=abc"
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query={"route": "team-a"})
parsed = httpx.URL(ws_url)
assert parsed.path == "/v1/responses"
assert dict(parsed.params) == {"token": "abc", "route": "team-a"}
@pytest.mark.allow_call_model_methods
@pytest.mark.parametrize(
("configured_ws_base_url", "expected_scheme"),
[
("http://proxy.example.test/v1?token=abc", "ws"),
("https://proxy.example.test/v1?token=abc", "wss"),
],
)
def test_websocket_model_prepare_websocket_url_normalizes_explicit_http_schemes(
configured_ws_base_url: str, expected_scheme: str
):
client = DummyWSClient()
client.websocket_base_url = configured_ws_base_url
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query={"route": "team-a"})
parsed = httpx.URL(ws_url)
assert parsed.scheme == expected_scheme
assert parsed.path == "/v1/responses"
assert dict(parsed.params) == {"token": "abc", "route": "team-a"}
@pytest.mark.allow_call_model_methods
@pytest.mark.parametrize("extra_query", [omit, NOT_GIVEN])
def test_websocket_model_prepare_websocket_url_treats_top_level_omit_sentinels_as_absent(
extra_query,
):
client = DummyWSClient()
client.websocket_base_url = "wss://proxy.example.test/v1?token=abc"
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query=extra_query)
parsed = httpx.URL(ws_url)
assert parsed.path == "/v1/responses"
assert dict(parsed.params) == {"token": "abc"}
@pytest.mark.allow_call_model_methods
def test_websocket_model_prepare_websocket_url_skips_not_given_query_values():
client = DummyWSClient()
client.websocket_base_url = "wss://proxy.example.test/v1?token=abc"
client.default_query = {"api-version": NOT_GIVEN, "route": "team-a"}
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query={"tenant": NOT_GIVEN, "region": "us"})
parsed = httpx.URL(ws_url)
assert parsed.path == "/v1/responses"
assert dict(parsed.params) == {"token": "abc", "route": "team-a", "region": "us"}
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_prepare_websocket_request_filters_omit_from_extra_body():
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
frame, _ws_url, _headers = await model._prepare_websocket_request(
{
"model": "gpt-4",
"input": "hi",
"stream": True,
"extra_body": {"keep": "value", "drop": omit},
}
)
assert frame["type"] == "response.create"
assert frame["keep"] == "value"
assert "drop" not in frame
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize("extra_body", [omit, NOT_GIVEN])
async def test_websocket_model_prepare_websocket_request_ignores_top_level_extra_body_sentinels(
extra_body,
):
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
frame, _ws_url, _headers = await model._prepare_websocket_request(
{
"model": "gpt-4",
"input": "hi",
"stream": True,
"extra_body": extra_body,
}
)
assert frame["type"] == "response.create"
assert frame["stream"] is True
assert frame["model"] == "gpt-4"
assert frame["input"] == "hi"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_prepare_websocket_request_preserves_envelope_fields():
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
frame, _ws_url, _headers = await model._prepare_websocket_request(
{
"model": "gpt-4",
"input": "hi",
"stream": True,
"extra_body": {
"type": "not-response-create",
"stream": False,
"custom": "value",
},
}
)
assert frame["type"] == "response.create"
assert frame["stream"] is True
assert frame["custom"] == "value"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_prepare_websocket_request_strips_client_timeout_kwarg():
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
frame, _ws_url, _headers = await model._prepare_websocket_request(
{
"model": "gpt-4",
"input": "hi",
"stream": True,
"timeout": 30.0,
"metadata": {"request_id": "123"},
}
)
assert frame["type"] == "response.create"
assert frame["metadata"] == {"request_id": "123"}
assert "timeout" not in frame
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_prepare_websocket_request_skips_not_given_values():
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
frame, _ws_url, _headers = await model._prepare_websocket_request(
{
"model": "gpt-4",
"input": "hi",
"stream": True,
"user": NOT_GIVEN,
"stream_options": NOT_GIVEN,
"extra_body": {
"metadata": {"request_id": "123"},
"optional_field": NOT_GIVEN,
},
}
)
assert frame["type"] == "response.create"
assert frame["stream"] is True
assert frame["metadata"] == {"request_id": "123"}
assert "user" not in frame
assert "stream_options" not in frame
assert "optional_field" not in frame
json.dumps(frame)
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_get_response_applies_timeout_to_recv(monkeypatch):
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
class SlowRecvWSConnection(DummyWSConnection):
async def recv(self) -> str:
await asyncio.sleep(0.2)
return await super().recv()
ws = SlowRecvWSConnection([_response_completed_frame("resp-timeout", 1)])
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
with pytest.raises(TimeoutError, match="Responses websocket receive timed out"):
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(extra_args={"timeout": 0.01}),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert ws.close_calls == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_get_response_marks_partial_receive_timeout_unsafe_to_replay(
monkeypatch,
):
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
class PartialThenSlowRecvWSConnection(DummyWSConnection):
def __init__(self) -> None:
super().__init__([_response_event_frame("response.created", "resp-partial", 1)])
self.recv_calls = 0
async def recv(self) -> str:
self.recv_calls += 1
if self.recv_calls == 1:
return await super().recv()
await asyncio.sleep(0.2)
return await super().recv()
ws = PartialThenSlowRecvWSConnection()
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
with pytest.raises(TimeoutError, match="Responses websocket receive timed out") as exc_info:
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(extra_args={"timeout": 0.01}),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
error = exc_info.value
assert getattr(error, "_openai_agents_ws_replay_safety", None) == "unsafe"
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
)
)
assert advice is not None
assert advice.suggested is False
assert advice.replay_safety == "unsafe"
assert ws.close_calls == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_get_response_applies_timeout_while_waiting_for_request_lock(
monkeypatch,
):
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
recv_started = asyncio.Event()
release_first_request = asyncio.Event()
class BlockingRecvWSConnection(DummyWSConnection):
async def recv(self) -> str:
recv_started.set()
await release_first_request.wait()
return await super().recv()
ws = BlockingRecvWSConnection(
[
_response_completed_frame("resp-lock-1", 1),
_response_completed_frame("resp-lock-2", 2),
]
)
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
first_task = asyncio.create_task(
model.get_response(
system_instructions=None,
input="first",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
)
await asyncio.wait_for(recv_started.wait(), timeout=1.0)
with pytest.raises(TimeoutError, match="request lock wait timed out"):
await model.get_response(
system_instructions=None,
input="second",
model_settings=ModelSettings(extra_args={"timeout": 0.01}),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
release_first_request.set()
first_response = await first_task
assert first_response.response_id == "resp-lock-1"
assert len(ws.sent_messages) == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_get_response_allows_zero_pool_timeout_when_lock_uncontended(
monkeypatch,
):
client = DummyWSClient()
client.timeout = httpx.Timeout(connect=1.0, read=1.0, write=1.0, pool=0.0)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws = DummyWSConnection([_response_completed_frame("resp-zero-pool", 1)])
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
response = await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert response.response_id == "resp-zero-pool"
assert len(ws.sent_messages) == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_get_response_allows_zero_timeout_when_ws_ops_are_immediate(
monkeypatch,
):
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws = DummyWSConnection([_response_completed_frame("resp-zero-timeout", 1)])
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
response = await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(extra_args={"timeout": 0}),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert response.response_id == "resp-zero-timeout"
assert len(ws.sent_messages) == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_get_response_uses_client_default_timeout_when_no_override(
monkeypatch,
):
client = DummyWSClient()
client.timeout = httpx.Timeout(connect=1.0, read=0.01, write=1.0, pool=1.0)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
class SlowRecvWSConnection(DummyWSConnection):
async def recv(self) -> str:
await asyncio.sleep(0.2)
return await super().recv()
ws = SlowRecvWSConnection([_response_completed_frame("resp-timeout-default", 1)])
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
with pytest.raises(TimeoutError, match="Responses websocket receive timed out"):
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert ws.close_calls == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_get_response_uses_client_default_timeout_when_override_is_not_given(
monkeypatch,
):
client = DummyWSClient()
client.timeout = httpx.Timeout(connect=1.0, read=0.01, write=1.0, pool=1.0)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
class SlowRecvWSConnection(DummyWSConnection):
async def recv(self) -> str:
await asyncio.sleep(0.2)
return await super().recv()
ws = SlowRecvWSConnection([_response_completed_frame("resp-timeout-not-given", 1)])
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
with pytest.raises(TimeoutError, match="Responses websocket receive timed out"):
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(extra_args={"timeout": NOT_GIVEN}),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert ws.close_calls == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_prepare_websocket_request_includes_client_auth_headers():
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
_frame, _ws_url, headers = await model._prepare_websocket_request(
{
"model": "gpt-4",
"input": "hi",
"stream": True,
}
)
assert headers["Authorization"] == "Bearer test-key"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_default_headers_override_auth_case_insensitively():
client = DummyWSClient()
client.default_headers["authorization"] = "Bearer override-key"
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
_frame, _ws_url, headers = await model._prepare_websocket_request(
{
"model": "gpt-4",
"input": "hi",
"stream": True,
}
)
assert headers["authorization"] == "Bearer override-key"
assert "Authorization" not in headers
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_prepare_websocket_request_omit_removes_inherited_header():
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
_frame, _ws_url, headers = await model._prepare_websocket_request(
{
"model": "gpt-4",
"input": "hi",
"stream": True,
"extra_headers": {"User-Agent": omit},
}
)
assert "Authorization" in headers
assert "User-Agent" not in headers
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_prepare_websocket_request_replaces_header_case_insensitively():
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
_frame, _ws_url, headers = await model._prepare_websocket_request(
{
"model": "gpt-4",
"input": "hi",
"stream": True,
"extra_headers": {
"authorization": "Bearer override-key",
"user-agent": "Custom UA",
},
}
)
assert headers["authorization"] == "Bearer override-key"
assert headers["user-agent"] == "Custom UA"
assert "Authorization" not in headers
assert "User-Agent" not in headers
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_prepare_websocket_request_skips_not_given_header_values():
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
_frame, _ws_url, headers = await model._prepare_websocket_request(
{
"model": "gpt-4",
"input": "hi",
"stream": True,
"extra_headers": {
"Authorization": NOT_GIVEN,
"X-Optional": NOT_GIVEN,
},
}
)
assert headers["Authorization"] == "Bearer test-key"
assert "X-Optional" not in headers
assert "NOT_GIVEN" not in headers.values()
@pytest.mark.allow_call_model_methods
def test_websocket_model_prepare_websocket_url_includes_client_default_query():
client = DummyWSClient()
client.websocket_base_url = "wss://proxy.example.test/v1?token=abc"
client.default_query = {"api-version": "2025-01-01-preview", "omit_me": omit}
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(
extra_query={"route": "team-a", "api-version": "2026-01-01-preview"}
)
parsed = httpx.URL(ws_url)
assert parsed.path == "/v1/responses"
assert dict(parsed.params) == {
"token": "abc",
"api-version": "2026-01-01-preview",
"route": "team-a",
}
@pytest.mark.allow_call_model_methods
def test_websocket_model_prepare_websocket_url_omit_removes_inherited_query_params():
client = DummyWSClient()
client.websocket_base_url = "wss://proxy.example.test/v1?token=abc"
client.default_query = {"route": "team-a", "region": "us"}
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query={"token": omit, "route": omit, "keep": "1"})
parsed = httpx.URL(ws_url)
assert parsed.path == "/v1/responses"
assert dict(parsed.params) == {"region": "us", "keep": "1"}
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_close_closes_persistent_connection(monkeypatch):
client = DummyWSClient()
ws = DummyWSConnection([_response_completed_frame("resp-close", 1)])
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
async def fake_open(
ws_url: str, headers: dict[str, str], *, connect_timeout: float | None = None
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
assert ws.close_calls == 0
await model.close()
assert ws.close_calls == 1
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_close_falls_back_to_transport_abort_on_close_error():
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
class DummyTransport:
def __init__(self):
self.abort_calls = 0
def abort(self):
self.abort_calls += 1
class FailingWSConnection:
def __init__(self):
self.transport = DummyTransport()
async def close(self):
raise RuntimeError("attached to a different loop")
ws = FailingWSConnection()
model._ws_connection = ws
model._ws_connection_identity = ("wss://example.test", (("authorization", "x"),))
await model.close()
assert ws.transport.abort_calls == 1
assert model._ws_connection is None
assert model._ws_connection_identity is None
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_close_does_not_wait_for_held_request_lock():
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
request_lock = model._get_ws_request_lock()
await request_lock.acquire()
class DummyTransport:
def __init__(self):
self.abort_calls = 0
def abort(self):
self.abort_calls += 1
class HangingCloseWSConnection:
def __init__(self):
self.transport = DummyTransport()
self.close_calls = 0
async def close(self) -> None:
self.close_calls += 1
await asyncio.sleep(3600)
ws = HangingCloseWSConnection()
model._ws_connection = ws
model._ws_connection_identity = ("wss://example.test", (("authorization", "x"),))
try:
await asyncio.wait_for(model.close(), timeout=0.1)
finally:
if request_lock.locked():
request_lock.release()
assert ws.transport.abort_calls == 1
assert ws.close_calls == 0
assert model._ws_connection is None
assert model._ws_connection_identity is None
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_open_websocket_connection_disables_message_size_limit(monkeypatch):
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
captured: dict[str, Any] = {}
sentinel = object()
async def fake_connect(*args: Any, **kwargs: Any) -> object:
captured["args"] = args
captured["kwargs"] = kwargs
return sentinel
monkeypatch.setattr("websockets.asyncio.client.connect", fake_connect)
result = await model._open_websocket_connection(
"wss://proxy.example.test/v1/responses",
{"Authorization": "Bearer test-key"},
connect_timeout=None,
)
assert result is sentinel
assert captured["args"] == ("wss://proxy.example.test/v1/responses",)
assert captured["kwargs"]["user_agent_header"] is None
assert captured["kwargs"]["additional_headers"] == {"Authorization": "Bearer test-key"}
assert captured["kwargs"]["max_size"] is None
assert captured["kwargs"]["open_timeout"] is None
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_open_websocket_connection_honors_connect_timeout(monkeypatch):
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
captured: dict[str, Any] = {}
sentinel = object()
async def fake_connect(*args: Any, **kwargs: Any) -> object:
captured["args"] = args
captured["kwargs"] = kwargs
return sentinel
monkeypatch.setattr("websockets.asyncio.client.connect", fake_connect)
result = await model._open_websocket_connection(
"wss://proxy.example.test/v1/responses",
{"Authorization": "Bearer test-key"},
connect_timeout=42.0,
)
assert result is sentinel
assert captured["kwargs"]["open_timeout"] == 42.0
@pytest.mark.allow_call_model_methods
def test_get_retry_advice_uses_openai_headers() -> None:
request = httpx.Request("POST", "https://api.openai.com/v1/responses")
response = httpx.Response(
429,
request=request,
headers={
"x-should-retry": "true",
"retry-after-ms": "250",
"x-request-id": "req_456",
},
json={"error": {"code": "rate_limit"}},
)
error = RateLimitError(
"rate limited", response=response, body={"error": {"code": "rate_limit"}}
)
model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, object()))
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
)
)
assert advice is not None
assert advice.suggested is True
assert advice.retry_after == 0.25
assert advice.replay_safety == "safe"
assert advice.normalized is not None
assert advice.normalized.error_code == "rate_limit"
assert advice.normalized.status_code == 429
assert advice.normalized.request_id == "req_456"
@pytest.mark.allow_call_model_methods
def test_get_retry_advice_keeps_stateful_transport_failures_ambiguous() -> None:
model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, object()))
error = APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://api.openai.com/v1/responses"),
)
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
previous_response_id="resp_prev",
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety is None
assert advice.normalized is not None
assert advice.normalized.is_network_error is True
@pytest.mark.allow_call_model_methods
def test_get_retry_advice_marks_stateful_http_failures_replay_safe() -> None:
request = httpx.Request("POST", "https://api.openai.com/v1/responses")
response = httpx.Response(
429,
request=request,
json={"error": {"code": "rate_limit"}},
)
error = RateLimitError(
"rate limited", response=response, body={"error": {"code": "rate_limit"}}
)
model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, object()))
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
previous_response_id="resp_prev",
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety == "safe"
assert advice.normalized is not None
assert advice.normalized.status_code == 429
@pytest.mark.allow_call_model_methods
def test_get_retry_advice_keeps_stateless_transport_failures_retryable() -> None:
model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, object()))
error = APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://api.openai.com/v1/responses"),
)
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety is None
assert advice.normalized is not None
assert advice.normalized.is_network_error is True
@pytest.mark.allow_call_model_methods
def test_websocket_get_retry_advice_marks_ambiguous_replay_unsafe() -> None:
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient()))
error = RuntimeError("Responses websocket connection closed before a terminal response event.")
error.__cause__ = _connection_closed_error("peer closed after request send")
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=True,
previous_response_id="resp_prev",
)
)
assert advice is not None
assert advice.suggested is False
assert advice.replay_safety == "unsafe"
@pytest.mark.allow_call_model_methods
def test_websocket_get_retry_advice_allows_stateless_ambiguous_disconnect_retry() -> None:
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient()))
error = RuntimeError("Responses websocket connection closed before a terminal response event.")
error.__cause__ = _connection_closed_error("peer closed after request send")
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=True,
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety is None
@pytest.mark.allow_call_model_methods
def test_websocket_get_retry_advice_keeps_wrapped_pre_send_disconnect_safe() -> None:
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient()))
error = RuntimeError(
"Responses websocket connection closed before any response events were received."
)
setattr(error, "_openai_agents_ws_replay_safety", "safe") # noqa: B010
error.__cause__ = _connection_closed_error("peer closed before request send")
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=True,
previous_response_id="resp_prev",
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety == "safe"
@pytest.mark.allow_call_model_methods
def test_websocket_get_retry_advice_allows_stateless_wrapped_post_send_disconnect_retry() -> None:
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient()))
error = RuntimeError(
"Responses websocket connection closed before any response events were received."
)
setattr(error, "_openai_agents_ws_replay_safety", "unsafe") # noqa: B010
error.__cause__ = _connection_closed_error("peer closed after request send")
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=True,
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety is None
@pytest.mark.allow_call_model_methods
def test_websocket_get_retry_advice_allows_stateless_nonstream_post_send_retry() -> None:
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient()))
error = RuntimeError(
"Responses websocket connection closed before any response events were received."
)
setattr(error, "_openai_agents_ws_replay_safety", "unsafe") # noqa: B010
error.__cause__ = _connection_closed_error("peer closed after request send")
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety is None
@pytest.mark.allow_call_model_methods
def test_websocket_get_retry_advice_marks_wrapped_post_send_disconnect_unsafe() -> None:
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient()))
error = RuntimeError(
"Responses websocket connection closed before any response events were received."
)
setattr(error, "_openai_agents_ws_replay_safety", "unsafe") # noqa: B010
error.__cause__ = _connection_closed_error("peer closed after request send")
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=True,
previous_response_id="resp_prev",
)
)
assert advice is not None
assert advice.suggested is False
assert advice.replay_safety == "unsafe"
@pytest.mark.allow_call_model_methods
def test_websocket_get_retry_advice_marks_partial_nonstream_failure_unsafe() -> None:
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient()))
error = TimeoutError("Responses websocket receive timed out after 5.0 seconds.")
setattr(error, "_openai_agents_ws_replay_safety", "unsafe") # noqa: B010
setattr(error, "_openai_agents_ws_response_started", True) # noqa: B010
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
)
)
assert advice is not None
assert advice.suggested is False
assert advice.replay_safety == "unsafe"
@pytest.mark.allow_call_model_methods
def test_websocket_get_retry_advice_marks_connect_timeout_replay_safe() -> None:
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient()))
error = TimeoutError("Responses websocket connect timed out after 5.0 seconds.")
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=True,
previous_response_id="resp_prev",
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety == "safe"
@pytest.mark.allow_call_model_methods
def test_websocket_get_retry_advice_marks_request_lock_timeout_replay_safe() -> None:
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient()))
error = TimeoutError("Responses websocket request lock wait timed out after 5.0 seconds.")
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
previous_response_id="resp_prev",
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety == "safe"
@pytest.mark.allow_call_model_methods
def test_websocket_get_retry_advice_marks_stateful_receive_timeout_unsafe() -> None:
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient()))
error = TimeoutError("Responses websocket receive timed out after 5.0 seconds.")
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=True,
previous_response_id="resp_prev",
)
)
assert advice is not None
assert advice.suggested is False
assert advice.replay_safety == "unsafe"
@pytest.mark.allow_call_model_methods
def test_websocket_get_retry_advice_allows_stateless_receive_timeout_retry() -> None:
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=cast(Any, DummyWSClient()))
error = TimeoutError("Responses websocket receive timed out after 5.0 seconds.")
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=True,
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety is None
def test_get_client_disables_provider_managed_retries_when_requested() -> None:
class DummyClient:
def __init__(self):
self.calls: list[dict[str, int]] = []
def with_options(self, **kwargs):
self.calls.append(kwargs)
return "retry-client"
client = DummyClient()
model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, client))
assert cast(object, model._get_client()) is client
with provider_managed_retries_disabled(True):
assert cast(object, model._get_client()) == "retry-client"
assert client.calls == [{"max_retries": 0}]
def test_websocket_pre_event_disconnect_retry_respects_websocket_retry_disable() -> None:
assert _should_retry_pre_event_websocket_disconnect() is True
with websocket_pre_event_retries_disabled(True):
assert _should_retry_pre_event_websocket_disconnect() is False