mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
* 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
275 lines
9.7 KiB
Python
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
|