Files
livekit--agents/tests/test_plugin_openai_responses.py
2026-07-13 13:39:38 +08:00

110 lines
3.7 KiB
Python

from __future__ import annotations
import json
import aiohttp
import pytest
from openai.types import Reasoning
from livekit.plugins.openai.responses.llm import LLMStream, _ResponsesWebsocket
pytestmark = pytest.mark.plugin("openai")
class _FakeWSMsg:
def __init__(self, data: str) -> None:
self.type = aiohttp.WSMsgType.TEXT
self.data = data
self.extra = None
class _RecordingWS:
"""Minimal aiohttp-websocket stand-in: records what was sent, then replays
a single terminal frame so `generate_response` returns."""
def __init__(self, reply: dict) -> None:
self.sent: str | None = None
self._reply = reply
async def send_str(self, data: str) -> None:
self.sent = data
async def receive(self) -> _FakeWSMsg:
return _FakeWSMsg(json.dumps(self._reply))
class _FakePool:
def __init__(self, ws: _RecordingWS) -> None:
self._ws = ws
def connection(self, timeout: float | None = None): # noqa: ANN201
ws = self._ws
class _Ctx:
async def __aenter__(self): # noqa: ANN202
return ws
async def __aexit__(self, *exc): # noqa: ANN002, ANN202
return False
return _Ctx()
async def aclose(self) -> None:
return None
async def _capture_sent_payload(msg: dict) -> dict:
"""Run the real `_ResponsesWebsocket.generate_response` against a fake pool
and return the JSON that was actually put on the wire."""
ws = _ResponsesWebsocket(api_key="test-key", timeout=1.0)
rec = _RecordingWS({"type": "response.completed", "response": {"output": []}})
ws._pool = _FakePool(rec) # type: ignore[assignment]
async for _ in ws.generate_response(msg):
pass
assert rec.sent is not None
return json.loads(rec.sent)
async def test_reasoning_object_serialized_without_null_fields() -> None:
"""The WS transport serializes request models itself (not via the openai
SDK). It must drop unset/None fields, otherwise Optional fields default to
an explicit `null` on the wire and the Responses API 400s — e.g. after
openai-python added `Reasoning.mode` (default None), `Reasoning(effort=...)`
began emitting `"mode": null`, rejected with 'expected one of standard or
pro, but got null instead.' Regression guard for that class of bug."""
payload = {
"type": "response.create",
"model": "gpt-5.4",
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
"reasoning": Reasoning(effort="none"),
}
sent = await _capture_sent_payload(payload)
assert sent["reasoning"] == {"effort": "none"}
# No serialized request model may carry an explicit null-valued key.
assert None not in sent["reasoning"].values()
def test_error_event_missing_sequence_number_parses_cleanly() -> None:
"""Top-level protocol error frames (e.g. a request-validation 400) don't
carry `sequence_number`, which ResponseErrorEvent marks required. Parsing
must not raise a pydantic ValidationError that masks the real API message —
it should surface the message so it reaches the caller as an APIStatusError."""
frame = {
"type": "error",
"message": "Invalid type for 'reasoning.mode': expected one of "
"'standard' or 'pro', but got null instead.",
"code": "invalid_type",
"param": "reasoning.mode",
"status": 400,
}
# `_parse_ws_event` does not read `self`; invoke it directly on the frame.
parsed = LLMStream._parse_ws_event(object(), frame) # type: ignore[arg-type]
assert parsed is not None
assert parsed.type == "error"
assert parsed.message == frame["message"]
assert parsed.param == "reasoning.mode"