110 lines
3.7 KiB
Python
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"
|