litellm/tests/llm_translation/test_ws_vcr.py
Mateo Wang cd6e8cdf23
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
2026-07-08 00:19:06 -07:00

275 lines
9.7 KiB
Python

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