test(realtime): record and replay websocket traffic in redis vcr cassettes (#32390)

* test(realtime): record and replay websocket traffic in redis vcr cassettes

* style(realtime): ruff-format ws-vcr harness

* fix(realtime): warn instead of silently disabling ws-vcr when the redis client cannot be built
This commit is contained in:
Mateo Wang 2026-07-08 00:19:06 -07:00 committed by GitHub
parent 684e3e1c2e
commit cd6e8cdf23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 917 additions and 5 deletions

546
tests/_ws_vcr.py Normal file
View file

@ -0,0 +1,546 @@
"""Record and replay realtime WebSocket traffic in the shared VCR Redis store.
The HTTP VCR layer (``tests/_vcr_redis_persister.py`` /
``tests/_vcr_conftest_common.py``) only intercepts httpx/aiohttp, so the
realtime suite always reached the live provider. This module intercepts at the
``websockets.connect`` boundary instead and caches whole WebSocket sessions
under a distinct ``litellm:vcr:wscassette:`` key, reusing the same Redis client,
24h TTL, save-on-pass, and best-effort degradation semantics.
Record mode logs every frame in order with its direction, a text/binary flag,
and, for each server frame, the number of client frames seen before it. That
count is the causal gate for replay: a recorded server frame is only released
once the client has sent at least that many frames, so the deterministic replay
reproduces the same interleaving without a live connection. Client frames are
matched against the recording with volatile fields (ids, timestamps) normalized
away; a structurally different client frame is contract drift and raises loudly
rather than hanging, and every replay wait is bounded by a timeout.
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
import re
import warnings
from typing import AsyncIterator, Callable, Literal, Optional, Protocol, Union
from pydantic import BaseModel, ConfigDict, ValidationError
from websockets.exceptions import ConnectionClosedOK
from tests._vcr_redis_persister import (
CASSETTE_TTL_SECONDS,
VCRCassetteCacheWarning,
_build_default_client,
_record_cache_failure,
)
WS_REDIS_KEY_PREFIX = "litellm:vcr:wscassette:"
WS_MAX_SESSIONS_PER_CASSETTE = 20
WS_MAX_FRAMES_PER_SESSION = 2000
WS_REPLAY_TIMEOUT_ENV = "LITELLM_WS_VCR_REPLAY_TIMEOUT"
WS_DEFAULT_REPLAY_TIMEOUT_SECONDS = 15.0
WS_CASSETTE_SCHEMA_VERSION = 1
_log = logging.getLogger(__name__)
Message = Union[str, bytes]
Direction = Literal["client_to_server", "server_to_client"]
Opcode = Literal["text", "binary"]
class WsConnectionLike(Protocol):
async def recv(self, decode: Optional[bool] = None) -> Message: ...
async def send(self, message: Message, *args: object, **kwargs: object) -> None: ...
async def close(self, *args: object, **kwargs: object) -> None: ...
def __aiter__(self) -> AsyncIterator[Message]: ...
class WsConnectContextLike(Protocol):
async def __aenter__(self) -> WsConnectionLike: ...
async def __aexit__(self, *exc_info: object) -> Optional[bool]: ...
class RedisLike(Protocol):
def get(self, key: str) -> Optional[bytes]: ...
def set(self, key: str, value: bytes, ex: int) -> object: ...
class WsFrame(BaseModel):
model_config = ConfigDict(frozen=True)
direction: Direction
opcode: Opcode
text: Optional[str] = None
binary_b64: Optional[str] = None
client_frames_before: Optional[int] = None
class WsSession(BaseModel):
model_config = ConfigDict(frozen=True)
frames: tuple[WsFrame, ...]
class WsCassette(BaseModel):
model_config = ConfigDict(frozen=True)
schema_version: int = WS_CASSETTE_SCHEMA_VERSION
sessions: tuple[WsSession, ...]
class WsVcrReplayError(Exception): ...
class WsVcrContractDrift(WsVcrReplayError): ...
class WsVcrReplayTimeout(WsVcrReplayError): ...
_UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
_OPENAI_ID_RE = re.compile(
r"\b(?:evt|event|item|msg|resp|response|sess|session|call|fc|rs|conv|ce|audio)_[A-Za-z0-9]{6,}"
)
_ISO_TS_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?")
_EPOCH_RE = re.compile(r"(?<![\d.])1[0-9]{9,12}(?![\d.])")
_VOLATILE_KEYS = frozenset(
{
"event_id",
"item_id",
"previous_item_id",
"response_id",
"id",
"session_id",
"call_id",
"conversation_id",
}
)
_BEARER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._\-]+")
_OPENAI_KEY_RE = re.compile(r"sk-[A-Za-z0-9_\-]{8,}")
_XAI_KEY_RE = re.compile(r"xai-[A-Za-z0-9_\-]{8,}")
def scrub_secrets(text: str) -> str:
scrubbed = _BEARER_RE.sub("Bearer <redacted>", text)
scrubbed = _OPENAI_KEY_RE.sub("<redacted-key>", scrubbed)
scrubbed = _XAI_KEY_RE.sub("<redacted-key>", scrubbed)
return scrubbed
def _normalize_json_for_match(obj: object) -> object:
if isinstance(obj, dict):
return {
str(key): ("<vcr-id>" if key in _VOLATILE_KEYS else _normalize_json_for_match(value))
for key, value in sorted(obj.items(), key=lambda kv: str(kv[0]))
}
if isinstance(obj, list):
return [_normalize_json_for_match(item) for item in obj]
if isinstance(obj, str):
return _normalize_scalar_string(obj)
return obj
def _normalize_scalar_string(text: str) -> str:
normalized = _UUID_RE.sub("<vcr-uuid>", text)
normalized = _OPENAI_ID_RE.sub("<vcr-id>", normalized)
normalized = _ISO_TS_RE.sub("<vcr-iso-ts>", normalized)
normalized = _EPOCH_RE.sub("<vcr-epoch>", normalized)
return normalized
def normalize_text_for_match(text: str) -> str:
try:
parsed = json.loads(text)
except (ValueError, TypeError):
return _normalize_scalar_string(text)
return json.dumps(_normalize_json_for_match(parsed), sort_keys=True, separators=(",", ":"))
def text_frames_match(recorded: str, incoming: str) -> bool:
return normalize_text_for_match(recorded) == normalize_text_for_match(incoming)
def _frame_payload(message: Message) -> tuple[Opcode, Optional[str], Optional[str]]:
if isinstance(message, str):
return "text", scrub_secrets(message), None
try:
decoded = message.decode("utf-8")
except UnicodeDecodeError:
return "binary", None, base64.b64encode(message).decode("ascii")
return "text", scrub_secrets(decoded), None
def ws_redis_key_for(nodeid: str) -> str:
rel = nodeid.replace("::", "/").replace("\\", "/").lstrip("./")
return f"{WS_REDIS_KEY_PREFIX}{rel}"
def replay_timeout_seconds() -> float:
raw = os.environ.get(WS_REPLAY_TIMEOUT_ENV)
if not raw:
return WS_DEFAULT_REPLAY_TIMEOUT_SECONDS
try:
return float(raw)
except ValueError:
return WS_DEFAULT_REPLAY_TIMEOUT_SECONDS
class WsSessionRecorder:
def __init__(self) -> None:
self._frames: list[WsFrame] = []
self._client_count = 0
def record_client_frame(self, message: Message) -> None:
opcode, text, binary_b64 = _frame_payload(message)
self._frames.append(WsFrame(direction="client_to_server", opcode=opcode, text=text, binary_b64=binary_b64))
self._client_count += 1
def record_server_frame(self, message: Message) -> None:
opcode, text, binary_b64 = _frame_payload(message)
self._frames.append(
WsFrame(
direction="server_to_client",
opcode=opcode,
text=text,
binary_b64=binary_b64,
client_frames_before=self._client_count,
)
)
def to_session(self) -> WsSession:
return WsSession(frames=tuple(self._frames))
class RecordingConnection:
def __init__(self, real: WsConnectionLike, recorder: WsSessionRecorder) -> None:
self._real = real
self._recorder = recorder
async def recv(self, decode: Optional[bool] = None) -> Message:
result = await self._real.recv(decode=decode)
self._recorder.record_server_frame(result)
return result
async def send(self, message: Message, *args: object, **kwargs: object) -> None:
self._recorder.record_client_frame(message)
await self._real.send(message, *args, **kwargs)
async def close(self, *args: object, **kwargs: object) -> None:
await self._real.close(*args, **kwargs)
def __aiter__(self) -> AsyncIterator[Message]:
return self._iterate()
async def _iterate(self) -> AsyncIterator[Message]:
async for message in self._real:
self._recorder.record_server_frame(message)
yield message
class ReplayConnection:
def __init__(
self,
session: WsSession,
timeout: float,
on_error: Callable[[WsVcrReplayError], None],
) -> None:
self._server_frames = tuple(f for f in session.frames if f.direction == "server_to_client")
self._client_frames = tuple(f for f in session.frames if f.direction == "client_to_server")
self._timeout = timeout
self._on_error = on_error
self._server_cursor = 0
self._client_cursor = 0
self._client_sent = 0
self._closed = False
self._progress = asyncio.Event()
async def recv(self, decode: Optional[bool] = None) -> Message:
want_bytes = decode is False
while True:
if self._closed or self._server_cursor >= len(self._server_frames):
raise ConnectionClosedOK(None, None)
frame = self._server_frames[self._server_cursor]
needed = frame.client_frames_before or 0
if self._client_sent >= needed:
self._server_cursor += 1
return _materialize_frame(frame, want_bytes)
await self._await_client_progress(needed)
async def _await_client_progress(self, needed: int) -> None:
waiter = self._progress
try:
await asyncio.wait_for(waiter.wait(), timeout=self._timeout)
except asyncio.TimeoutError:
error = WsVcrReplayTimeout(
f"WS-VCR replay stalled: server frame #{self._server_cursor} needs "
f"{needed} client frame(s) but only {self._client_sent} were sent within "
f"{self._timeout}s. The client stopped driving the recorded session."
)
self._on_error(error)
raise error
async def send(self, message: Message, *args: object, **kwargs: object) -> None:
if self._client_cursor >= len(self._client_frames):
error = WsVcrContractDrift(
"WS-VCR contract drift: client sent frame "
f"#{self._client_cursor + 1} but the recording has only "
f"{len(self._client_frames)} client frame(s). Extra frame: {_preview(message)}"
)
self._on_error(error)
raise error
recorded = self._client_frames[self._client_cursor]
if not _client_frame_matches(recorded, message):
error = WsVcrContractDrift(
"WS-VCR contract drift on client frame "
f"#{self._client_cursor + 1}:\n recorded: {_preview_frame(recorded)}\n"
f" got: {_preview(message)}"
)
self._on_error(error)
raise error
self._client_cursor += 1
self._client_sent += 1
self._signal_progress()
async def close(self, *args: object, **kwargs: object) -> None:
self._closed = True
self._signal_progress()
def _signal_progress(self) -> None:
previous = self._progress
self._progress = asyncio.Event()
previous.set()
def __aiter__(self) -> AsyncIterator[Message]:
return self._iterate()
async def _iterate(self) -> AsyncIterator[Message]:
while True:
try:
yield await self.recv()
except ConnectionClosedOK:
return
def _materialize_frame(frame: WsFrame, want_bytes: bool) -> Message:
if frame.opcode == "text":
text = frame.text or ""
return text.encode("utf-8") if want_bytes else text
return base64.b64decode(frame.binary_b64 or "")
def _client_frame_matches(recorded: WsFrame, message: Message) -> bool:
opcode, text, binary_b64 = _frame_payload(message)
if recorded.opcode != opcode:
return False
if opcode == "text":
return text_frames_match(recorded.text or "", text or "")
return recorded.binary_b64 == binary_b64
def _preview(message: Message) -> str:
text = message if isinstance(message, str) else message.decode("utf-8", errors="replace")
return scrub_secrets(text)[:200]
def _preview_frame(frame: WsFrame) -> str:
if frame.opcode == "text":
return (frame.text or "")[:200]
return f"<binary {len(frame.binary_b64 or '')} b64 chars>"
class _RecordingConnect:
def __init__(
self,
real_context: WsConnectContextLike,
recorder: WsSessionRecorder,
on_done: Callable[[WsSessionRecorder], None],
) -> None:
self._real_context = real_context
self._recorder = recorder
self._on_done = on_done
async def __aenter__(self) -> RecordingConnection:
real = await self._real_context.__aenter__()
return RecordingConnection(real, self._recorder)
async def __aexit__(self, *exc_info: object) -> Optional[bool]:
try:
return await self._real_context.__aexit__(*exc_info)
finally:
self._on_done(self._recorder)
class _ReplayConnect:
def __init__(
self,
session: WsSession,
timeout: float,
on_error: Callable[[WsVcrReplayError], None],
) -> None:
self._session = session
self._timeout = timeout
self._on_error = on_error
async def __aenter__(self) -> ReplayConnection:
return ReplayConnection(self._session, self._timeout, self._on_error)
async def __aexit__(self, *exc_info: object) -> bool:
return False
class WsVcrController:
def __init__(
self,
original_connect: Callable[..., WsConnectContextLike],
cassette: Optional[WsCassette],
timeout: float,
) -> None:
self._original_connect = original_connect
self._cassette = cassette
self._timeout = timeout
self._replay_cursor = 0
self._recorded_sessions: list[WsSession] = []
self._errors: list[WsVcrReplayError] = []
self._replayed = False
self._recorded = False
def connect(self, *args: object, **kwargs: object) -> object:
if self._cassette is not None and self._replay_cursor < len(self._cassette.sessions):
session = self._cassette.sessions[self._replay_cursor]
self._replay_cursor += 1
self._replayed = True
return _ReplayConnect(session, self._timeout, self._errors.append)
self._recorded = True
recorder = WsSessionRecorder()
return _RecordingConnect(self._original_connect(*args, **kwargs), recorder, self._finish_recorder)
def _finish_recorder(self, recorder: WsSessionRecorder) -> None:
self._recorded_sessions.append(recorder.to_session())
@property
def errors(self) -> tuple[WsVcrReplayError, ...]:
return tuple(self._errors)
@property
def replayed(self) -> bool:
return self._replayed
@property
def recorded(self) -> bool:
return self._recorded
def built_cassette(self) -> Optional[WsCassette]:
if not self._recorded_sessions:
return None
return WsCassette(sessions=tuple(self._recorded_sessions))
def verdict(self) -> str:
if self._replayed and not self._recorded:
return f"[WS-VCR HIT] sessions={self._replay_cursor} frames={self._played_frame_count()}"
if self._recorded:
cassette = self.built_cassette()
frames = _cassette_frame_count(cassette) if cassette is not None else 0
return f"[WS-VCR MISS] recorded sessions={len(self._recorded_sessions)} frames={frames}"
return "[WS-VCR NOOP] (no websocket traffic)"
def _played_frame_count(self) -> int:
if self._cassette is None:
return 0
return sum(len(s.frames) for s in self._cassette.sessions[: self._replay_cursor])
def _cassette_frame_count(cassette: WsCassette) -> int:
return sum(len(s.frames) for s in cassette.sessions)
def load_ws_cassette(client: RedisLike, key: str) -> Optional[WsCassette]:
from redis.exceptions import RedisError
try:
data = client.get(key)
except RedisError as exc:
_record_cache_failure("load", exc)
message = f"WS-VCR redis load failed for {key}; treating as cache miss: {type(exc).__name__}: {exc}"
_log.warning(message)
warnings.warn(message, VCRCassetteCacheWarning, stacklevel=2)
return None
if data is None:
return None
try:
raw = data.decode("utf-8") if isinstance(data, (bytes, bytearray)) else data
return WsCassette.model_validate_json(raw)
except (ValidationError, ValueError, TypeError) as exc:
_record_cache_failure("load", exc)
message = (
f"WS-VCR redis load failed for {key}; cached payload is corrupt, "
f"treating as cache miss: {type(exc).__name__}: {exc}"
)
_log.warning(message)
warnings.warn(message, VCRCassetteCacheWarning, stacklevel=2)
return None
def save_ws_cassette(
client: RedisLike,
key: str,
cassette: WsCassette,
passed: bool,
ttl_seconds: int = CASSETTE_TTL_SECONDS,
) -> bool:
from redis.exceptions import RedisError
if not passed:
_log.info("WS-VCR redis save skipped for %s; test did not pass - leaving any prior cassette intact", key)
return False
if len(cassette.sessions) > WS_MAX_SESSIONS_PER_CASSETTE:
_log.warning(
"WS-VCR redis save refused for %s; %d sessions (> WS_MAX_SESSIONS_PER_CASSETTE=%d)",
key,
len(cassette.sessions),
WS_MAX_SESSIONS_PER_CASSETTE,
)
return False
if any(len(session.frames) > WS_MAX_FRAMES_PER_SESSION for session in cassette.sessions):
_log.warning(
"WS-VCR redis save refused for %s; a session exceeds WS_MAX_FRAMES_PER_SESSION=%d",
key,
WS_MAX_FRAMES_PER_SESSION,
)
return False
payload = cassette.model_dump_json().encode("utf-8")
try:
client.set(key, payload, ex=ttl_seconds)
except RedisError as exc:
_record_cache_failure("save", exc)
message = f"WS-VCR redis save failed for {key}; cassette not persisted: {type(exc).__name__}: {exc}"
_log.warning(message)
warnings.warn(message, VCRCassetteCacheWarning, stacklevel=2)
return False
return True
def build_ws_cassette_client(
builder: Callable[[], RedisLike] = _build_default_client,
) -> Optional[RedisLike]:
try:
return builder()
except Exception as exc:
_record_cache_failure("load", exc)
message = (
f"WS-VCR redis client unavailable; realtime tests fall back to live "
f"websocket traffic: {type(exc).__name__}: {exc}"
)
_log.warning(message)
warnings.warn(message, VCRCassetteCacheWarning, stacklevel=2)
return None

View file

@ -41,11 +41,13 @@ def fake_openai_endpoint():
# Per-item respx detection (``apply_vcr_auto_marker_to_items``) handles
# the vast majority of respx-vs-vcrpy conflicts automatically. The only
# entry below is the persister's own unit-test file, which exercises
# ``save_cassette`` / ``load_cassette`` against fakeredis and must not
# itself run under a live cassette context.
_VCR_AUTO_MARKER_SKIP_FILES = frozenset({"test_vcr_redis_persister.py"})
# the vast majority of respx-vs-vcrpy conflicts automatically. The entries
# below are the persister's and the WebSocket VCR's own unit-test files, which
# exercise ``save_cassette`` / ``load_cassette`` against fakeredis and must not
# themselves run under a live cassette context.
_VCR_AUTO_MARKER_SKIP_FILES = frozenset(
{"test_vcr_redis_persister.py", "test_ws_vcr.py"}
)
_VCR_INCOMPATIBLE_NODEID_SUFFIXES: tuple[str, ...] = ()

View file

@ -0,0 +1,89 @@
"""WebSocket VCR wiring for the realtime suite.
This directory inherits the HTTP VCR machinery from
``tests/llm_translation/conftest.py`` (which only intercepts httpx/aiohttp and
is therefore a no-op for realtime WebSocket traffic). The autouse fixture below
adds the WebSocket layer: it patches ``websockets.connect`` for the duration of
each test so realtime frames are recorded to, or replayed from, the same
cassette Redis under a ``litellm:vcr:wscassette:`` prefix.
"""
from __future__ import annotations
import os
import sys
from typing import Optional
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from tests._vcr_conftest_common import ( # noqa: E402
vcr_disabled,
vcr_outcome_logging_enabled,
)
from tests._ws_vcr import ( # noqa: E402
WsVcrController,
build_ws_cassette_client,
load_ws_cassette,
replay_timeout_seconds,
save_ws_cassette,
ws_redis_key_for,
)
_ws_cassette_client: Optional[object] = None
def _get_ws_cassette_client() -> Optional[object]:
global _ws_cassette_client
if _ws_cassette_client is None:
_ws_cassette_client = build_ws_cassette_client()
return _ws_cassette_client
def _emit_verdict(request: pytest.FixtureRequest, verdict: str) -> None:
if os.environ.get("PYTEST_XDIST_WORKER"):
return
reporter = request.config.pluginmanager.getplugin("terminalreporter")
if reporter is None:
return
reporter.write_line(f"{verdict} :: {request.node.nodeid}")
@pytest.fixture(autouse=True)
def _ws_vcr(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch):
if vcr_disabled():
yield
return
import websockets
client = _get_ws_cassette_client()
if client is None:
yield
return
key = ws_redis_key_for(request.node.nodeid)
cassette = load_ws_cassette(client, key)
controller = WsVcrController(
original_connect=websockets.connect,
cassette=cassette,
timeout=replay_timeout_seconds(),
)
monkeypatch.setattr(websockets, "connect", controller.connect)
yield
rep_call = getattr(request.node, "rep_call", None)
passed = bool(rep_call and rep_call.passed)
if controller.recorded:
built = controller.built_cassette()
if built is not None:
save_ws_cassette(client, key, built, passed=passed)
if vcr_outcome_logging_enabled():
_emit_verdict(request, controller.verdict())
if controller.errors and passed:
raise controller.errors[0]

View file

@ -0,0 +1,275 @@
from __future__ import annotations
import asyncio
import os
import sys
import warnings
import fakeredis
import pytest
from websockets.exceptions import ConnectionClosedOK
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from tests._vcr_redis_persister import ( # noqa: E402
VCRCassetteCacheWarning,
cassette_cache_health,
)
from tests._ws_vcr import ( # noqa: E402
CASSETTE_TTL_SECONDS,
RedisLike,
ReplayConnection,
WsCassette,
WsFrame,
WsSession,
WsSessionRecorder,
WsVcrContractDrift,
WsVcrReplayError,
WsVcrReplayTimeout,
build_ws_cassette_client,
load_ws_cassette,
save_ws_cassette,
scrub_secrets,
text_frames_match,
ws_redis_key_for,
)
def _server(text: str, client_frames_before: int) -> WsFrame:
return WsFrame(
direction="server_to_client",
opcode="text",
text=text,
client_frames_before=client_frames_before,
)
def _client(text: str) -> WsFrame:
return WsFrame(direction="client_to_server", opcode="text", text=text)
def _collect_errors():
errors: list[WsVcrReplayError] = []
return errors, errors.append
def test_cassette_json_roundtrip_preserves_frames_and_gate():
cassette = WsCassette(
sessions=(
WsSession(
frames=(
_server('{"type":"session.created"}', 0),
_client('{"type":"response.create"}'),
_server('{"type":"response.done"}', 1),
WsFrame(
direction="server_to_client", opcode="binary", binary_b64="dGVzdA==", client_frames_before=1
),
)
),
)
)
restored = WsCassette.model_validate_json(cassette.model_dump_json())
assert restored == cassette
assert restored.sessions[0].frames[2].client_frames_before == 1
assert restored.sessions[0].frames[3].opcode == "binary"
assert restored.sessions[0].frames[3].binary_b64 == "dGVzdA=="
def test_recorder_tracks_client_frame_count_as_causal_gate():
recorder = WsSessionRecorder()
recorder.record_server_frame('{"type":"session.created"}')
recorder.record_client_frame('{"type":"conversation.item.create"}')
recorder.record_client_frame('{"type":"response.create"}')
recorder.record_server_frame('{"type":"response.done"}')
session = recorder.to_session()
server_frames = [f for f in session.frames if f.direction == "server_to_client"]
assert server_frames[0].client_frames_before == 0
assert server_frames[1].client_frames_before == 2
async def test_replay_recv_returns_bytes_when_decode_false_and_str_otherwise():
session = WsSession(frames=(_server("hello", 0), _server("world", 0)))
_, on_error = _collect_errors()
conn = ReplayConnection(session, timeout=1.0, on_error=on_error)
as_bytes = await conn.recv(decode=False)
as_str = await conn.recv()
assert as_bytes == b"hello"
assert as_str == "world"
async def test_replay_serves_server_frame_only_after_causal_client_count_met():
session = WsSession(
frames=(
_server('{"type":"session.created"}', 0),
_client('{"type":"response.create"}'),
_server('{"type":"response.done"}', 1),
)
)
_, on_error = _collect_errors()
conn = ReplayConnection(session, timeout=2.0, on_error=on_error)
first = await conn.recv(decode=False)
assert first == b'{"type":"session.created"}'
gated = asyncio.ensure_future(conn.recv(decode=False))
await asyncio.sleep(0.1)
assert not gated.done(), "gated server frame was released before the recorded client frame was sent"
await conn.send('{"type":"response.create"}')
released = await asyncio.wait_for(gated, timeout=1.0)
assert released == b'{"type":"response.done"}'
async def test_replay_exhausted_server_frames_raise_connection_closed():
session = WsSession(frames=(_server("only", 0),))
_, on_error = _collect_errors()
conn = ReplayConnection(session, timeout=1.0, on_error=on_error)
await conn.recv(decode=False)
with pytest.raises(ConnectionClosedOK):
await conn.recv(decode=False)
async def test_replay_timeout_raises_instead_of_hanging():
session = WsSession(
frames=(
_server('{"type":"session.created"}', 0),
_server('{"type":"response.done"}', 5),
)
)
errors, on_error = _collect_errors()
conn = ReplayConnection(session, timeout=0.15, on_error=on_error)
await conn.recv(decode=False)
with pytest.raises(WsVcrReplayTimeout):
await asyncio.wait_for(conn.recv(decode=False), timeout=2.0)
assert errors and isinstance(errors[0], WsVcrReplayTimeout)
async def test_replay_accepts_client_frame_with_volatile_id_drift():
recorded_client = _client('{"type":"conversation.item.create","item":{"id":"item_ABC12345","role":"user"}}')
session = WsSession(frames=(_server("s", 0), recorded_client, _server("done", 1)))
errors, on_error = _collect_errors()
conn = ReplayConnection(session, timeout=1.0, on_error=on_error)
await conn.recv(decode=False)
await conn.send('{"type":"conversation.item.create","item":{"role":"user","id":"item_ZZ99887766"}}')
assert errors == []
assert await conn.recv(decode=False) == b"done"
async def test_replay_rejects_structurally_different_client_frame():
session = WsSession(frames=(_server("s", 0), _client('{"type":"response.create"}'), _server("done", 1)))
errors, on_error = _collect_errors()
conn = ReplayConnection(session, timeout=1.0, on_error=on_error)
await conn.recv(decode=False)
with pytest.raises(WsVcrContractDrift):
await conn.send('{"type":"session.update","session":{"voice":"alloy"}}')
assert errors and isinstance(errors[0], WsVcrContractDrift)
async def test_replay_rejects_extra_client_frame_beyond_recording():
session = WsSession(frames=(_server("s", 0), _client('{"type":"response.create"}')))
errors, on_error = _collect_errors()
conn = ReplayConnection(session, timeout=1.0, on_error=on_error)
await conn.send('{"type":"response.create"}')
with pytest.raises(WsVcrContractDrift):
await conn.send('{"type":"response.create"}')
assert errors
def test_text_frames_match_normalizes_ids_and_timestamps_but_not_structure():
assert text_frames_match(
'{"type":"x","event_id":"evt_111","ts":"2026-05-25T03:40:37.262045Z"}',
'{"type":"x","event_id":"evt_999","ts":"2026-06-01T10:00:00Z"}',
)
assert not text_frames_match('{"type":"x","text":"hi"}', '{"type":"x","text":"bye"}')
assert not text_frames_match('{"type":"x"}', '{"type":"x","extra":1}')
def test_scrub_secrets_removes_auth_material():
scrubbed = scrub_secrets("Authorization: Bearer sk-abcdef123456 and key xai-zzz99988877 raw sk-plainkey123")
assert "sk-abcdef123456" not in scrubbed
assert "xai-zzz99988877" not in scrubbed
assert "sk-plainkey123" not in scrubbed
assert "Bearer <redacted>" in scrubbed
def test_recorder_scrubs_secrets_in_stored_frames():
recorder = WsSessionRecorder()
recorder.record_client_frame('{"authorization":"Bearer sk-supersecretvalue"}')
stored = recorder.to_session().frames[0].text
assert stored is not None
assert "sk-supersecretvalue" not in stored
def _sample_cassette() -> WsCassette:
return WsCassette(sessions=(WsSession(frames=(_server('{"type":"session.created"}', 0),)),))
def test_save_sets_24h_ttl_and_load_roundtrips():
fake = fakeredis.FakeStrictRedis()
key = ws_redis_key_for("tests/llm_translation/realtime/test_x.py::test_y")
assert save_ws_cassette(fake, key, _sample_cassette(), passed=True) is True
ttl = fake.ttl(key)
assert CASSETTE_TTL_SECONDS - 5 <= ttl <= CASSETTE_TTL_SECONDS
loaded = load_ws_cassette(fake, key)
assert loaded == _sample_cassette()
def test_save_skipped_when_test_failed_leaves_no_key():
fake = fakeredis.FakeStrictRedis()
key = ws_redis_key_for("tests/llm_translation/realtime/test_x.py::test_fail")
assert save_ws_cassette(fake, key, _sample_cassette(), passed=False) is False
assert fake.get(key) is None
def test_save_skipped_when_test_failed_preserves_prior_cassette():
fake = fakeredis.FakeStrictRedis()
key = ws_redis_key_for("tests/llm_translation/realtime/test_x.py::test_keep")
save_ws_cassette(fake, key, _sample_cassette(), passed=True)
newer = WsCassette(sessions=(WsSession(frames=(_server('{"type":"other"}', 0),)),))
assert save_ws_cassette(fake, key, newer, passed=False) is False
assert load_ws_cassette(fake, key) == _sample_cassette()
def test_load_missing_key_returns_none():
fake = fakeredis.FakeStrictRedis()
assert load_ws_cassette(fake, ws_redis_key_for("never/recorded")) is None
def test_ws_redis_key_uses_distinct_prefix():
key = ws_redis_key_for("tests/llm_translation/realtime/test_x.py::TestY::test_z")
assert key.startswith("litellm:vcr:wscassette:")
assert "::" not in key
def test_build_ws_cassette_client_warns_and_counts_failure_instead_of_silently_disabling():
def _broken_builder() -> RedisLike:
raise ValueError("invalid CASSETTE_REDIS_URL")
failures_before = cassette_cache_health()["load_failures"]
with pytest.warns(VCRCassetteCacheWarning, match="fall back to live websocket traffic"):
assert build_ws_cassette_client(builder=_broken_builder) is None
assert cassette_cache_health()["load_failures"] == failures_before + 1
def test_build_ws_cassette_client_returns_built_client_without_warning():
fake = fakeredis.FakeStrictRedis()
with warnings.catch_warnings():
warnings.simplefilter("error", VCRCassetteCacheWarning)
assert build_ws_cassette_client(builder=lambda: fake) is fake